Source: specific/components/ol-map.vue

<template>
    <div>
        <Header v-if="!parentPopup" :name="$route.name" :title="title ?? $t($route.name)" :backButton="backButton" />
        
        <Toolbar ref="toolbar" v-if="toolbar" v-model="toolbarOpen" v-model:toolbarHeight="toolbarHeight" width="100%" :toolbarCloseable="toolbarCloseable"
            @close="toolbarOpen = false">
            <component :is="toolbarComponent" :parent="this" />
        </Toolbar>

        <q-btn v-if="toolbar" style="position:absolute; top:40px; right:5px; z-index: 1000;" @click="toolbarOpen = true"
            padding="xs" icon="settings" text-color="black" size="sm" />

        <div id="map-container" class="map-container q-pa-none q-ma-none" :style="mapContainerStyle" tabIndex="0">

            <div class="map q-pa-none q-ma-none" ref="map" />

            <q-slider v-if="altSelectedCatalog" z-index=2000 v-model="compare" :min="0" :max="1" :step="0.001" dense style="position: absolute; bottom: 4px; left: 0px; width: 100%;" @update:model-value="render()"/>

            <i v-if="crosshair" id="crosshair" class="crosshair material-icons">close</i>
    
            <OlMapLegend v-if="selectedCatalog || selectedIndicator" :options="legend" :optionsTiff="tiffLegend" :parent= "this" :selectedTitle="selectedTitle" :selectedCatalog="selectedCatalog" />

            <OlMapLegend v-if="altSelectedCatalog" :optionsTiff="altTiffLegend" :parent= "this" :rightLegend="true" :selectedTitle="altSelectedTitle" :selectedCatalog="altSelectedCatalog" />

            <div ref="infoTip" class="infoTip"/>
            <div id="info" v-if="info">{{ `zoom ${zoom.toFixed(1)}, center
                ${center[0].toFixed(3)},
                ${center[1].toFixed(3)}, pixel: ${valueAtPixel}, lon/lat: ${cursorPosition[0].toFixed(3) } ${ cursorPosition[1].toFixed(3) },
                xy: ${ cursorXY[0].toFixed(0) } ${ cursorXY[1].toFixed(0) }, c: ${ ($store.compass ?? 0).toFixed(0) } o: ${ opacity } th: ${ toolbarHeight }` }}
            </div>
        </div>
        
        <q-btn-group flat class="btnGroup">

            <q-btn round flat dense icon="content_copy" @click="exportMap(false)" text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("Copy to clipboard") }}</q-tooltip>
            </q-btn>
            <q-btn round flat dense icon="download" @click="exportMap(true)" text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("Download as image") }}</q-tooltip>
            </q-btn>
            <q-btn v-if="showEditableSource || shapeAPI" round flat dense icon="image_aspect_ratio" @click="animateToExtent(null)" text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("Fit to features") }}</q-tooltip>
            </q-btn>
            <q-btn v-if="rotation != 0" round flat dense icon="north" @click="map.getView().setRotation(0)"
                text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("North orientation") }}</q-tooltip>
            </q-btn>
            <q-btn v-if="trackPosition" round flat dense :icon="$icons.my_location"
                @click="map.getView().setCenter(geolocation.getPosition())" text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("Center at GPS") }}</q-tooltip>
            </q-btn>  
            <q-btn round flat dense icon="layers"
                @click="showLayers()" text-color="black">
                <q-tooltip anchor="center left" self="center right">{{ $t("Layers") }}</q-tooltip>
            </q-btn>    
            <q-slider v-if="selectedCatalog || (additionalTiff && additionalTiff.maxZoom >= this.zoom)"  class="semi-transparent q-pl-sm" v-model="opacity" :min="0.0" :max="1." :step="0.05" dense label :label-value="$t('Opacity: ' + opacity)" reverse vertical switch-label-side/>          
        </q-btn-group>
    </div>
</template>

<script>
/**
 * OpenLayers map component.
 * 
 * @component
 * @name OlMap
 * @example
 * <OlMap />
 
 */

// #region imports 
import "ol/ol.css";
import { Map, View, Feature, Overlay } from "ol";

import GeoJSON from 'ol/format/GeoJSON';
import { OSM, Vector as VectorSource,  BingMaps } from "ol/source";
import { Vector as VectorLayer } from "ol/layer";
import WebGLTile from 'ol/layer/WebGLTile.js';
import WebGLVectorLayer from "ol/layer/WebGLVector.js";
import Tile from 'ol/layer/Tile.js';
import { defaults as defaultInteractions, DragRotateAndZoom, Modify } from "ol/interaction";
//import { defaults as defaultControls } from 'ol/control/defaults';
import { fromLonLat } from "ol/proj";
import { get as getProjection, useGeographic } from 'ol/proj';
import proj4 from 'proj4';
import { register } from 'ol/proj/proj4.js';
import { MapMixin } from '../mixins/ol-map.js';
import { MapEditingMixin } from '../mixins/ol-map-editing.js';
import { MapStylesMixin } from '../mixins/ol-map-styles.js';
import { MapEventsMixin } from '../mixins/ol-map-events.js';
import { MapLayersMixin } from '../mixins/ol-map-layers.js';
import { loadComponent } from '@/common/component-loader';
import { GeoTIFF as GeoTIFFSource } from 'ol/source';
import {getRenderPixel} from 'ol/render.js';

// #endregion

export default {
    name: 'OlMap',
    mixins: [MapMixin, MapEditingMixin, MapStylesMixin, MapEventsMixin, MapLayersMixin],
    props: ["parentPopup"],
    components: {
        OlMapPopup: loadComponent('ol-map-popup'),
        OlMapLegend: loadComponent('ol-map-legend'),
        Toolbar: loadComponent('toolbar'),
        ContextMenu: loadComponent('context-menu'),
        Autocomplete: loadComponent('autocomplete'),
        DatacubeSelector: loadComponent('datacube-selector'),
    },
    beforeRouteEnter(to, from, next) {
        next(vm => { vm.init(to.name); });
    },
    beforeRouteUpdate(to, from, next) {
        this.routeKey++;
        this.init(to.name);
        next();
    },
    beforeRouteLeave(to, from, next) {
        if (this.activeAction != null) {
            this.endObject();
        }
        this.geolocation.setTracking(false);
        this.saveStorable(this, from.name);
        next();
    },
    computed: {
        toolbarComponent: function () {
            if (!this.toolbar) return null;
            return loadComponent(this.toolbar);
            //return defineAsyncComponent(() => import(`../../${this.toolbar}.vue`));
        },

        mapContainerStyle : function () {
            return {
                top: ((this.parentPopup ? 0 : 40) + (this.toolbarHeight ?? 0)) + 'px',
                height: ((this.parentPopup ? 28 : 0) + this.$q.screen.height - 80 - (this.toolbarHeight ?? 0)) + 'px'
            }
        }
    },
    watch: {
        valueAtPixel: function (val) {
            this.valueAtPixelStyle = {
                position: "absolute",
                top: (this.cursorPixel[1] - 30) + "px",
                left: this.cursorPixel[0] + "px",
                backgroundColor: "white",
                padding: "3px",
                borderRadius: "5px",
            }
        },

        selectedSite: function (val) {
            this.setShapeLayer();
        },
        selectedBaseLayer: function (val) {
            this.setBaseLayer();
        },
        showEditableSource: function (val) {
            this.editableLayer.setVisible(val);
        },
        nutsLevel: function (val) {
            this.setBoundariesLayer();
        },
        showPoints: function (val) {
            this.setPointLayer();
        },
        srid: function (val) {
            this.setSrid(val)
        },
        trackPosition: function (val) {
            this.geolocationLayer.setVisible(val);
            this.geolocation.setTracking(val);
        },
        opacity: function (val) {
            this.tiffLayer.setOpacity(val);
            if (this.altTiffLayer) this.altTiffLayer.setOpacity(val);
            if (this.additionalTiffLayer) this.additionalTiffLayer.setOpacity(val);
        },
        mapTilerOpacity: function (val) {
            this.mapTilerLayer.setOpacity(val);
        },
        selectedIndicator: async function (val) {
            this.valueFrom = null;
            this.valueTo = null;
        },
        selectedDataSource: function (val) {
            this.selectedIndicator = null;
            this.setPointLayer();
            this.setBoundariesLayer();
        },
        selectedCatalog: function (val) {
            this.setTileLayer(this.selectedTitle, this.selectedCatalog, this.tiffLayer, this.tiffLegend, false);
        },
        altSelectedCatalog: function (val) {
            this.setTileLayer(this.altSelectedTitle, this.altSelectedCatalog, this.altTiffLayer, this.altTiffLegend, true);
        },

    },
    data: function () {
        return {
            toolbarHeight: 0,
            compare: 0.5,
            altSelectedCatalog: null,
            altSelectedTitle: null,
            altTiffLayer: null,
            tiffLayer: null,
            routeKey: 0,
            storable: ["toolbarOpen", "srid", "center", "zoom", "rotation", "opacity", "mapTilerOpacity", "trackPosition", "showEditableSource", "selectedBaseLayer", "info"],
            dialog: false,
            srid: null,
            toolbar: null,
            toolbarOpen: false,
            toolbarCloseable: true,
            center: [12, 51],
            cursorPosition: [12, 51],
            cursorXY: [0, 0],
            cursorPixel: [0, 0],
            pointFeatures: [],
            zoom: 5,

            map: null,
            view: null,
            vectorSource: null,
            overlay: null,
            projectionForSave: null,
            projection: getProjection("EPSG:3857"),
            extent: null,
            format: new GeoJSON(),
            trackPosition: false,

            rotation: 0,

            type: null,
            geom: null,
            source: null,
            opacity: 0.5,
            mapTilerOpacity: 0.5,
            styleCache: {},
            nutsLevel: null,
            showPoints: true,
            selectedIndicator: null,
            selectedTitle: null,
            sources: [],
            selectedCatalog: null,
            legend: [],
            tiffLegend: [],
            altTiffLegend: [],

            activeAction: null,
            showEditableSource: false,
            select: null,
            modify: null,
            snap: null,
            draw: null,
            selectedFeature: null,
            valueAtPixel: null,
            pixelRatio: 1,
            moveTimeout: null,
            selectedBaseLayer: { value: 1, label: "OSM" },
            //selectedDataSource: { value: 1, label: "LUCAS 2018" },
            selectedDataSource: [], // [{ value: 1, label: "LUCAS 2018" }],
            selectedDataSourceIds: [],
            selectedSite: null,
            crosshairOverlay: null,
            crosshair: false,

            params: null,
            baseLayers: [
                { value: 1, label: "OSM" },
                { value: 2, label: "Bing sat" }
            ],

            pointSource: null,
            pointLayer: null,

            editableLayer: null,
            editableSource: null,

            boundariesSource: null,
            boundariesLayer: null,

            shapeSource: null,
            shapeLayer: null,
            shapeAPI: null,
            geolocation: null,
            accuracyFeature: null,
            positionFeature: null,
            geolocationSource: null,
            geolocationLayer: null,

            editStack: [],
            drawVertexByVertex: false,

            infoTip: null,
            info: false,
            title: null,

            backButton: false,
            valueFrom: null,
            valueTo: null,

            layersProps: {
                baseLayers: this.baseLayers,
                selectedBaseLayer: this.selectedBaseLayer,
            },

            additionalTiff: null,
            additionalTiffSource: null,     
            additionalTiffLayer: null,

            pointStyle : {
                "shape-points":
                [
                    'match', 
                    ['get', 'data_source_order_no'],
                    0, 16, 1, 3, 0
                ],
                "shape-radius" : [
                        'interpolate', ['linear'], ['zoom'],
                        5, 2,
                        10, 5,
                        15, 10,
                        20, 15
                    ],
                //"shape-fill-color": ['interpolate', ['linear'], ['get', 'value'], 0, '#000000'],
                "shape-fill-color": ['get', 'color_code'],
            },
            
            boundaryStyle : {
                'stroke-color': ['get', 'color_code'], //['*', ['get', 'color_code'], '#FF0000'],
                'stroke-width': 1,
                //'stroke-offset': 0,
                'fill-color': ['get', 'fill_color_code'],          
            },
        };
    },

    mounted() {
        if (this.parentPopup) this.init(this.parentPopup.path);
    },

    methods: {
        /**
         * Renders the map.
         */
        render() {
            this.map.render();
            if (this.infoTip.style.visibility == 'visible' && this.selectedTitle) {
                let value = this.getValueAtPixel(this.cursorPixel   );
                this.infoTip.innerHTML = value;
            }
        },


        /**
         * Clips the rendering of a layer based on the specified comparison ratio and direction.
         * 
         * @param {Object} event - The rendering event object containing the WebGL context.
         * @param {number} compare - The ratio (0 to 1) to determine the clipping position.
         * @param {boolean} right - If true, clips the right side of the layer; otherwise, clips the left side.
         */
        clipLayer(event, compare, right) {
            const gl = event.context;
            gl.enable(gl.SCISSOR_TEST);

            const mapSize = this.map.getSize();

            const bottomLeft = getRenderPixel(event, [0, mapSize[1]]);
            const topRight = getRenderPixel(event, [mapSize[0], 0]);

            const mapWidth = topRight[0] - bottomLeft[0];
            const width = Math.round(mapWidth * compare);
            const height = topRight[1] - bottomLeft[1];

            if (right) {
                gl.scissor(bottomLeft[0] + width, bottomLeft[1], mapWidth - width, height);
            } else {
                gl.scissor(bottomLeft[0], bottomLeft[1], width, height);
            }

            gl.clear(gl.COLOR_BUFFER_BIT); // Optionally clear the scissor region
        },

        /**
         * Exports the map as an image.
         * 
         * @param {boolean} download - Indicates whether the image should be downloaded.
         */
        async exportMap(download) {
            await this.export(download, 'map-container', 'map.png');
            return;
        },

        /**
         * Handles the click event on a popup.
         * 
         * @param {Object} feature - The feature object associated with the clicked popup.
         */
        async popupClicked(feature) {
            this.$refs.popup.hide();
            let osm_id = feature.get('osm_id');
            let result = await this.get(this.api2, { OsmId: osm_id });
            this.vectorSource.addFeatures(this.format.readFeatures(result));
        },


        /**
         * Initializes the component.
         * 
         * @param {string} routeName - The name of the route.
         */
        async init(routeName) {

            proj4.defs("EPSG:4258", "+proj=longlat +ellps=GRS80 +no_defs +type=crs");
            proj4.defs("EPSG:3035", "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs");
            register(proj4);
            let self = this;
            await this.$nextTick();

            this.toolbarHeight = 0;
            this.toolbarOpen = false;
            this.toolbarCloseable = true;
            this.toolbar = null;

            if (this.$refs.toolbar && this.toolbar) {
                console.log("invoke calcToolbar");
                this.$refs.toolbar.calcToolbarHeight();
            }
            this.infoTip = this.$refs.infoTip;  //document.getElementById('infoTip');
            this.selectedTitle = null;
            this.selectedCatalog = null;
            this.altSelectedCatalog = null;
            this.selectedIndicator = null;
            this.selectedTitle = null;
            this.selectedSite = null;
            this.crosshair = false;
            this.toolbarCloseable = true;
            this.editStack = [];
            this.params = null;
            this.backButton = false;
            this.toolbar = null;
            this.tiffSource = null;
            this.additionalTiffSource = null;
            this.mapTilerSource = false;
            this.customFeaturesAPI = null;
            this.zoomToEditableSource = false;
            this.customRestrictedLayer = null;
            this.customRestrictedAPI = null;
            this.additionalTiffLayer = null;
            this.additionalTiff = null;
            this.selectedTitleIndicatorId = null;

            if (!this.$store.catalogs.sourceTitles) {
                this.$store.catalogs.sourceTitles = await this.get("DataCube/GetTitles");
            }

            this.projectionForSave = getProjection("EPSG:4326");
            this.createGeolocation();

            this.loadStorable(this, routeName);

            this.initializeComponent(this.parentPopup);

            if (!this.trackPosition) this.geolocation.setTracking(false);
            // if this.srid is not an object, make it one
            this.srid = this.srid ?? 3035;
            this.projection = this.projection ?? "EPSG:" + this.srid;

            this.projection = "EPSG:" + this.srid;

            this.pointSource = new VectorSource({ format: this.format });
            this.editableSource = new VectorSource({ format: this.format });
            this.boundariesSource = new VectorSource({ format: this.format });
            this.shapeSource = new VectorSource({ format: this.format });

            this.geolocationSource = new VectorSource({
                format: this.format,
                projection: this.projection,
                features: [this.accuracyFeature, this.positionFeature]
            });

            //this.baseLayer = new WebGLTile({
                // properties: { visible: true, zIndex: 0, source: null, cacheSize: 1024 },
            this.baseLayer = new Tile({
                properties: { visible: true, zIndex: 0, source: null, cacheSize: 1024 },
            });

            this.baseLayer.on("precompose", e => {
                this.pixelRatio = e.frameState.pixelRatio;
            });

            this.tiffLayer = new WebGLTile({
                properties: {
                    type: "GeoTIFF",
                },
                visible: false,
                opacity: this.opacity,
                zIndex: 1,
                imageSmoothing: false
            });

            this.altTiffLayer = new WebGLTile({
                properties: {
                    type: "GeoTIFF",
                },
                visible: false,
                opacity: this.opacity,
                zIndex: 1,
                //imageSmoothing: false
            });

            this.tiffLayer.on('prerender', (event) => {
                if (!this.altSelectedCatalog) return;
                this.clipLayer(event, this.compare, false);
            });

            this.altTiffLayer.on('prerender', (event) => {
                this.clipLayer(event, this.compare, true);
            });

            this.tiffLayer.on('postrender', function (event) {
                const gl = event.context;
                gl.disable(gl.SCISSOR_TEST);
            });

            this.altTiffLayer.on('postrender', function (event) {
                const gl = event.context;
                gl.disable(gl.SCISSOR_TEST);
            });


            this.mapTilerLayer = new WebGLTile({
                // properties: {
                //     type: "MapTiler",
                // },
                visible: false, opacity: this.mapTilerOpacity, zIndex: 2, source: null,
            });

            this.editableLayer = new VectorLayer({
                source: this.editableSource,
                zIndex: 100,
                style: feature => {
                    if (feature.getGeometry().getType() === 'Point') {
                        return this.getObservationPointStyle(feature, this.zoom);
                    } else {
                        return this.shapeStyle;
                    }
                }
            });

            this.shapeLayer = new WebGLVectorLayer({
                source: this.shapeSource,
                zIndex: 3,
                style: this.boundaryStyle         
            });

            this.boundariesLayer = new WebGLVectorLayer({
                source: this.boundariesSource,
                zIndex: 4,
                style: this.boundaryStyle   
                // style: feature => {
                //     return this.getBoundaryStyle(feature);
                // }
            });

            this.pointLayer = new WebGLVectorLayer({
                zIndex: 5,
                source: this.pointSource,
                opacity: 1,
                style: this.pointStyle,
            });

            if (this.additionalTiff) {

                this.additionalTiffSource = new GeoTIFFSource({
                    normalize: false,
                    sources: [{ url: this.additionalTiff.url }],
                    projection: getProjection("EPSG:" + this.srid),
                    interpolate: false,
                });

                this.additionalTiffLayer = new WebGLTile({
                    properties: {
                        type: "GeoTIFF",
                    },
                    opacity: this.opacity,
                    zIndex: 6,
                    minZoom: this.additionalTiff.minZoom ?? 0,
                    maxZoom: this.additionalTiff.maxZoom ?? 30,
                    source: this.additionalTiffSource,
                    style: {
                        color: JSON.parse(this.additionalTiff.color_map ?? '[[  "interpolate",  [   "linear"  ],  [   "band",   1  ],  0,  [   255,   255,   255,  0  ],  255,  [   0,   0,   0,   1 ] ]]')
                    }
                });
            }

            this.customRestrictedSource = new VectorSource({
                    format: this.format,
            });


            this.customRestrictedLayer = new WebGLVectorLayer({
                source: this.customRestrictedSource,
                zIndex: 7,
                minZoom: this.customRestrictedAPI?.minZoom ?? 0,
                maxZoom: this.customRestrictedAPI?.maxZoom ?? 30,
                style: this.boundaryStyle
            });

            this.geolocationLayer = new VectorLayer({
                source: this.geolocationSource,
                zIndex: 5000,
            });

            this.geolocationLayer.set('interactive', false);

            if (this.map) this.map.dispose();

            this.map = new Map({
                controls: [],
                interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
                target: this.$refs.map,
                layers: [],
                view: new View({
                    projection: this.projection,
                    center: fromLonLat(this.center, this.projection),
                    zoom: this.zoom,
                }),
                // controls: defaultControls({ rotateOptions: { autoHide: false } }),
            });
            this.map.addLayer(this.baseLayer);

            this.map.addLayer(this.tiffLayer);
            this.map.addLayer(this.altTiffLayer);

            this.map.addLayer(this.mapTilerLayer);
            this.map.addLayer(this.boundariesLayer);
            this.map.addLayer(this.shapeLayer);
            this.map.addLayer(this.editableLayer);
            this.map.addLayer(this.pointLayer);
            this.map.addLayer(this.geolocationLayer);

            if (this.additionalTiff) this.map.addLayer(this.additionalTiffLayer);

            this.map.addLayer(this.customRestrictedLayer);

            this.editableLayer.setVisible(this.showEditableSource);
await this.$nextTick();
            // this.select = new Select({
            //     condition: singleClick,
            //     toggleCondition: shiftKeyOnly,
            //     hitTolerance: 5,
            //     style: this.selectedStyleFunction,
            // });

            // this.map.addInteraction(this.select);

            this.modify = new Modify({
                source: this.editableSource,
            });
            this.map.addInteraction(this.modify);

            this.createEvents();
            this.setBaseLayer();
            //this.setTileLayer();
            this.setMapTilerLayer();

            if (this.selectedTitleIndicatorId) {
                this.selectedTitle = this.$store.catalogs.sourceTitles.find(x => x.indicator_id == this.selectedTitleIndicatorId);
                this.selectedTitle.description = this.$store.catalogs.descriptions[this.selectedTitleIndicatorId];
                let sources = await this.get("DataCube/GetShdcFiles", {
                    titleId: this.selectedTitle.value,
                    depth: this.selectedTitleDepth,
                    confidence:  this.selectedTitleConfidence
                });
                this.selectedCatalog = sources[0];
            }

            if (this.customFeaturesAPI) {
                await this.readCustomFeatures();
            }

            this.map.updateSize();
            this.extent = this.map.getView().calculateExtent(this.map.getSize());

            if (this.shapeAPI) {
                await this.setShapeLayer();
                //this.animateToExtent(this.shapeSource);
            }

            if (this.showEditableSource) {
                this.trackPosition = true;
            }

            if (this.selectedDataSource || this.selectedIndicator) {
                this.getIndicatorValues();
            }

                            
        },

        showLayers() {
            this.initPopup( { component: "layers", title : this.$t("Map layers and features") });
        },
    }
}
</script>

<style scoped>
.map-container {
    position: absolute;
    width: 100%;
}

.map {
    width: 100%;
    height: 100%;
    background-color: white !important;
}

#info {
    position:absolute;
    top: 10px;
    left: 0px;
    padding: 5px;
    z-index: 1000;
}

#toolbar {
    padding: 0;
    margin: 0;
}

.btnGroup {
    position: fixed;
    top: 40%;
    right: 5px;
    padding: 5px;
    z-index: 1000;
    flex-direction: column;
}

.crosshair {
    font-size: 30px;
    font-weight: 50;
    color: blue;
    position: absolute;
    top: calc(50% - 15px);
    left: calc(50% - 15px);
    pointer-events: none;
}

.infoTip {
    position: absolute;
    display: inline-block;
    width: auto;
    z-index: 100;
    background-color: #fff;
    color: #000;
    text-align: center;
    border-radius: 4px;
    padding: 5px;
    transform: translate(-60%, -80%);
    /* Center the tip horizontally */
    visibility: hidden;
    pointer-events: none;
}
</style>