Source: components/ol-map.vue

<template>
    <Header :name="$route.name" :title="title ?? $t($route.name)" :backButton="backButton" />
    <Toolbar ref="toolbar" :open="toolbarOpen || !toolbarCloseable" 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: 9999;" @click="toolbarOpen = true"
        padding="xs" icon="settings" size="sm" />
    <div id="map-container"
        :style="{ top: (35 + toolbarHeight) + 'px', height: ($q.screen.height - 75 - toolbarHeight) + 'px' }"
        tabIndex="0">

        <ContextMenu ref="clickMenu" :contextMenu="false" :options="[
        { label: $t('History'), callback: showHistory, visible: !activeAction && selectedTitle != null && selectedFeature == null },
        //{ label: $t('Frequencies'), callback: showFrequencies, visible: !activeAction && selectedIndicator != null },
        //{ label: $t('Properties'), callback: editProps, visible: !activeAction, runIfSingle: true },
        ///{ label: $t('Statistics'), callback: showFeatureStats, visible: !activeAction && selectedFeature != null && !selectedFeature.get('custom_geometry_id') && selectedCatalog != null }
    ]" />

        <!-- <ContextMenu ref="contextmenu" :options="[
            { label: $t('New polygon'), callback: newObject, options: { type: 'Polygon' } },
            { label: $t('New point'), callback: newObject, options: { type: 'Point' } },
            { label: $t('New line'), callback: newObject, options: { type: 'LineString' } },
            { label: $t('Delete'), callback: deleteObject, visible: selectedFeature != null },
            { label: $t('End editing'), callback: endObject, visible: activeAction != null },
        ]" /> -->
        <div id="map" ref="map" />
        <i v-if="crosshair" id="crosshair" class="crosshair material-icons">close</i>
        <q-btn-group flat class="btnGroup">
            <q-btn size="sm" round flat dense icon="content_copy" @click="exportMap(false)">
                <q-tooltip anchor="center left" self="center right">{{ $t("Copy to clipboard") }}</q-tooltip>
            </q-btn>
            <q-btn size="sm" round flat dense icon="download" @click="exportMap(true)">
                <q-tooltip anchor="center left" self="center right">{{ $t("Download as image") }}</q-tooltip>
            </q-btn>
            <q-btn v-if="rotation != 0" size="sm" round flat dense icon="north" @click="map.getView().setRotation(0)">
                <q-tooltip anchor="center left" self="center right">{{ $t("North orientation") }}</q-tooltip>
            </q-btn>
            <q-btn v-if="trackPosition" size="sm" round flat dense :icon="$icons.my_location"
                @click="map.getView().setCenter(geolocation.getPosition())">
                <q-tooltip anchor="center left" self="center right">{{ $t("Center at GPS") }}</q-tooltip>
            </q-btn>
        </q-btn-group>
        <OlMapLegend v-if="selectedCatalog || selectedIndicator" :options="legend"
            :unit="selectedIndicator ? selectedIndicator.unit : null" />
        <ImageViewer ref="imageViewerMap" @compassChanged="compassChanged" />
        <div id="infoTip" />
        <div id="info" v-if="info">{{ `zoom ${zoom.toFixed(1)}, center
            ${center[0].toFixed(3)},
            ${center[1].toFixed(3)}` }} '{{ activeAction }}' pixel: {{ valueAtPixel }}
            ll: {{ cursorPosition[0].toFixed(3) }} {{ cursorPosition[1].toFixed(3) }}
            xy: {{ cursorXY[0].toFixed(0) }} {{ cursorXY[1].toFixed(0) }} c: {{ ($store.compass ?? 0).toFixed(0) }}
        </div>
    </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 GeoTIFFSource from 'ol/source/GeoTIFF.js';
import { OSM, Vector as VectorSource, Cluster as ClusterSource, BingMaps } from "ol/source";
import { Vector as VectorLayer } from "ol/layer";
import TileLayer from 'ol/layer/WebGLTile.js';
import { defaults as defaultInteractions, DragRotateAndZoom, Extent, Draw, Modify, Select, Snap } from "ol/interaction";
//import { defaults as defaultControls } from 'ol/control/defaults';
import { fromLonLat } from "ol/proj";
import { Point } from "ol/geom";
import { get as getProjection, useGeographic } from 'ol/proj';
import proj4 from 'proj4';
import { register } from 'ol/proj/proj4.js';
import { defineAsyncComponent } from 'vue';
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 OlMapPopup from './ol-map-popup.vue';
import OlMapLegend from './ol-map-legend.vue';
import OlMapProps from './ol-map-props.vue';
import Toolbar from './toolbar.vue';
import ImageViewer from './image-viewer.vue';
import ContextMenu from './context-menu.vue';
import { click, shiftKeyOnly, singleClick } from "ol/events/condition";
import { Style, Circle, Fill, Stroke } from "ol/style";
// #endregion
export default {
    name: 'OlMap',
    mixins: [MapMixin, MapEditingMixin, MapStylesMixin, MapEventsMixin, MapLayersMixin],
    components: {
        OlMapPopup,
        OlMapLegend,
        OlMapProps,
        Toolbar,
        ContextMenu,
        ImageViewer,
    },
    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();
        }
        console.log("Leaving map");
        this.geolocation.setTracking(false);
        this.saveStorable(this, from.name);
        next();
    },
    computed: {
        toolbarComponent: function () {
            if (!this.toolbar) return null;
            return defineAsyncComponent(() => import(`../components/${this.toolbar}.vue`));
        },
    },
    watch: {
        //"$q.screen.width": "calcToolbarHeight",
        //toolbarOpen: "calcToolbarHeight",

        valueAtPixel: function (val) {
            this.valueAtPixelStyle = {
                position: "absolute",
                top: (this.cursorPixel[1] - 30) + "px",
                left: this.cursorPixel[0] + "px",
                backgroundColor: "white",
                padding: "3px",
                borderRadius: "5px",
            }
        },

        selectedShape: 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);
        },
        clustered: function (val) {
            if (this.pointSource) {
                this.pointLayer.setSource(this.clustered ? this.clusteredSource : this.pointSource);
            }
        },
        selectedIndicator: async function (val) {
            this.setPointLayer();
            this.setBoundariesLayer();
            if (this.selectedIndicator) {
                this.legend = await this.get("Home/GetIndicatorLegend", { indicatorId: this.selectedIndicator.value });
                if (!this.legend) this.legend = [];

            } else {
                this.legend = [];
            }
        },
        selectedDataSource: function (val) {
            this.selectedIndicator = null;
            this.setPointLayer();
            this.setBoundariesLayer();
        },
        selectedCatalog: function (val) {
            if (this.selectedTitle) {
                this.createLegend(this.selectedTitle.color_map);
            }
            this.setTileLayer();
        },
    },
    data: function () {
        return {
            routeKey: 0,
            storable: ["toolbarOpen", "srid", "center", "zoom", "rotation", "opacity", "trackPosition", "clustered", "showEditableSource", "selectedBaseLayer", "selectedDataSource", "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,
            clustered: true,

            type: null,
            geom: null,
            source: null,
            opacity: 0.5,
            styleCache: {},
            nutsLevel: null,
            showPoints: true,
            selectedIndicator: null,
            selectedTitle: null,
            sources: [],
            selectedCatalog: null,
            legend: [],
            activeAction: null,
            showEditableSource: false,
            select: null,
            modify: null,
            snap: null,
            draw: null,
            selectedFeature: null,
            toolbarHeight: 0,
            valueAtPixel: null,
            tiffLayer: null,
            pixelRatio: 1,
            moveTimeout: null,
            selectedBaseLayer: { value: 1, label: "OSM" },
            selectedDataSource: { value: 1, label: "LUCAS 2018" },
            selectedShape: null,
            crosshairOverlay: null,
            crosshair: false,

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

            pointSource: null,
            pointLayer: null,
            clusteredSource: null,

            editableLayer: null,
            editableSource: null,

            boundariesSource: null,
            boundariesLayer: null,

            shapeSource: null,
            shapeLayer: null,

            geolocation: null,
            accuracyFeature: null,
            positionFeature: null,
            geolocationSource: null,
            geolocationLayer: null,

            editStack: [],
            drawVertexByVertex: false,

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

            backButton: false,

        };
    },

    methods: {

        exportMap(download) {
            const mapCanvas = document.createElement('canvas');
            const size = this.map.getSize();
            mapCanvas.width = size[0];
            mapCanvas.height = size[1];
            const mapContext = mapCanvas.getContext('2d');
            Array.prototype.forEach.call(
                this.map.getViewport().querySelectorAll('.ol-layer canvas, canvas.ol-layer'),
                function (canvas) {
                    if (canvas.width > 0) {
                        const opacity =
                            canvas.parentNode.style.opacity || canvas.style.opacity;
                        mapContext.globalAlpha = opacity === '' ? 1 : Number(opacity);
                        let matrix;
                        const transform = canvas.style.transform;
                        if (transform) {
                            // Get the transform parameters from the style's transform matrix
                            matrix = transform
                                .match(/^matrix\(([^\(]*)\)$/)[1]
                                .split(',')
                                .map(Number);
                        } else {
                            matrix = [
                                parseFloat(canvas.style.width) / canvas.width,
                                0,
                                0,
                                parseFloat(canvas.style.height) / canvas.height,
                                0,
                                0,
                            ];
                        }
                        // Apply the transform to the export map context
                        CanvasRenderingContext2D.prototype.setTransform.apply(
                            mapContext,
                            matrix,
                        );
                        const backgroundColor = canvas.parentNode.style.backgroundColor;
                        if (backgroundColor) {
                            mapContext.fillStyle = backgroundColor;
                            mapContext.fillRect(0, 0, canvas.width, canvas.height);
                        }
                        mapContext.drawImage(canvas, 0, 0);
                    }
                },
            );
            mapContext.globalAlpha = 1;
            mapContext.setTransform(1, 0, 0, 1, 0, 0);

            if (download) {
                var link = document.createElement('a');
                link.href = mapCanvas.toDataURL();
                link.download = 'map.png';
                link.click();
            } else {
                mapCanvas.toBlob((blob) => {
                    const item = new ClipboardItem({ 'image/png': blob });
                    navigator.clipboard.write([item])
                        .then(() => {
                            this.$q.notify({
                                message: this.$t("Image copied to clipboard"),
                                color: "positive",
                                timeout: 1000,
                                position: "bottom"
                            });
                        })
                        .catch((err) => {
                            this.showError("Copy failed: ", err);
                        });
                }, 'image/png');
            }
        },
        // async calcToolbarHeight() {
        //     await this.$nextTick(() => {
        //         this.toolbarHeight = this.toolbarOpen ? this.$refs.toolbar.$el.clientHeight : 0;
        //     });
        // },

        /**
         * 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;

            this.infoTip = document.getElementById('infoTip');
            this.selectedCatalog = null;
            this.selectedIndicator = null;
            this.selectedTitle = null;
            this.selectedShape = null;
            this.crosshair = false;
            this.toolbarCloseable = true;
            this.editStack = [];
            this.params = null;
            this.backButton = false;


            this.projectionForSave = getProjection("EPSG:4326");
            this.createGeolocation();
            console.log("route", this.$route.name, this.$store.props[this.$route.name]);
            this.copyObject(this.$store.props[this.$route.name], this, true);

            // if this.srid is not an object, make it one
            this.srid = this.srid ?? 3035;
            this.projection = this.projection ?? "EPSG:" + this.srid;
            this.loadStorable(this, routeName);
            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.clusteredSource = new ClusterSource({
                format: this.format,
                projection: this.projection,
                distance: 7, //parseInt(distanceInput.value, 10),
                minDistance: 7, // parseInt(minDistanceInput.value, 10),
                source: this.pointSource
            });

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

            this.baseLayer = new TileLayer({
                properties: { visible: true, zIndex: 0, source: null },
            });
            this.baseLayer.on("precompose", e => {
                this.pixelRatio = e.frameState.pixelRatio;
            });

            this.tiffLayer = new TileLayer({
                properties: {
                    type: "GeoTIFF",
                }, visible: false, opacity: this.opacity, zIndex: 1, 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 VectorLayer({ source: this.shapeSource, zIndex: 2 });

            this.boundariesLayer = new VectorLayer({
                source: this.boundariesSource, opacity: 0.5, zIndex: 3, style: feature => {
                    return this.getBoundaryStyle(feature);
                }
            });

            this.pointLayer = new VectorLayer({
                source: this.clustered ? this.clusteredSource : this.pointSource,
                type: 'Point',
                zIndex: 4,
                style: feature => {
                    return this.getPointStyle(feature, this.clustered, this.zoom);
                }
            });

            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: "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.editableLayer);
            this.map.addLayer(this.shapeLayer);
            this.map.addLayer(this.boundariesLayer);
            this.map.addLayer(this.pointLayer);
            this.map.addLayer(this.geolocationLayer);

            this.editableLayer.setVisible(this.showEditableSource);
            this.$store.map = this.map;

            // 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();

            if (this.params && this.params.id) {
                await this.readCustomFeatures(this.params.id);
            } else if (this.customFeatures) {
                await this.readCustomFeatures(null);
            }

            if (this.shapeAPI) {
                let shapes = await this.get(this.shapeAPI);
                console.log(shapes);
                this.shapeSource.addFeatures(this.format.readFeatures(shapes));
                this.animateToExtent(this.shapeSource);
            }

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

            this.map.updateSize();

            if (this.showEditableSource) {
                this.$nextTick(() => {

                    // count features in editable source with id starting with 'g'
                    let count = 0;
                    for (let f of this.editableSource.getFeatures()) {
                        if (f.getId().startsWith('g')) {
                            count++;
                        }
                    }

                    if (count > 0) {
                        let f = this.editableSource.getFeatures()[0];
                        if (count == 1 && f.getGeometry().getType() === 'Point') {
                            this.map.getView().setCenter(f.getGeometry().getCoordinates());
                        } else {
                            this.animateToExtent(this.editableSource);
                        }
                    }
                });
            }

        }
    }
}
</script>

<style scoped>
#map-container {
    position: absolute;
    width: 100%;
    /*height: calc(100vh - 75px);*/
}

#map {
    width: 100%;
    height: 100%;
}

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

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

.btnGroup {
    position: absolute;
    bottom: 10px;
    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;
    height: 30px;
    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>