/*********************************************************************** * Copyright © 2024-2025 Guilhem Moulin * Popup and feature overlays * * 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 Overlay from 'ol/Overlay.js'; import Stroke from 'ol/style/Stroke.js'; import Style from 'ol/style/Style.js'; import VectorTileLayer from 'ol/layer/VectorTile.js'; import { Popover } from 'bootstrap'; /* return an tag with the given URL and optional text */ const reURL = new RegExp('^https?://', 'i'); const formatLink = function(url, text) { if (url == null || typeof url !== 'string' || !reURL.test(url)) { return url; } const a = document.createElement('a'); a.href = url; a.target = '_blank'; if (text != null && text !== '') { const t = document.createTextNode(text + ' '); a.appendChild(t); } const i = document.createElement('i'); i.classList.add('bi', 'bi-box-arrow-up-right'); a.appendChild(i); return a; }; /* test a condition on the field maps */ const condField = function(cond, k) { if (Array.isArray(cond)) { return cond.includes(k); } if (cond instanceof RegExp) { return cond.test(k); } return cond(k); }; /* filter fields by condition */ const filterFields = function(k, fields) { return fields.map(function(v) { if (v.cond == null || condField(v.cond, k)) { return v; } }).filter((f) => f != null); }; /* filter fields using a pre-built map */ const mapFields = function(k, fieldMap, fields) { if (fields === undefined) { return fieldMap.map((v) => k[v]); } return fields.map(function(v) { if (!Array.isArray(v)) { return fieldMap[v]; } else if (condField(v[1], k)) { return fieldMap[v[0]]; } }).filter((f) => f !== undefined); }; /* pre-build the field map so we don't need to duplicate objects accross layers */ const mkFieldMap = function(fieldMap) { return Object.fromEntries(Object.entries(fieldMap).map(function([k, o]) { if (typeof o === 'string') { return [k, {key: k, desc: o}]; } else { return [k, Object.assign(o, {key: k})]; } })); }; const LAYERS = { svk: { ledningar: { title: 'Kraftledning (befintlig)', fields: [ { key: 'Placement', desc: 'Förläggning' }, { key: 'Voltage', desc: 'Spänning', unit: 'kV' }, { key: 'geom_length', desc: 'Ledlängd', fn: 'length' }, ], }, transmissionsnatsprojekt: { title: 'Transmissionsnätsprojekt', fields: [ { key: 'Name', desc: 'Projektnamn' }, { key: 'Voltage', desc: 'Spänning', unit: 'kV' }, { key: 'Url', desc: 'Länk', fn: formatLink }, ], }, }, misc: { gigafactories: { title: 'Stor industrisatsning', fields: [ { key: 'Name', desc: 'Namn' }, { key: 'Url', desc: 'Länk', fn: formatLink }, ], }, dammar: { /* Documentation at * https://www.smhi.se/polopoly_fs/1.34541!/dammprod%202013_3%2C%20beskrivning%2C%20SVAR2012_2.pdf */ title: 'Damm', fields: [ { key: 'DNamn', desc: 'Dammenhetens namn' }, { key: 'Namn', desc: 'Dammanläggningens namn' }, { key: 'LST_OBJID', desc: 'Länsnr', classes: ['feature-objid'] }, { key: 'Status', desc: 'Status', fn: (v) => v === 1 ? 'Befintlig damm' : v === 2 ? 'Fd. damm' : '' }, //{ key: 'Regleringstyp', desc: 'Regleringstyp' }, { key: 'ByggAr', desc: 'Byggår' }, { key: 'DammHojd', desc: 'Dammhöjd', unit: 'm' }, { key: 'KronLangd', desc: 'Krönlängd', unit: 'm' }, { key: 'Fiskvag', desc: 'Fiskväg', fn: (v) => v === 1 ? 'Bassängtrappa' : v === 2 ? 'Denilränna' : v === 3 ? 'Slitsränna' : v === 4 ? 'Omlöp' : v === 5 ? 'Inlöp' : v === 6 ? 'Ålledare' : v === 7 ? 'Smoltränna' : v === 8 ? 'Okänd typ' : v === 9 ? 'Ingen' : v === 10 ? 'Annan' : null }, { key: 'HARO', desc: 'Huvudavrinningsområdesnummer', classes: ['feature-objid'] }, { key: 'Vattendistrikt', desc: 'Vattendistrikt', classes: ['feature-objid'] }, { key: 'Verksamhet', desc: 'Verksamhet', fn: (v) => v === 1 ? 'Kraftproduktion' : v === 2 ? 'Industri' : v === 3 ? 'Sjöfart' : v === 4 ? 'Invallning' : v === 5 ? 'Vattenförsörjning' : v === 6 ? 'Spegeldamm' : v === 7 ? 'Historisk' : v === 8 ? 'Övrigt' : null }, { key: 'DG', desc: 'Högsta dämningsgräns', unit: 'm' }, { key: 'SG', desc: 'Lägsta sänkningsgräns', unit: 'm' }, { key: 'MY', desc: 'Magasinsyta', unit: 'km²' }, { key: 'RV', desc: 'Reglerbar volym', unit: 'Mm³' }, { key: 'Kommentar', desc: 'Kommentar' }, ], }, }, }; LAYERS.mrr = {}; (function() { const fields = [ { key: 'name', desc: 'Namn' }, { key: 'mineral', desc: 'Koncessionsmineral', cond: (i) => i < 6 }, { key: 'owners', desc: 'Ägare', cond: [0,2,4] }, { key: 'owners', desc: 'Sökande', cond: [1,3,5] }, { key: 'conc_name', desc: 'Tillhörande bearbetnings\u00ADkoncession(er)', cond: [6] }, { key: 'licenceid', desc: 'Tillståndsid', classes: ['feature-attr-mrr-license-id'], cond: [0,2,4,6] }, { key: 'geom_area', desc: 'Areal', fn: 'area' }, { key: 'validfrom', desc: 'Giltig från', cond: [0,2,4] }, { key: 'validto', desc: 'Giltig till', cond: [0,2,4] }, { key: 'diarynr', desc: 'Diarienummer', classes: ['feature-attr-dnr'] }, { key: 'appl_date', desc: 'Ansökningsdatum' }, { key: 'dec_date', desc: 'Beslutsdatum', cond: [0,2,4,6] }, ]; Object.entries({ ec: 'Bearbetningskoncession', met: 'Undersökningstillstånd, metaller och industrimineral', ogd: 'Undersökningstillstånd, olja, gas och diamant', }) .flatMap(([k, title]) => [ /* don't use Object.entries() to guaranty ordering */ ['appr', 'beviljad'], /* even index */ ['appl', 'ansökt'], /* odd index */ ].map(([a,b]) => [a + '_' + k, title + ' \u2013 ' + b])) .concat([['appr_dl', 'Markanvisning till koncession']]) /* index #6 */ .forEach(([k, title], idx) => LAYERS.mrr[k] = { title, fields: filterFields(idx, fields) }); })(); LAYERS.vbk = {}; (function() { const fieldMap = mkFieldMap({ Projektnamn: 'Projektnamn', OmrID: { desc: 'Områdes-ID', classes: ['feature-objid'] }, AntalVerk: 'Aktuella verk', AntalEjXY: 'Antal ej koordinatsatta verk', Projektstatus: 'Projektstatus', Diarienummer: 'Diarienummer', geom_area: { desc: 'Areal', fn: 'area' }, Calprod: { desc: 'Beräknad årsproduktion', unit: 'GWh' }, PlaneradByggstart: 'Planerad byggstart', PlaneratDrift: 'Planerat drifttagande', AndringsansokanPagar: 'Ändringsansökan pågår', UnderByggnation: 'Under byggnation', Organisationsnamn: 'Verksamhetsutövare', Organisationsnummer: { desc: 'Organisationsnummer', classes: ['feature-orgnr'] }, SamradsunderlagInlamnat: 'Samrådsunderlag inlämnat', AnsokanInlamnat: 'Tillståndsansökan inlämnad', AnsokanAterkallad: 'Tillståndsansökan återkallad', AnsokanBeviljad: 'Tillståndsansökan beviljad', AnsokanAvslagen: 'Tillståndsansökan avslagen', AnsokanOverklagad: 'Överklagad', Natura2000_Ansokan: 'Natura2000 ansökan', Natura2000_Beslutdatum: 'Natura2000 beslutsdatum', Uppfort: 'Parken uppförd', PlaneratAntalVerkMin: 'Planerat antal verk (min)', PlaneratAntalVerkMax: 'Planerat antal verk (max)', PlaneradHojdMin: { desc: 'Panerad totalhöjd (min)', unit: 'm' }, PlaneradHojdMax: { desc: 'Panerad totalhöjd (max)', unit: 'm' }, PlaneradProduktionMin: { desc: 'Planerad årsproduktion (min)', unit: 'GWh' }, PlaneradProduktionMax: { desc: 'Planerad årsproduktion (max)', unit: 'GWh' }, BeviljatAntalVerk: 'Beviljat antal verk', UppfortAntalVerk: 'Uppfört antal verk', BeviljadMaxhojd: { desc: 'Beviljad maxhöjd', unit: 'm' }, InstalleradEffekt: { desc: 'Installerad effekt', unit: 'MW' }, ElNamn: 'Elområde', SenasteUppdaterat: 'Senast uppdaterat', }); Object.entries({ current: null, notcurrent: ' \u2013 ej aktuell', }) .forEach(([k, title]) => LAYERS.vbk['area_' + k] = { title: 'Landbaserad projekteringsområde för vindkraft' + (title ?? ''), fields: mapFields(k, fieldMap, [ 'Projektnamn', 'OmrID', 'AntalVerk', 'AntalEjXY', 'geom_area', 'Calprod', 'PlaneradByggstart', 'PlaneratDrift', 'AndringsansokanPagar', ['UnderByggnation', ['current']], 'Organisationsnamn', 'Organisationsnummer', 'ElNamn', 'SenasteUppdaterat', ]), }); [ ['completed', /* 0 */ 'uppförd'], ['approved', /* 1 */ 'tillståndsansökan beviljad'], ['amended', /* 2 */ 'ändringsansökan'], ['rejected', /* 3 */ 'tillståndsansökan avslagen'], ['appealed', /* 4 */ 'överklagad'], ['applied', /* 5 */ 'tillståndsansökan inlämnad'], ['consultation', /* 6 */ 'samråd inför tillståndsansökan'], ['investigation', /* 7 */ 'inledande undersökningar'], ['revoked', /* 8 */ 'inte aktuell eller återkallad'], ] .forEach(([k, title], idx) => LAYERS.vbk['offshore_' + k] = { title: 'Havsbaserad vindkraft \u2013 ' + title, fields: mapFields(idx, fieldMap, [ 'Projektnamn', 'OmrID', 'Organisationsnamn', 'Organisationsnummer', 'Projektstatus', 'Diarienummer', ['AndringsansokanPagar', [1,2,4]], 'geom_area', ['SamradsunderlagInlamnat', (i) => i <= 6 || i === 8], ['AnsokanInlamnat', (i) => i <= 5 || i === 8], ['AnsokanAterkallad', [8]], ['AnsokanBeviljad', [0,1,4,8]], ['AnsokanAvslagen', [3,8]], ['AnsokanOverklagad', [0,1,3,4,8]], ['Natura2000_Ansokan', (i) => i !== 2], ['Natura2000_Beslutdatum', (i) => i !== 2], ['UnderByggnation', [1]], ['PlaneratAntalVerkMin', (i) => i > 0], ['PlaneratAntalVerkMax', (i) => i > 0], ['PlaneradHojdMin', (i) => i > 0], ['PlaneradHojdMax', (i) => i > 0], ['PlaneradProduktionMin', (i) => i > 0], ['PlaneradProduktionMax', (i) => i > 0], ['PlaneradByggstart', (i) => i > 0], ['Uppfort', [0,8]], ['PlaneratDrift', (i) => i > 0], ['BeviljatAntalVerk', [0,1,4,8]], ['UppfortAntalVerk', [0,8]], ['BeviljadMaxhojd', [0,1,4,8]], ['InstalleradEffekt', [0]], ['Calprod', [0]], 'ElNamn', 'SenasteUppdaterat', ]), }); Object.assign(fieldMap, mkFieldMap({ VerkID: { desc: 'Verk-ID', classes: ['feature-objid'] }, Status: 'Status', Handlingstyp: 'Handlingstyp', MB_Tillstand: 'Miljöbalken tillstånd tidsbegränsning', Uppfort: 'Uppförandedatum',/* override previous def */ Totalhojd: { desc: 'Totalhöjd', unit: 'm' }, Navhojd: { desc: 'Navhöjd', unit: 'm' }, Rotordiameter: { desc: 'Rotordiameter', unit: 'm' }, Maxeffekt: { desc: 'Maxeffekt', unit: 'MW' }, Fabrikat: 'Fabrikat', Modell: 'Modell', Placering: 'Placering', })); [ ['completed', /* 0 */ 'uppfört'], ['approved', /* 1 */ 'beviljat'], ['rejected', /* 2 */ 'avslagit/nekat'], ['processed', /* 3 */ 'handlagt'], ['dismounted', /* 4 */ 'nedmonterat'], ['appealed', /* 5 */ 'överklagat'], ['revoked', /* 6 */ 'inte aktuell eller återkallad'], ] .forEach(([k, title], idx) => LAYERS.vbk['station_' + k] = { title: 'Landbaserad vindkraftverk \u2013 ' + title, fields: mapFields(idx, fieldMap, [ 'VerkID', 'OmrID', 'Projektnamn', 'Status', 'Handlingstyp', ['Uppfort', [0,4,6]], 'MB_Tillstand', 'Totalhojd', 'Navhojd', 'Rotordiameter', 'Maxeffekt', 'Calprod', 'Fabrikat', 'Modell', 'Organisationsnamn', 'Organisationsnummer', 'Placering', 'ElNamn', 'SenasteUppdaterat', ]), }); })(); LAYERS.avverk = {}; (function() { const zeroIsNull = (v) => v > 0 ? v : null; const fieldMap = mkFieldMap({ /* Documentation at * https://www.skogsstyrelsen.se/globalassets/sjalvservice/karttjanster/geodatatjanster/produktbeskrivningar/utforda-avverkningar---produktbeskrivning.pdf * and * https://www.skogsstyrelsen.se/globalassets/sjalvservice/karttjanster/geodatatjanster/produktbeskrivningar/yttre-granser-for-avverkningsanmalda-omraden---produktbeskrivning.pdf */ Beteckn: { desc: 'Ärendebeteckning', classes: ['feature-objid'] }, ArendeAr: 'Registeringsår', Inkomdatum: 'Inkom datum', Skogstyp: 'Skogstyp', Avvdatum: 'Datum för avverkning', KallaDatum: 'Ursprung för datum för avverkning', AnmaldHa: { desc: 'Areal anmält', unit: 'ha' }, NatforHa: { desc: 'Areal naturlig föryngring', unit: 'ha', fn: zeroIsNull }, SkogsodlHa: { desc: 'Areal plantering', unit: 'ha', fn: zeroIsNull }, AvvSasong: 'Avverkningssäsong', Avverktyp: 'Avverkningstyp', ArendeStatus: 'Ärendestatus', AvvHa: { desc: 'Avverkad areal', unit: 'ha' }, geom_area: { desc: 'Areal för ytan', fn: 'area' }, }); LAYERS.avverk.utford = { title: 'Utförd avverkning', fields: mapFields(fieldMap, [ 'Beteckn', 'ArendeAr', 'Skogstyp', 'AnmaldHa', 'NatforHa', 'Avverktyp', 'Avvdatum', 'KallaDatum', 'geom_area', ]), }; LAYERS.avverk.anmald = { title: 'Avverkningsanmälansområde', fields: mapFields(fieldMap, [ 'Beteckn', 'Inkomdatum', 'ArendeAr', 'AnmaldHa', 'NatforHa', 'SkogsodlHa', 'AvvSasong', 'ArendeStatus', 'AvvHa', ]), }; })(); LAYERS.skydd = {}; (function() { const fieldMap = mkFieldMap({ NVRID: { desc: 'NVR-ID', classes: ['feature-objid'] }, FORSKRNAMN: 'Föreskriftsområde', OBJEKTNAMN: 'Namn', NAMN: 'Namn', BESLSTAT: 'Beslutsstatus', FORESKRTYP: 'Föreskriftstyp', FORESKRIFT: 'Föreskriftssubtyp', FRANDATUM: 'Från datum', TILLDATUM: 'Till datum', BESKRIVN: 'Beskrivning', geom_area: { desc: 'Areal', fn: 'area' }, SKYDDSTYP: 'Skyddstyp', BESLSTATUS: 'Beslutsstatus', URSBESLDAT: 'Beslutsdatum (bildande)', URSGALLDAT: 'Ursprungligt gällandedatum', SENGALLDAT: 'Senaste gällandedatum', FORVALTARE: 'Förvaltare', IUCNKAT: 'IUCN-kategori', DIARIENR: { desc: 'Diarienummer', classes: ['feature-attr-dnr'] }, LAGRUM: 'Lagrum', BESLMYND: 'Beslutsmyndighet', LAND_HA: { desc: 'Areal land', unit: 'ha' }, VATTEN_HA: { desc: 'Areal vatten', unit: 'ha' }, SKOG_HA: { desc: 'Skogsmarksareal', unit: 'ha' }, IKRAFTDATF: 'Ikraftträdandedatum föreskrifter', TILLSYNSMH: 'Tillsynsmyndighet', PROVNMHTIL: 'Prövningsmyndighet tillstånd', PROVNMHDIS: 'Prövningsmyndighet dispens', NAME: 'Namn', RAMSAR_ID: { desc: 'Ramsar-ID', classes: ['feature-objid'] }, LEGAL_ACT: 'Rättsakt', URSPR_BESL: 'Ursprungligt beslutsdatum', SEN_BESLUT: 'Senaste beslutsdatum', LINK: { desc: 'Länk', fn: formatLink }, }); LAYERS.skydd.tilltradesforbud = { title: 'Tillträdesförbud', fields: mapFields(fieldMap, [ 'NVRID', 'FORSKRNAMN', 'OBJEKTNAMN', 'BESLSTAT', 'FORESKRTYP', 'FORESKRIFT', 'FRANDATUM', 'TILLDATUM', 'BESKRIVN', 'geom_area', ]), }; /* Nationella skyddsformer från Naturvårdsregistret */ const isSurface = (k) => !/_punkt$/.test(k); Object.entries({ nationalpark: 'Nationalpark', naturreservat: 'Naturreservat', naturreservat_kommunalt: 'Kommunalt naturreservat', naturvardsomrade: 'Naturvårdsområde', djur_och_vaxtskyddsomrade: 'Djur- och växtskyddsområde', kulturreservat: 'Kulturreservat', vattenskyddsomrade: 'Vattenskyddsområden', landskapsbildsskyddsomrade: 'Landskapsbildsskyddsområde', ovrigt_biotopskyddsomrade: 'Biotopskydd utanför skogsmark', naturminne_yta: 'Naturminne (yta)', naturminne_punkt: 'Naturminne (punkt)', interimistiskt_forbud: 'Interimistiskt förbud', }) .forEach(([k, title]) => LAYERS.skydd[k] = { title: title, fields: mapFields(k, fieldMap, [ 'NVRID', 'NAMN', 'SKYDDSTYP', 'BESLSTATUS', 'URSBESLDAT', ['URSGALLDAT', (k) => k !== 'vattenskyddsomrade'], ['SENGALLDAT', (k) => k !== 'vattenskyddsomrade'], ['FORVALTARE', (k) => k !== 'vattenskyddsomrade'], ['IKRAFTDATF', (k) => k === 'vattenskyddsomrade'], 'IUCNKAT', 'DIARIENR', 'LAGRUM', 'BESLMYND', ['TILLSYNSMH', (k) => k === 'vattenskyddsomrade'], ['PROVNMHTIL', (k) => k === 'vattenskyddsomrade'], ['PROVNMHDIS', (k) => k === 'vattenskyddsomrade'], ['geom_area', isSurface], ['LAND_HA', isSurface], ['VATTEN_HA', isSurface], ['SKOG_HA', isSurface], ]), }); /* Natura 2000-områden */ (function() { const fields = [ { key: 'SITE_CODE', desc: 'Områdeskod', classes: ['feature-objid'] }, { key: 'NAMN', desc: 'Namn' }, { key: 'OMRADESTYP', desc: 'Områdestyp' }, { key: 'UPPLAMNARE', desc: 'Uppgiftslämnare' }, { key: 'SPA_DATUM', desc: 'SPA-datum' }, { key: 'SCI_FORSL', desc: 'SCI-förslagsdatum' }, { key: 'SCI_DATUM', desc: 'SCI-datum' }, { key: 'SAC_DATUM', desc: 'SAC-datum' }, fieldMap.geom_area, { key: 'KVALITET', desc: 'Kvalitet' }, { key: 'KARAKTAR', desc: 'Kännetecken för området' }, { key: 'ARTER', desc: 'Arter' }, { key: 'NATURTYPER', desc: 'Naturtyper' }, { key: 'BEVPLAN', desc: 'Bevarandeplan', fn: formatLink }, ]; Object.entries({ fageldirektivet: 'Fågeldirektivet (SPA)', habitatdirektivet: 'Art- och habitatdirektivet (SCI)', }) .forEach(([k, title]) => LAYERS.skydd[k] = { title, fields }); })(); /* Områden med internationell status */ LAYERS.skydd.helcom = { title: 'Marina skyddade områden (Helcom MPA)', fields: mapFields(fieldMap, [ 'NAME', 'geom_area' ]), }; LAYERS.skydd.ramsar = { title: 'Ramsar-områden (Våtmarkskonventionen)', fields: mapFields(fieldMap, [ 'RAMSAR_ID', 'SKYDDSTYP', 'NAMN', 'geom_area', 'LAND_HA', 'VATTEN_HA', 'SKOG_HA', 'URSPR_BESL', 'SEN_BESLUT', 'LEGAL_ACT', 'LINK', ]), }; LAYERS.skydd.ospar = { title: 'Marina skyddade områden (Ospar MPA)', fields: [ { key: 'ORIGIN', desc: 'Ursprung' }, { key: 'NAMN_N2000', desc: 'N2000-namn' }, { key: 'MPA_ID', desc: 'MPA-ID', classes: ['feature-objid'] }, { key: 'MPA_NAMN', desc: 'MPA-namn' }, { key: 'N2000_SITE', desc: 'N2000-ID', classes: ['feature-objid'] }, fieldMap.geom_area, ], }; LAYERS.skydd.varldsarv = { title: 'Världsarv med mycket höga naturvärden (Unesco)', fields: mapFields(fieldMap, [ 'NAMN', 'geom_area' ]), }; LAYERS.skydd.naturvardsavtal = { title: 'Naturvårdsavtal (Naturvårdsverket, Länsstyrelsen)', fields: [ { key: 'ID', desc: 'ID', classes: ['feature-objid'] }, { key: 'OBJNAMN', desc: 'Namn' }, { key: 'FASTBET', desc: 'Fastighet', classes: ['feature-objid'] }, { key: 'DATSTART', desc: 'Giltig från' }, { key: 'DATSLUT', desc: 'Giltig till' }, { key: 'DIARIENRNV', desc: 'Diarienummer', classes: ['feature-attr-dnr'] }, { key: 'STATUS', desc: 'Satus' }, fieldMap.geom_area, ], }; })(); (function() { const fieldMap = mkFieldMap({ Beteckn: { desc: 'Ärendebeteckning', classes: ['feature-objid'] }, Biotyp: { desc: 'Biotopkategori' }, Naturtyp: { desc: 'Skogstyp' }, ArendeAr: { desc: 'Registeringsår' }, geom_area: { desc: 'Areal', fn: 'area' }, AreaProd: { desc: 'Skogsmarksareal', unit: 'ha' }, Datbeslut: { desc: 'Beslutsdatum' }, Url: { desc: 'Länk', fn: (v) => formatLink(v, 'Skogens Pärlor') }, NvaTyp: 'Biotopkategori', DatAvtal: 'Avtalsdatum', Undertyp: 'Undertyp', AvtalatDatum: 'Avtalat datum', Objnamn: 'Objektnamn', Datinv: 'Datum för fältinventering', }); LAYERS.skydd.skogligt_biotopskyddsomrade = { title: 'Biotopskydd i skogsmark', fields: mapFields(fieldMap, [ 'Beteckn', 'Biotyp', 'Naturtyp', 'ArendeAr', 'geom_area', 'AreaProd', 'Datbeslut', 'Url', ]), }; LAYERS.skydd.naturvardsavtal_skogsstyrelsen = { title: 'Naturvårdsavtal (Skogsstyrelsen)', fields: mapFields(fieldMap, [ 'Beteckn', 'ArendeAr', 'NvaTyp', 'Naturtyp', 'DatAvtal', 'geom_area', 'AreaProd', 'Url', 'Undertyp', ]), }; LAYERS.skydd.atervatningsavtal = { title: 'Återvätningsavtal', fields: mapFields(fieldMap, [ 'Beteckn', 'ArendeAr', 'AvtalatDatum', 'geom_area', 'Url', ]), }; LAYERS.nv = {}; Object.assign(fieldMap, mkFieldMap(Object.fromEntries( [1,2,3].map((i) => [`Biotop${i}`, `Biotoptyp #${i}`]).concat( [1,2,3,4,5,6,7,8].map((i) => [`Beskrivn${i}`, `Nyckelord #${i} som beskriver objektet`]) )))); LAYERS.nv.naturvarde_sks = { title: 'Objekt med naturvärden (Skogsstyrelsen)', fields: mapFields(fieldMap, [ 'Beteckn', 'Objnamn', 'Datinv', 'Biotop1', 'Biotop2', 'Biotop3', 'Beskrivn1', 'Beskrivn2', 'Beskrivn3', 'geom_area', 'Url', ]), }; LAYERS.nv.nyckelbiotop = { title: 'Nyckelbiotop (Skogsstyrelsen)', fields: mapFields(fieldMap, [ 'Beteckn', 'Objnamn', 'Datinv', 'Biotop1', 'Biotop2', 'Biotop3', 'Beskrivn1', 'Beskrivn2', 'Beskrivn3', 'Beskrivn4', 'Beskrivn5', 'Beskrivn6', 'Beskrivn7', 'Beskrivn8', 'geom_area', 'Url', ]), }; LAYERS.nv.nyckelbiotop_storskogsbruk = { title: 'Nyckelbiotop (storskogsbruket)', fields: [ { key: 'Org', desc: 'Uppgifter lämnade av' }, { key: 'InkomDatum', desc: 'Inkom datum' }, fieldMap.geom_area, fieldMap.Url, ], }; LAYERS.nv.sumpskog = { title: 'Sumpskog', fields: [ { key: 'Namn', desc: 'Objektnamn' }, { key: 'Tradtext', desc: 'Skogstyp' }, { key: 'Hydrtext', desc: 'Hydrologisk typ' }, { key: 'Delklass', desc: 'Klass på delobjektet' }, { key: 'Klassu', desc: 'Klass på objektet' }, { key: 'Lovandel', desc: 'Andel löv' }, { key: 'Andelva', desc: 'Andel öppet vatten' }, { key: 'Krontakn', desc: 'Krontäckning' }, { key: 'Huggklas', desc: 'Huggningsklass' }, { key: 'Ingrepp', desc: 'Ingrepp på delobjekt (max 4)' }, { key: 'Ingrpavv', desc: 'Grad av påverkan på delobjekt (max 4)' }, { key: 'Objnyck', desc: 'Nyckelord på objektnivå' }, { key: 'Delnyck', desc: 'Nyckelord på delobjektsnivå' }, { key: 'Flygar', desc: 'Flygbildsår' }, { key: 'Faltdat', desc: 'Datum för fältbesök' }, { key: 'Invtekn', desc: 'Inventeringsteknik' }, { key: 'Invdat', desc: 'Inventeringdatum' }, { key: 'Ansvmynd', desc: 'Ansvarig myndighet' }, fieldMap.geom_area, fieldMap.Url, ], }; })(); LAYERS.nv.pagaende_naturreservatsbildning = { title: 'Pågående naturreservatsbildning', fields: [ { key: 'NAMN', desc: 'Objektnamn' }, /* XXX unclear what "GRANSJUST" means, just a guess */ { key: 'GRANSJUST', desc: 'Senast justerat' }, { key: 'geom_area', desc: 'Areal', fn: 'area' }, ], }; LAYERS.nv.snus = { title: 'Skyddsvärd statlig skog', fields: [ { key: 'NAMN', desc: 'Objektnamn' }, { key: 'AR', desc: 'År' }, { key: 'NATURGEOGR', desc: 'Naturgeografisk region', classes: ['feature-objid'] }, { key: 'OBJEKTKATE', desc: 'Objektskategori', classes: ['feature-objid'] }, { key: 'MARKAGARE', desc: 'Markägare' }, { key: 'VARDEKARNA', desc: 'Areal värdekärna', unit: 'ha' }, { key: 'UTV_MARK', desc: 'Areal utvecklingsmark', unit: 'ha' }, { key: 'TOTAL_AREA', desc: 'Totalareal', unit: 'ha' }, { key: 'LAND', desc: 'Areal land', unit: 'ha' }, { key: 'VATTEN', desc: 'Areal vatten', unit: 'ha' }, { key: 'PROD_SKOG', desc: 'Areal produktiv skogsmark', unit: 'ha' }, { key: 'SKOG_O_FJG', desc: 'Areal produktiv skogsmark ovanför fjällnära gräns', unit: 'ha' }, { key: 'SKOG_N_FJG', desc: 'Areal produktiv skogsmark nedanför fjällnära gräns', unit: 'ha' }, { key: 'SKYDDSZON', desc: 'Areal skyddszon', unit: 'ha' }, { key: 'ARRO_MARK', desc: 'Areal arronderingsmark', unit: 'ha' }, { key: 'KRITERIER', desc: 'Kriterier för urval' }, { key: 'BESKRIVN', desc: 'Beskrivning av området' }, { key: 'LST_BEDOMN', desc: 'Länsstyrelsens bedömning' }, { key: 'KALLOR', desc: 'Källor' }, ], }; LAYERS.ri = {}; (function() { const fieldMap = mkFieldMap({ NAMN: 'Namn', SKYDD: 'Skydd', AMNESOMRAD: 'Ämnesområde', AMNESOMR: 'Ämnesområde', OMRADESNR: { desc: 'Områdesnummer', classes: ['feature-objid'] }, BESKRIVNIN: { desc: 'Beskrivning', fn: formatLink }, LANK_VARDE: { desc: 'Länk värdebeskrivning', fn: formatLink }, LAGRUM: 'Lagrum', BESLUTSDAT: 'Beslutsdatum', BESLDATUM: 'Beslutsdatum', ARENDENR: { desc: 'Ärendenummer', classes: ['feature-attr-dnr'] }, LANK_BESLU: { desc: 'Länk beslut', fn: formatLink }, AKTIVITET: 'Aktivitet', NATURTYP: 'Naturtyp', ORGINALID: { desc: 'Original-ID', classes: ['feature-objid'] }, RIKSID: { desc: 'Riks-ID', classes: ['feature-objid'] }, geom_area: { desc: 'Areal', fn: 'area' }, AREA_LAND_: { desc: 'Areal land', unit: 'ha' }, AREA_VATTE: { desc: 'Areal vatten', unit: 'ha' }, }); LAYERS.ri.naturvard = { title: 'Riksintresse naturvård', fields: mapFields(fieldMap, [ 'NAMN', 'SKYDD', 'AMNESOMRAD', 'BESKRIVNIN', 'LAGRUM', 'BESLUTSDAT', 'ORGINALID', 'RIKSID', 'geom_area', ]), }; LAYERS.ri.friluftsliv = { title: 'Riksintresse friluftsliv', fields: mapFields(fieldMap, [ 'NAMN', 'SKYDD', 'AMNESOMR', 'OMRADESNR', 'LANK_VARDE', 'LAGRUM', 'BESLDATUM', 'ARENDENR', 'LANK_BESLU', 'AKTIVITET', 'NATURTYP', 'geom_area', 'AREA_LAND_', 'AREA_VATTE', ]), }; Object.assign(fieldMap, mkFieldMap({ METODBESKR: 'Metodbeskrivning', TILLKDATUM: 'Tillkomstdatum', REVDATUM: 'Revisionsdatum', ANM: 'Anmärkning', OBJEKTLANK: { desc: 'Objektlänk', fn: formatLink }, REFERENS: 'Referens', OBJTYP: 'Objekttyp', ORIGINALID: fieldMap.ORGINALID, DIG_SKALA: { desc: 'Digitaliseringsskala', fn: (v) => v > 0 ? v : null }, })); [ ['rorligt_friluftsliv', /* 0 */ 'rörligt friluftsliv (MB 4 kap 1§ och 2§)'], ['obruten_kust', /* 1 */ 'obruten kust (MB 4 kap 3§)'], ['obrutet_fjall', /* 2 */ 'obrutet fjäll (MB 4 kap 5§)'], ['skyddade_vattendrag', /* 3 */ 'skyddade vattendrag (MB 4 kap 6§)'], ] .forEach(([k, title], idx) => LAYERS.ri[k] = { title: 'Riksintresse ' + title, fields: mapFields(idx, fieldMap, [ 'NAMN', 'BESKRIVNIN', 'METODBESKR', 'TILLKDATUM', 'REVDATUM', ['OBJTYP', [1]], ['ANM', [0,1,3]], ['DIG_SKALA', [3]], 'OBJEKTLANK', 'geom_area', 'ORIGINALID', 'REFERENS', ]), }); })(); LAYERS.ren = { betesomrade: { title: 'Samebyarnas betesområde', fields: [ { key: 'NAMN', desc: 'Sameby' }, { key: 'SAMEBY_TYP', desc: 'Samebys typ' }, { key: 'SIGNATUR', desc: 'Signatur' }, { key: 'AKTUALITET', desc: 'Aktualitet' }, { key: 'geom_area', desc: 'Areal', fn: 'area' }, ], }, flyttled: { title: 'Samebyarnas markanvändningsredovisning \u2013 flyttled', fields: [ { key: 'LED_ID', desc: 'Led-ID', classes: ['feature-objid'], fn: (v) => v > 0 ? v : null }, { key: 'SAMEBY1', desc: 'Sameby #1' }, { key: 'SAMEBY2', desc: 'Sameby #2' }, { key: 'SAMEBY3', desc: 'Sameby #3' }, { key: 'BESKRIVNIN', desc: 'Beskrivning' }, { key: 'ARSTID', desc: 'Årstid' }, { key: 'RIKSINTR', desc: 'Riksintresse' }, { key: 'FAST_LED', desc: 'Fast led' }, { key: 'AKTUALITET', desc: 'Aktualitet' }, { key: 'SIGNATUR', desc: 'Signatur' }, { key: 'geom_length', desc: 'Ledlängd', fn: 'length' }, ], }, riks_ren: { title: 'Riksintresse rennäring', fields: [ { key: 'LAGRUM', desc: 'Lagrum' }, { key: 'AKTUALITET', desc: 'Aktualitet' }, { key: 'SIGNATUR', desc: 'Signatur' }, { key: 'geom_area', desc: 'Areal', fn: 'area' }, ], }, omr_riks: { title: '(Kärn)områden av riksintresse rennäring', fields: [ { key: 'OMR_NR', desc: 'Områdes-ID', classes: ['feature-objid'] }, { key: 'LANK', desc: 'Länk' }, { key: 'ARET_RUNT', desc: 'Årets runt' }, { key: 'SAMEBY', desc: 'Sameby' }, { key: 'ANSVARIG', desc: 'Ansvarig' }, { key: 'AKTUALITET', desc: 'Aktualitet' }, { key: 'SIGNATUR', desc: 'Signatur' }, { key: 'geom_area', desc: 'Areal', fn: 'area' }, ], }, }; /* format value to HTML */ 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 { value /= 1000; value = Math.round(value*100) / 100; unit = '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 { value /= 1000000; unit = 'km²'; } value = Math.round(value*100) / 100; } if (value == null) { return null; } if (value instanceof HTMLElement) { return value; } switch (typeof value) { case 'boolean': return document.createTextNode(value ? 'Ja' : 'Nej'); case 'string': return document.createTextNode(value); case 'number': if (unit != null) { return document.createTextNode(value.toLocaleString('sv-SE') + '\u202F' + unit); } return document.createTextNode(value.toString()); default: return null; } }; /* turn the properties into a fine */ const formatFeaturePropertiesToHTML = function(properties) { const table = document.createElement('table'); table.classList.add('table', 'table-sm', 'table-borderless', 'table-hover'); const tbody = document.createElement('tbody'); table.appendChild(tbody); const def = LAYERS[properties.layer_group][properties.layer]; def.fields.forEach(function(field) { const tr = document.createElement('tr'); tbody.appendChild(tr); const th = document.createElement('th'); th.setAttribute('scope', 'row'); tr.appendChild(th); const textDesc = document.createTextNode(field.desc); th.appendChild(textDesc); const td = document.createElement('td'); tr.appendChild(td); const v = formatValue(properties[field.key], field); if (v != null) { td.appendChild(v); } field.classes?.forEach?.((c) => td.classList.add(c)); }); const content = document.createElement('div'); if (def.title != null) { const h = document.createElement('h6'); content.appendChild(h); const textNode = document.createTextNode(def.title); h.appendChild(textNode); } content.appendChild(table); return content; }; /* Initialize popup overlay with the give map and HTML element */ let popupOverlay = null; export const initPopupOverLay = function(map, element) { popupOverlay = new Overlay({ stopEvent: true, element: element, }); map.addOverlay(popupOverlay); }; let featureOverlayLayer = null; let overlayAttributes = [], overlayAttrIdx = 0, mapSources = {}; /* Clear the highlighted feature list and make the overlay layer invisible */ const disposeFeatureOverlay = function() { if (featureOverlayLayer?.getVisible?.()) { featureOverlayLayer.setVisible(false); featureOverlayLayer.changed(); } /* clear the overlay list */ overlayAttributes = []; overlayAttrIdx = 0; mapSources = {}; } let popover = null; /* Clear overlay layer and dispose popover */ export const disposePopover = function() { disposeFeatureOverlay(); if (popover?.tip != null) { popover.dispose(); } }; /* Initialize popover on the given map */ export const initPopover = function(map) { featureOverlayLayer = new VectorTileLayer({ zIndex: 65535, declutter: false, visible: false, renderMode: 'vector', style: null, map: map, }); const header = document.createElement('div'); header.classList.add('d-flex'); const headerGrabbingArea = document.createElement('div'); headerGrabbingArea.classList.add('flex-grow-1', 'grabbing-area', 'pe-2', 'me-2'); header.appendChild(headerGrabbingArea); const pageNode = document.createElement('h6'); headerGrabbingArea.appendChild(pageNode); headerGrabbingArea.onmousedown = function(event) { /* move the popover around */ if (event.button != 0) { return; } const popoverTip = popover.tip; if (popoverTip.classList.contains('popover-maximized')) { return; } headerGrabbingArea.classList.add('grabbing-area-grabbed'); if (!popoverTip.classList.contains('popover-detached')) { /* detach popover tip */ popoverTip.classList.add('popover-detached'); const rect = popoverTip.getBoundingClientRect(); const style = popoverTip.style; style.display = 'none'; /* avoid reflows between the following assignments */ style.position = 'absolute'; style.transform = ''; style.inset = `${rect.top}px auto auto ${rect.left}px`; style.display = ''; } let clientX = event.clientX, clientY = event.clientY; document.onmousemove = function(event) { const offsetX = clientX - event.clientX; const offsetY = clientY - event.clientY; clientX = event.clientX; clientY = event.clientY; popoverTip.style.top = (popoverTip.offsetTop - offsetY).toString() + 'px'; popoverTip.style.left = (popoverTip.offsetLeft - offsetX).toString() + 'px'; }; document.onmouseup = function(event) { /* done moving around */ if (event.button != 0) { return; } headerGrabbingArea.classList.remove('grabbing-area-grabbed'); document.onmousemove = null; document.onmouseup = null; }; }; /* current number page and total page count */ const pageNum = document.createElement('span'); const pageCount = document.createElement('span'); pageNode.appendChild(document.createTextNode('Träff ')); pageNode.appendChild(pageNum); pageNode.appendChild(document.createTextNode(' av ')); pageNode.appendChild(pageCount); /* highlight a feature */ const featureOverlayStyle = new Style({ stroke: new Stroke({ color: 'rgba(0, 255, 255, .8)', width: 3, }), }); const highlightFeature = function(layer_group, layer, id) { const source = mapSources[layer_group]; if (source == null) { return; } if (featureOverlayLayer.getSource() !== source) { /* console.log('Updating source for feature overlay layer'); */ featureOverlayLayer.setVisible(false); featureOverlayLayer.setSource(source); } featureOverlayLayer.setStyle(function(feature) { if (feature.getId() === id && feature.getProperties().layer === layer) { return featureOverlayStyle; } }); featureOverlayLayer.setVisible(true); featureOverlayLayer.changed(); }; /* highlight the feature at index overlayAttrIdx within the CGI reply list */ const refreshPopover = function() { const attr = overlayAttributes[overlayAttrIdx]; highlightFeature(attr.layer_group, attr.layer, attr.ogc_fid); pageNum.innerHTML = (overlayAttrIdx + 1).toString(); const content = formatFeaturePropertiesToHTML(attr); popover.tip.getElementsByClassName('popover-body')[0].replaceChildren(content); }; /* go back/forward in the overlayAttributes list */ const onClickPageChange = function(event, offset) { const btn = event.target; if (btn.classList.contains('disabled') || popover?.tip == null) { return; } if (overlayAttrIdx + offset < 0 || overlayAttrIdx + offset > overlayAttributes.length - 1) { return; /* out of range */ } overlayAttrIdx += offset; if (overlayAttrIdx < 1) { btnPrev.classList.add('disabled'); } else { btnPrev.classList.remove('disabled'); } if (overlayAttrIdx < overlayAttributes.length - 1) { btnNext.classList.remove('disabled'); } else { btnNext.classList.add('disabled'); } refreshPopover(); setTimeout(function() { btn.blur() }, 100); }; /* control buttons */ const btnPrev = document.createElement('button'); btnPrev.classList.add('popover-button', 'popover-button-prev'); btnPrev.setAttribute('type', 'button'); btnPrev.title = 'Föregående träff'; btnPrev.setAttribute('aria-label', btnPrev.title); btnPrev.onclick = function(event) { return onClickPageChange(event, -1); }; const btnNext = document.createElement('button'); btnNext.classList.add('popover-button', 'popover-button-next'); btnNext.setAttribute('type', 'button'); btnNext.title = 'Nästa träff'; btnNext.setAttribute('aria-label', btnNext.title); btnNext.onclick = function(event) { return onClickPageChange(event, +1); }; const btnExpand = document.createElement('button'); btnExpand.classList.add('popover-button', 'popover-button-expand'); btnExpand.setAttribute('type', 'button'); const btnExpandTitle = 'Förstora'; const btnExpandTitle2 = 'Förminska'; btnExpand.setAttribute('aria-label', btnExpand.title); btnExpand.onclick = function() { /* maximize or reduce the popover */ if (popover?.tip == null) { return; } if (!popover.tip.classList.contains('popover-maximized')) { popover.tip.classList.add('popover-maximized'); btnExpand.classList.replace('popover-button-expand', 'popover-button-reduce'); btnExpand.title = btnExpandTitle2; btnExpand.setAttribute('aria-label', btnExpand.title); } else { popover.tip.classList.remove('popover-maximized'); btnExpand.classList.replace('popover-button-reduce', 'popover-button-expand'); btnExpand.title = btnExpandTitle; btnExpand.setAttribute('aria-label', btnExpand.title); } setTimeout(function() { btnExpand.blur() }, 100); }; const btnClose = document.createElement('button'); btnClose.classList.add('popover-button', 'popover-button-close'); btnClose.setAttribute('type', 'button'); btnClose.title = 'Stäng'; btnClose.setAttribute('aria-label', btnClose.title); btnClose.onclick = disposePopover; header.appendChild(btnPrev); header.appendChild(btnNext); header.appendChild(btnExpand); header.appendChild(btnClose); const container0 = map.getViewport().getElementsByClassName('ol-overlaycontainer-stopevent')[0]; map.on('singleclick', function(event) { disposeFeatureOverlay(); /* dispose any pre-existing popover if not in detached mode */ popover = Popover.getInstance(popupOverlay.element); if (popover?.tip != null && !popover.tip.classList.contains('popover-detached')) { popover.dispose(); } const size = event.map.getSize(); if (size[0] < 576 || size[1] < 576) { return; /* skip popover if the map is too small */ } /* unclear how many feature we'll find, don't render prev/next buttons for now */ pageNode.classList.add('d-none'); btnPrev.classList.add('d-none', 'disabled'); btnNext.classList.add('d-none', 'disabled'); /* never start in maximized mode */ if (popover?.tip != null) { popover.tip.classList.remove('popover-maximized'); } btnExpand.classList.replace('popover-button-reduce', 'popover-button-expand'); btnExpand.title = btnExpandTitle; btnExpand.setAttribute('aria-label', btnExpand.title); const fetch_body = []; event.map.forEachFeatureAtPixel(event.pixel, function(feature, layer) { const layerGroup = layer.get('layerGroup'); const layerName = feature.getProperties().layer; mapSources[layerGroup] ??= layer.getSource(); const def = layerName != null ? LAYERS[layerGroup][layerName] : null; if (def?.fields == null) { /* skip layers which didn't opt-in for popover */ return false; } if (fetch_body.length === 0) { /* first feature in the list, mark cursor and detached popover as in-progress */ document.body.classList.add('inprogress'); popover?.tip?.classList?.add?.('inprogress'); } fetch_body.push({ layer_group: layerGroup, layer: layerName, fid: feature.getId() ?? -1, }); if (fetch_body.length >= 100) { return true; /* enough matches already, stop detection here */ } }, { hitTolerance: 5, checkWrapped: false, layerFilter: (lyr) => lyr.get('layerGroup') != null, }); if (fetch_body.length === 0) { /* no feature at pixel (or only within layers which didn't opt-in for popover) */ if (popover?.tip != null) { /* dispose pre-detached popover */ popover.dispose(); } return; } fetch('/q', { method: 'POST', body: JSON.stringify(fetch_body), headers: { 'Content-Type': 'application/json; charset=UTF-8', }, }) .then(function(resp) { if (resp.status === 200) { return resp.json(); } else { throw new Error(`${resp.url} [${resp.status}]`); } }) .then(function(data) { /* the data is received from the CGI in the order it was sent */ /* TODO optimizations on the CGI would break the above assumption, so the * decoded JSON response would need to be reordered to match fetch_body */ overlayAttributes = data; if (overlayAttributes.length === 0) { /* couldn't fetch any attribute for feature(s) at pixel */ if (popover?.tip != null) { /* dispose pre-detached popover */ popover.dispose(); } return; } pageCount.innerHTML = overlayAttributes.length.toString(); if (overlayAttributes.length >= 2) { /* render prev/pre buttons */ btnNext.classList.remove('d-none', 'disabled'); btnPrev.classList.remove('d-none'); pageNode.classList.remove('d-none'); } if (popover?.tip == null) { /* create a new popover (we're not already showing one in detached mode) */ pageNum.innerHTML = (overlayAttrIdx + 1).toString(); popupOverlay.setPosition(event.coordinate); const attr = overlayAttributes[0]; highlightFeature(attr.layer_group, attr.layer, attr.ogc_fid); popover = new Popover(popupOverlay.element, { template: '', title: header, content: formatFeaturePropertiesToHTML(attr), html: true, placement: 'right', fallbackPlacements: ['right', 'left', 'bottom', 'top'], container: container0, }); popover.show(); } else if (popover.tip.classList.contains('popover-detached')) { /* update existing detached mode popover */ refreshPopover(); popover.tip.classList.remove('inprogress'); } }) .catch(function(e) { console.log(e); }) .finally(function() { /* remove in-progress marking on the cursor */ document.body.classList.remove('inprogress'); }); }); };