From f4fc9efb47f6a54176ce433771ba507d4902a437 Mon Sep 17 00:00:00 2001 From: Pascal Barth Date: Tue, 23 Sep 2025 09:16:16 +0200 Subject: [PATCH 001/561] PB-1383: migrate all store modules to Pinia only the modules, not how they are used throughout the app --- packages/viewer/src/store/debug.store.js | 43 - .../viewer/src/store/{index.js => index.ts} | 18 +- .../viewer/src/store/modules/app.store.js | 30 - .../viewer/src/store/modules/app.store.ts | 35 + .../viewer/src/store/modules/cesium.store.js | 180 --- .../viewer/src/store/modules/cesium.store.ts | 155 +++ .../viewer/src/store/modules/debug.store.ts | 34 + .../viewer/src/store/modules/drawing.store.js | 235 ---- .../viewer/src/store/modules/drawing.store.ts | 173 +++ .../src/store/modules/features.store.js | 779 ----------- .../src/store/modules/features.store.ts | 812 ++++++++++++ .../src/store/modules/geolocation.store.js | 94 -- .../src/store/modules/geolocation.store.ts | 91 ++ .../viewer/src/store/modules/i18n.store.js | 40 - .../viewer/src/store/modules/i18n.store.ts | 36 + .../viewer/src/store/modules/layers.store.js | 995 -------------- .../viewer/src/store/modules/layers.store.ts | 1145 +++++++++++++++++ .../viewer/src/store/modules/map.store.js | 176 --- .../viewer/src/store/modules/map.store.ts | 130 ++ .../src/store/modules/position.store.js | 482 ------- .../src/store/modules/position.store.ts | 463 +++++++ .../viewer/src/store/modules/print.store.js | 84 -- .../viewer/src/store/modules/print.store.ts | 97 ++ .../viewer/src/store/modules/profile.store.js | 215 ---- .../viewer/src/store/modules/profile.store.ts | 241 ++++ .../viewer/src/store/modules/search.store.js | 349 ----- .../viewer/src/store/modules/search.store.ts | 367 ++++++ .../viewer/src/store/modules/share.store.js | 2 +- .../viewer/src/store/modules/topics.store.js | 117 -- .../viewer/src/store/modules/topics.store.ts | 87 ++ packages/viewer/src/store/modules/ui.store.js | 579 --------- packages/viewer/src/store/modules/ui.store.ts | 528 ++++++++ packages/viewer/src/store/store.ts | 8 + .../src/utils/{gpxUtils.js => gpxUtils.ts} | 33 +- packages/viewer/src/utils/kmlUtils.ts | 7 +- packages/viewer/src/utils/layerUtils.js | 149 --- packages/viewer/src/utils/layerUtils.ts | 92 ++ .../utils/{styleUtils.js => styleUtils.ts} | 17 +- 38 files changed, 4537 insertions(+), 4581 deletions(-) delete mode 100644 packages/viewer/src/store/debug.store.js rename packages/viewer/src/store/{index.js => index.ts} (90%) delete mode 100644 packages/viewer/src/store/modules/app.store.js create mode 100644 packages/viewer/src/store/modules/app.store.ts delete mode 100644 packages/viewer/src/store/modules/cesium.store.js create mode 100644 packages/viewer/src/store/modules/cesium.store.ts create mode 100644 packages/viewer/src/store/modules/debug.store.ts delete mode 100644 packages/viewer/src/store/modules/drawing.store.js create mode 100644 packages/viewer/src/store/modules/drawing.store.ts delete mode 100644 packages/viewer/src/store/modules/features.store.js create mode 100644 packages/viewer/src/store/modules/features.store.ts delete mode 100644 packages/viewer/src/store/modules/geolocation.store.js create mode 100644 packages/viewer/src/store/modules/geolocation.store.ts delete mode 100644 packages/viewer/src/store/modules/i18n.store.js create mode 100644 packages/viewer/src/store/modules/i18n.store.ts delete mode 100644 packages/viewer/src/store/modules/layers.store.js create mode 100644 packages/viewer/src/store/modules/layers.store.ts delete mode 100644 packages/viewer/src/store/modules/map.store.js create mode 100644 packages/viewer/src/store/modules/map.store.ts delete mode 100644 packages/viewer/src/store/modules/position.store.js create mode 100644 packages/viewer/src/store/modules/position.store.ts delete mode 100644 packages/viewer/src/store/modules/print.store.js create mode 100644 packages/viewer/src/store/modules/print.store.ts delete mode 100644 packages/viewer/src/store/modules/profile.store.js create mode 100644 packages/viewer/src/store/modules/profile.store.ts delete mode 100644 packages/viewer/src/store/modules/search.store.js create mode 100644 packages/viewer/src/store/modules/search.store.ts delete mode 100644 packages/viewer/src/store/modules/topics.store.js create mode 100644 packages/viewer/src/store/modules/topics.store.ts delete mode 100644 packages/viewer/src/store/modules/ui.store.js create mode 100644 packages/viewer/src/store/modules/ui.store.ts create mode 100644 packages/viewer/src/store/store.ts rename packages/viewer/src/utils/{gpxUtils.js => gpxUtils.ts} (57%) delete mode 100644 packages/viewer/src/utils/layerUtils.js create mode 100644 packages/viewer/src/utils/layerUtils.ts rename packages/viewer/src/utils/{styleUtils.js => styleUtils.ts} (91%) diff --git a/packages/viewer/src/store/debug.store.js b/packages/viewer/src/store/debug.store.js deleted file mode 100644 index 3009f6144d..0000000000 --- a/packages/viewer/src/store/debug.store.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The name of the mutation for base URL override changes - * - * @type {String} - */ -export const SET_HAS_URL_OVERRIDES_MUTATION_KEY = 'setHasBaseUrlOverrides' - -const mutations = { - setShowTileDebugInfo: (state, { showTileDebugInfo }) => - (state.showTileDebugInfo = showTileDebugInfo), - setShowLayerExtents: (state, { showLayerExtents }) => - (state.showLayerExtents = showLayerExtents), -} -mutations[SET_HAS_URL_OVERRIDES_MUTATION_KEY] = (state, { hasOverrides }) => - (state.hasBaseUrlOverride = hasOverrides) - -/** Vuex module that contains debug tools things */ -export default { - state: { - showTileDebugInfo: false, - showLayerExtents: false, - hasBaseUrlOverride: false, - }, - getters: {}, - actions: { - toggleShowTileDebugInfo({ commit, state }, { dispatcher }) { - commit('setShowTileDebugInfo', { - showTileDebugInfo: !state.showTileDebugInfo, - dispatcher, - }) - }, - toggleShowLayerExtents({ commit, state }, { dispatcher }) { - commit('setShowLayerExtents', { showLayerExtents: !state.showLayerExtents, dispatcher }) - }, - setHasBaseUrlOverrides({ commit }, { hasOverrides, dispatcher }) { - commit(SET_HAS_URL_OVERRIDES_MUTATION_KEY, { - hasOverrides: !!hasOverrides, - dispatcher, - }) - }, - }, - mutations, -} diff --git a/packages/viewer/src/store/index.js b/packages/viewer/src/store/index.ts similarity index 90% rename from packages/viewer/src/store/index.js rename to packages/viewer/src/store/index.ts index 7612e726b1..82352fc32d 100644 --- a/packages/viewer/src/store/index.js +++ b/packages/viewer/src/store/index.ts @@ -1,9 +1,10 @@ -import { createStore } from 'vuex' +import { createPinia } from 'pinia' + +import type { State } from '@/store/store' import { ENVIRONMENT } from '@/config/staging.config' -import debug from '@/store/debug.store' -import app from '@/store/modules/app.store' import cesium from '@/store/modules/cesium.store' +import debug from '@/store/modules/debug.store' import drawing from '@/store/modules/drawing.store' import features from '@/store/modules/features.store' import geolocation from '@/store/modules/geolocation.store' @@ -35,8 +36,11 @@ import topicChangeManagementPlugin from '@/store/plugins/topic-change-management import updateSelectedFeaturesPlugin from '@/store/plugins/update-selected-features.plugin' import vuexLogPlugin from '@/store/plugins/vuex-log.plugin' -const store = createStore({ - // Do not run strict mode on production as it has performance cost +const pinia = createPinia() +pinia.use(appReadinessPlugin) + +const store = createStore{ + // Do not run strict mode on production has it has performance cost strict: ENVIRONMENT !== 'production', state: {}, plugins: [ @@ -59,13 +63,11 @@ const store = createStore({ updateSelectedFeaturesPlugin, ], modules: { - app, drawing, features, profile, geolocation, i18n, - layers, map, position, search, @@ -78,4 +80,4 @@ const store = createStore({ }, }) -export default store +export default pinia diff --git a/packages/viewer/src/store/modules/app.store.js b/packages/viewer/src/store/modules/app.store.js deleted file mode 100644 index 6170de5f8a..0000000000 --- a/packages/viewer/src/store/modules/app.store.js +++ /dev/null @@ -1,30 +0,0 @@ -/** Vuex module that tells if the app has finished loading (is ready to show stuff) */ -export default { - state: { - /** - * Flag that tells if the app is ready to show data and the map - * - * @type Boolean - */ - isReady: false, - - /** - * Flag telling that the Map Module is ready. This is useful for E2E testing which should - * not start before the Map Module is ready. - */ - isMapReady: false, - }, - getters: {}, - actions: { - setAppIsReady: ({ commit }, { dispatcher }) => { - commit('setAppIsReady', { dispatcher }) - }, - mapModuleReady: ({ commit }, { dispatcher }) => { - commit('mapModuleReady', { dispatcher }) - }, - }, - mutations: { - setAppIsReady: (state) => (state.isReady = true), - mapModuleReady: (state) => (state.isMapReady = true), - }, -} diff --git a/packages/viewer/src/store/modules/app.store.ts b/packages/viewer/src/store/modules/app.store.ts new file mode 100644 index 0000000000..d792787d71 --- /dev/null +++ b/packages/viewer/src/store/modules/app.store.ts @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +import { sendMapReadyEventToParent } from '@/api/iframePostMessageEvent.api' + +/** Module that tells if the app has finished loading (is ready to show stuff) */ +interface AppState { + /** Flag that tells if the app is ready to show data and the map */ + isReady: boolean + /** + * Flag telling that the Map Module is ready. This is useful for E2E testing which should not + * start before the Map Module is ready. + */ + isMapReady: boolean +} + +const useAppStore = defineStore('app', { + state: (): AppState => ({ + isReady: false, + isMapReady: false, + }), + getters: {}, + actions: { + setAppIsReady(dispatcher: ActionDispatcher) { + this.isReady = true + }, + setMapModuleReady(dispatcher: ActionDispatcher) { + this.isMapReady = true + sendMapReadyEventToParent() + }, + }, +}) + +export default useAppStore diff --git a/packages/viewer/src/store/modules/cesium.store.js b/packages/viewer/src/store/modules/cesium.store.js deleted file mode 100644 index 818ca6879e..0000000000 --- a/packages/viewer/src/store/modules/cesium.store.js +++ /dev/null @@ -1,180 +0,0 @@ -import GeoAdmin3DLayer from '@/api/layers/GeoAdmin3DLayer.class' -import { - CESIUM_BUILDING_LAYER_ID, - CESIUM_CONSTRUCTIONS_LAYER_ID, - CESIUM_LABELS_LAYER_ID, - CESIUM_LAYER_TOOLTIPS_CONFIGURATION, - CESIUM_VEGETATION_LAYER_ID, -} from '@/config/cesium.config' - -const labelLayer = new GeoAdmin3DLayer({ - layerId: CESIUM_LABELS_LAYER_ID, - layerName: '3d_labels', - urlTimestampToUse: '20180716', - use3dTileSubFolder: true, -}) -const vegetationLayer = new GeoAdmin3DLayer({ - layerId: CESIUM_VEGETATION_LAYER_ID, - layerName: '3d_vegetation', - urlTimestampToUse: 'v1', - use3dTileSubFolder: false, -}) -const buildingsLayer = new GeoAdmin3DLayer({ - layerId: CESIUM_BUILDING_LAYER_ID, - layerName: '3d_constructions', - urlTimestampToUse: 'v1', - use3dTileSubFolder: false, // buildings JSON has already been migrated to the new URL nomenclature -}) -const constructionsLayer = new GeoAdmin3DLayer({ - layerId: CESIUM_CONSTRUCTIONS_LAYER_ID, - layerName: '3d_constructions', - urlTimestampToUse: 'v1', - use3dTileSubFolder: false, // buildings JSON has already been migrated to the new URL nomenclature -}) - -/** Module that stores all information related to the 3D viewer */ -export default { - state: { - /** - * Flag telling if the app should be displaying the map in 3D or not - * - * @type Boolean - */ - active: false, - /** - * Flag telling if the 3D viewer should show the vegetation layer - * (ch.swisstopo.vegetation.3d) - * - * The vegetation layer needs to be updated to work optimally with the latest version of - * Cesium. While waiting for this update to be available, we disable the vegetation layer by - * default (it can be activated through the debug tools on the side of the map) - * - * @type Boolean - */ - showVegetation: false, - /** - * Flag telling if the 3D viewer should show buildings (ch.swisstopo.swisstlbuildings3d.3d). - * As this layer has already been updated for the latest Cesium stack, we activate it by - * default. - * - * @type Boolean - */ - showBuildings: true, - /** - * Flag telling if the 3D viewer should show buildings (ch.swisstopo.swisstlm3d.3d). As this - * layer has already been updated for the latest Cesium stack, we activate it by default. - * - * @type Boolean - */ - showConstructions: true, - /** - * Flag telling if the 3D viewer should show the labels () - * - * @type Boolean - */ - showLabels: true, - /** - * Flag telling if the 3D viewer is ready or not - * - * @type Boolean - */ - isViewerReady: false, - /** - * An array of Cesium Layer tooltip configurations, stating which Cesium layers have - * tooltips, and what should be shown to the user - * - * @type LayerTooltipConfig[] - */ - layersTooltipConfig: CESIUM_LAYER_TOOLTIPS_CONFIGURATION, - }, - getters: { - backgroundLayersFor3D(state, getters, rootState) { - const bgLayers = [] - const backgroundLayer = getters.currentBackgroundLayer - if (backgroundLayer) { - if (backgroundLayer.idIn3d) { - bgLayers.push( - rootState.layers.config.find((layer) => layer.id === backgroundLayer.idIn3d) - ) - } else { - bgLayers.push(backgroundLayer) - } - } - if (state.showLabels) { - // labels are not up-to-date with the latest Cesium version, but we need then anyway ¯\_(ツ)_/¯ - bgLayers.push(labelLayer) - } - if (state.showBuildings) { - bgLayers.push(buildingsLayer) - } - if (state.showConstructions) { - bgLayers.push(constructionsLayer) - } - if (state.showVegetation) { - bgLayers.push(vegetationLayer) - } - return bgLayers - }, - layersWithTooltips(state, getters) { - return getters.backgroundLayersFor3D.filter((bgLayer) => - getters.layersTooltipConfig - .map((layerConfig) => layerConfig.layerId) - .includes(bgLayer.id) - ) - }, - layersTooltipConfig(state) { - return state.layersTooltipConfig - }, - }, - actions: { - set3dActive({ commit }, { active, dispatcher }) { - commit('set3dActive', { active: !!active, dispatcher }) - }, - toggleShow3dBuildings({ commit, state }, { dispatcher }) { - commit('setShowBuildings', { showBuildings: !state.showBuildings, dispatcher }) - }, - toggleShow3dConstructions({ commit, state }, { dispatcher }) { - commit('setShowConstructions', { - showConstructions: !state.showConstructions, - dispatcher, - }) - }, - toggleShow3dConstructionsBuildings({ commit, state }, { dispatcher }) { - commit('setShowConstructionsBuildings', { - showConstructionsBuildings: !(state.showConstructions || state.showBuildings), - dispatcher, - }) - }, - toggleShow3dVegetation({ commit, state }, { dispatcher }) { - commit('setShowVegetation', { showVegetation: !state.showVegetation, dispatcher }) - }, - setShowConstructionsBuildings({ commit }, { showConstructionsBuildings, dispatcher }) { - commit('setShowConstructionsBuildings', { - showConstructionsBuildings, - dispatcher, - }) - }, - setShowVegetation({ commit }, { showVegetation, dispatcher }) { - commit('setShowVegetation', { showVegetation, dispatcher }) - }, - setShowLabels({ commit }, { showLabels, dispatcher }) { - commit('setShowLabels', { showLabels, dispatcher }) - }, - setViewerReady({ commit }, { isViewerReady, dispatcher }) { - commit('setViewerReady', { isViewerReady, dispatcher }) - }, - }, - mutations: { - set3dActive: (state, { active }) => (state.active = active), - setShowBuildings: (state, { showBuildings }) => (state.showBuildings = showBuildings), - setShowConstructions: (state, { showConstructions }) => - (state.showConstructions = showConstructions), - setShowVegetation: (state, { showVegetation }) => (state.showVegetation = showVegetation), - setShowConstructionsBuildings: (state, { showConstructionsBuildings }) => { - state.showBuildings = showConstructionsBuildings - state.showConstructions = showConstructionsBuildings - }, - setShowLabels: (state, { showLabels }) => (state.showLabels = showLabels), - setViewerReady: (state, { isViewerReady }) => (state.isViewerReady = isViewerReady), - }, -} diff --git a/packages/viewer/src/store/modules/cesium.store.ts b/packages/viewer/src/store/modules/cesium.store.ts new file mode 100644 index 0000000000..237c6e2e18 --- /dev/null +++ b/packages/viewer/src/store/modules/cesium.store.ts @@ -0,0 +1,155 @@ +import type { Layer } from '@swissgeo/layers' + +import { layerUtils } from '@swissgeo/layers/utils' +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +import { get3dTilesBaseUrl } from '@/config/baseUrl.config' +import { + CESIUM_BUILDING_LAYER_ID, + CESIUM_CONSTRUCTIONS_LAYER_ID, + CESIUM_LABELS_LAYER_ID, + CESIUM_LAYER_TOOLTIPS_CONFIGURATION, + CESIUM_VEGETATION_LAYER_ID, + type LayerTooltipConfig, +} from '@/config/cesium.config' +import useLayersStore from '@/store/modules/layers.store' + +const labelLayer = layerUtils.makeGeoAdmin3DLayer({ + id: CESIUM_LABELS_LAYER_ID, + name: '3d_labels', + urlTimestampToUse: '20180716', + use3dTileSubFolder: true, + baseUrl: get3dTilesBaseUrl(), +}) +const vegetationLayer = layerUtils.makeGeoAdmin3DLayer({ + id: CESIUM_VEGETATION_LAYER_ID, + name: '3d_vegetation', + urlTimestampToUse: 'v1', + use3dTileSubFolder: false, + baseUrl: get3dTilesBaseUrl(), +}) +const buildingsLayer = layerUtils.makeGeoAdmin3DLayer({ + id: CESIUM_BUILDING_LAYER_ID, + name: '3d_constructions', + urlTimestampToUse: 'v1', + use3dTileSubFolder: false, // buildings JSON has already been migrated to the new URL nomenclature + baseUrl: get3dTilesBaseUrl(), +}) +const constructionsLayer = layerUtils.makeGeoAdmin3DLayer({ + id: CESIUM_CONSTRUCTIONS_LAYER_ID, + name: '3d_constructions', + urlTimestampToUse: 'v1', + use3dTileSubFolder: false, // buildings JSON has already been migrated to the new URL nomenclature + baseUrl: get3dTilesBaseUrl(), +}) + +/** Module that stores all information related to the 3D viewer */ +export interface CesiumState { + /** Flag telling if the app should be displaying the map in 3D or not */ + active: boolean + /** + * Flag telling if the 3D viewer should show the vegetation layer (ch.swisstopo.vegetation.3d) + * + * The vegetation layer needs to be updated to work optimally with the latest version of Cesium. + * While waiting for this update to be available, we disable the vegetation layer by default (it + * can be activated through the debug tools on the side of the map) + */ + showVegetation: boolean + /** + * Flag telling if the 3D viewer should show buildings (ch.swisstopo.swisstlbuildings3d.3d). As + * this layer has already been updated for the latest Cesium stack, we activate it by default. + */ + showBuildings: boolean + /** + * Flag telling if the 3D viewer should show buildings (ch.swisstopo.swisstlm3d.3d). As this + * layer has already been updated for the latest Cesium stack, we activate it by default. + */ + showConstructions: boolean + /** Flag telling if the 3D viewer should show the labels () */ + showLabels: boolean + /** Flag telling if the 3D viewer is ready or not */ + isViewerReady: boolean + /** + * An array of Cesium Layer tooltip configurations, stating which Cesium layers have tooltips, + * and what should be shown to the user + */ + layersTooltipConfig: LayerTooltipConfig[] +} + +const useCesiumStore = defineStore('cesium', { + state: (): CesiumState => ({ + active: false, + showVegetation: false, + showBuildings: true, + showConstructions: true, + showLabels: true, + isViewerReady: false, + layersTooltipConfig: CESIUM_LAYER_TOOLTIPS_CONFIGURATION, + }), + getters: { + backgroundLayersFor3D(): Layer[] { + const layerStore = useLayersStore() + + const bgLayers = [] + const backgroundLayer = layerStore.currentBackgroundLayer + if (backgroundLayer) { + if ('idIn3d' in backgroundLayer && backgroundLayer.idIn3d !== undefined) { + const matchingBackgroundIn3D = layerStore.config.find( + (layer) => layer.id === backgroundLayer.idIn3d + ) + bgLayers.push(matchingBackgroundIn3D ?? backgroundLayer) + } else { + bgLayers.push(backgroundLayer) + } + } + if (this.showLabels) { + // labels are not up-to-date with the latest Cesium version, but we need then anyway ¯\_(ツ)_/¯ + bgLayers.push(labelLayer) + } + if (this.showBuildings) { + bgLayers.push(buildingsLayer) + } + if (this.showConstructions) { + bgLayers.push(constructionsLayer) + } + if (this.showVegetation) { + bgLayers.push(vegetationLayer) + } + return bgLayers + }, + + layersWithTooltips(): Layer[] { + return this.backgroundLayersFor3D.filter((bgLayer) => + this.layersTooltipConfig + .map((layerConfig) => layerConfig.layerId) + .includes(bgLayer.id) + ) + }, + }, + actions: { + set3dActive(active: boolean, dispatcher: ActionDispatcher) { + this.active = active + }, + + setShowConstructionsBuildings(show: boolean, dispatcher: ActionDispatcher) { + this.showConstructions = show + this.showBuildings = show + }, + + setShowVegetation(show: boolean, dispatcher: ActionDispatcher) { + this.showVegetation = show + }, + + setShowLabels(show: boolean, dispatcher: ActionDispatcher) { + this.showLabels = show + }, + + setViewerReady(isReady: boolean, dispatcher: ActionDispatcher) { + this.isViewerReady = isReady + }, + }, +}) + +export default useCesiumStore diff --git a/packages/viewer/src/store/modules/debug.store.ts b/packages/viewer/src/store/modules/debug.store.ts new file mode 100644 index 0000000000..a2d5c8cd32 --- /dev/null +++ b/packages/viewer/src/store/modules/debug.store.ts @@ -0,0 +1,34 @@ +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +/** Module that contains debug tools things */ +interface DebugState { + showTileDebugInfo: boolean + showLayerExtents: boolean + hasBaseUrlOverrides: boolean +} + +const useDebugStore = defineStore('debug', { + state: (): DebugState => ({ + showTileDebugInfo: false, + showLayerExtents: false, + hasBaseUrlOverrides: false, + }), + getters: {}, + actions: { + toggleShowTileDebugInfo(dispatcher: ActionDispatcher) { + this.showTileDebugInfo = !this.showTileDebugInfo + }, + + toggleShowLayerExtents(dispatcher: ActionDispatcher) { + this.showLayerExtents = !this.showLayerExtents + }, + + setHasBaseUrlOverrides(hasOverrides: boolean, dispatcher: ActionDispatcher) { + this.hasBaseUrlOverrides = hasOverrides + }, + }, +}) + +export default useDebugStore diff --git a/packages/viewer/src/store/modules/drawing.store.js b/packages/viewer/src/store/modules/drawing.store.js deleted file mode 100644 index 45f74d8983..0000000000 --- a/packages/viewer/src/store/modules/drawing.store.js +++ /dev/null @@ -1,235 +0,0 @@ -import { EditableFeatureTypes } from '@/api/features/EditableFeature.class' -import { loadAllIconSetsFromBackend } from '@/api/icon.api' - -const defaultDrawingTitle = 'draw_mode_title' - -/** - * @typedef SelectedFeatureData - * @property {[number, number]} coordinate - * @property {string} featureId - */ - -/** @enum */ -export const EditMode = { - OFF: 'OFF', - MODIFY: 'MODIFY', // Mode for modifying existing features - EXTEND: 'EXTEND', // Mode for extending existing features (for line only) -} - -export default { - state: { - /** - * Current drawing mode (or `null` if there is none). See {@link EditableFeatureTypes} - * - * @type {String | null} - */ - mode: null, - /** - * List of all available icon sets for drawing (loaded from the backend service-icons) - * - * @type {IconSet[]} - */ - iconSets: [], - /** - * Feature IDs of all features that have been drawn. - * - * Removing an ID from the list will trigger a watcher that will delete the respective - * feature. - * - * @type {String[]} - */ - featureIds: [], - /** - * Drawing overlay configuration - * - * @type {{ show: boolean; title: string }} - */ - drawingOverlay: { - /** - * Flag to toggle drawing mode overlay - * - * @type {Boolean} - */ - show: false, - /** - * Title translation key of the drawing overlay - * - * @type {String} - */ - title: defaultDrawingTitle, - }, - /** - * KML is saved online using the KML backend service - * - * @type {Boolean} - */ - online: true, - /** - * KML ID to use for temporary local KML (only used when online === false) - * - * @type {String | null} - */ - temporaryKmlId: null, - /** - * The name of the drawing, or null if no drawing is currently edited. - * - * @type {String | null} - */ - name: null, - /** - * If true, continue the line string from the starting vertex, else it will continue from - * the last vertex - * - * @type {Boolean | null} - */ - reverseLineStringExtension: false, - - /** - * Current editing mode. See {@link EditMode} - * - * @type {String | null} - */ - editingMode: EditMode.OFF, - - /** - * Flag to indicate if the drawing is shared with an admin id - * - * @type {Boolean} - */ - isDrawingEditShared: false, - - /** - * Flag to indicate if the drawing has been modified - * - * @type {Boolean} - */ - isDrawingModified: false, - - /** - * Flag to indicate if the website is visited with an admin id - * - * @type {Boolean} - */ - isVisitWithAdminId: false, - }, - getters: { - isDrawingEmpty(state) { - return state.featureIds.length === 0 - }, - - showNotSharedDrawingWarning: (state) => - !state.isVisitWithAdminId && - !state.isDrawingEditShared && - state.isDrawingModified && - state.online, - }, - actions: { - setDrawingMode({ commit }, { mode, dispatcher }) { - if (mode in EditableFeatureTypes || mode === null) { - commit('setDrawingMode', { mode, dispatcher }) - } - }, - setIsDrawingEditShared({ commit, state }, { value = null, dispatcher }) { - commit('setIsDrawingEditShared', { - value: value === null ? state.isDrawingEditShared : value, - dispatcher, - }) - }, - setIsDrawingModified({ commit, state }, { value = null, dispatcher }) { - commit('setIsDrawingModified', { - value: value === null ? state.isDrawingModified : value, - dispatcher, - }) - }, - setIsVisitWithAdminId({ commit, state }, { value = null, dispatcher }) { - commit('setIsVisitWithAdminId', { - value: value === null ? state.isVisitWithAdminId : value, - dispatcher, - }) - }, - async loadAvailableIconSets({ commit }, { dispatcher }) { - const iconSets = await loadAllIconSetsFromBackend() - if (iconSets?.length > 0) { - commit('setIconSets', { iconSets, dispatcher }) - } - }, - addDrawingFeature({ commit }, { featureId, dispatcher }) { - commit('addDrawingFeature', { featureId, dispatcher }) - }, - deleteDrawingFeature({ commit, dispatch }, { featureId, dispatcher }) { - dispatch('clearAllSelectedFeatures', { dispatcher: dispatcher }) - commit('deleteDrawingFeature', { featureId }) - }, - clearDrawingFeatures({ commit }, { dispatcher }) { - commit('setDrawingFeatures', { featureIds: [], dispatcher }) - }, - setDrawingFeatures({ commit }, { featureIds, dispatcher }) { - commit('setDrawingFeatures', { featureIds, dispatcher }) - }, - toggleDrawingOverlay( - { commit, state }, - { online = true, kmlId = null, title = defaultDrawingTitle, dispatcher } - ) { - commit('setShowDrawingOverlay', { - show: !state.drawingOverlay.show, - online, - kmlId, - title, - dispatcher, - }) - }, - setShowDrawingOverlay( - { commit }, - { show, online = true, kmlId = null, title = defaultDrawingTitle, dispatcher } - ) { - commit('setShowDrawingOverlay', { - show, - online, - kmlId, - title, - dispatcher, - }) - }, - setDrawingName({ commit }, { name, dispatcher }) { - commit('setDrawingName', { name, dispatcher }) - }, - setEditingMode({ commit }, { mode, reverseLineStringExtension, dispatcher }) { - if (mode in EditMode) { - if (mode !== EditMode.EXTEND) { - reverseLineStringExtension = null - } - commit('setEditingMode', { mode, reverseLineStringExtension, dispatcher }) - } else { - commit('setEditingMode', { - mode: EditMode.OFF, - reverseLineStringExtension: null, - dispatcher, - }) - } - }, - }, - mutations: { - setDrawingMode: (state, { mode }) => (state.mode = mode), - setIsDrawingEditShared: (state, { value }) => (state.isDrawingEditShared = value), - setIsDrawingModified: (state, { value }) => (state.isDrawingModified = value), - setIsVisitWithAdminId: (state, { value }) => (state.isVisitWithAdminId = value), - setIconSets: (state, { iconSets }) => (state.iconSets = iconSets), - addDrawingFeature: (state, { featureId }) => state.featureIds.push(featureId), - deleteDrawingFeature: (state, { featureId }) => - (state.featureIds = state.featureIds.filter((featId) => featId !== featureId)), - setDrawingFeatures: (state, { featureIds }) => (state.featureIds = featureIds), - setShowDrawingOverlay(state, { show, online, kmlId, title }) { - state.drawingOverlay.show = show - state.drawingOverlay.title = title - state.online = online - state.temporaryKmlId = kmlId - }, - setDrawingName(state, { name }) { - state.name = name - }, - setEditingMode: (state, { mode, reverseLineStringExtension }) => { - state.editingMode = mode - state.reverseLineStringExtension = reverseLineStringExtension - }, - }, -} diff --git a/packages/viewer/src/store/modules/drawing.store.ts b/packages/viewer/src/store/modules/drawing.store.ts new file mode 100644 index 0000000000..d19ba824a6 --- /dev/null +++ b/packages/viewer/src/store/modules/drawing.store.ts @@ -0,0 +1,173 @@ +import { defineStore } from 'pinia' + +import type EditableFeature from '@/api/features/EditableFeature.class.ts' +import type { ActionDispatcher } from '@/store/store' + +import { DrawingIconSet, loadAllIconSetsFromBackend } from '@/api/icon.api.ts' + +const defaultDrawingTitle = 'draw_mode_title' + +export enum DrawingMode { + MARKER = 'MARKER', + ANNOTATION = 'ANNOTATION', + LINEPOLYGON = 'LINEPOLYGON', + MEASURE = 'MEASURE', +} + +/** @enum */ +export enum EditMode { + OFF = 'OFF', + /** Mode for modifying existing features */ + MODIFY = 'MODIFY', + /** Mode for extending existing features (for line only) */ + EXTEND = 'EXTEND', +} + +export interface DrawingState { + /** Current drawing mode (or `undefined` if there is none). */ + mode: DrawingMode | undefined + /** List of all available icon sets for drawing (loaded from the backend service-icons) */ + iconSets: DrawingIconSet[] + /** + * Feature IDs of all features that have been drawn. + * + * Removing an ID from the list will trigger a watcher that will delete the respective feature. + */ + featureIds: string[] + /** Drawing overlay configuration */ + drawingOverlay: { + /** Flag to toggle drawing mode overlay */ + show: boolean + /** Title translation key of the drawing overlay */ + title: string + } + /** KML is saved online using the KML backend service */ + online: boolean + /** KML ID to use for temporary local KML (only used when online === false) */ + temporaryKmlId: string | undefined + /** The name of the drawing, or undefined if no drawing is currently edited. */ + name: string | undefined + /** + * If true, continue the line string from the starting vertex, else it will continue from the + * last vertex + */ + reverseLineStringExtension: boolean + /** Current editing mode. */ + editingMode: EditMode + /** Flag to indicate if the drawing is shared with an admin id */ + isDrawingEditShared: boolean + /** Flag to indicate if the drawing has been modified */ + isDrawingModified: boolean + /** Flag to indicate if the website is visited with an admin id */ + isVisitWithAdminId: boolean +} + +const useDrawingStore = defineStore('drawing', { + state: (): DrawingState => ({ + mode: undefined, + iconSets: [], + featureIds: [], + drawingOverlay: { + show: false, + title: defaultDrawingTitle, + }, + online: true, + temporaryKmlId: undefined, + name: undefined, + reverseLineStringExtension: false, + editingMode: EditMode.OFF, + isDrawingEditShared: false, + isDrawingModified: false, + isVisitWithAdminId: false, + }), + getters: { + isDrawingEmpty(): boolean { + return this.featureIds.length === 0 + }, + + showNotSharedDrawingWarning(): boolean { + return ( + !this.isVisitWithAdminId && + !this.isDrawingEditShared && + this.isDrawingModified && + this.online + ) + }, + }, + actions: { + setDrawingMode(mode: DrawingMode | undefined, dispatcher: ActionDispatcher) { + if (mode === undefined || mode in DrawingMode) { + this.mode = mode + } + }, + + setIsDrawingEditShared(isShared: boolean, dispatcher: ActionDispatcher) { + this.isDrawingEditShared = isShared + }, + + setIsDrawingModified(isModified: boolean, dispatcher: ActionDispatcher) { + this.isDrawingModified = isModified + }, + + setIsVisitWithAdminId(isVisitingWithAdminId: boolean, dispatcher: ActionDispatcher) { + this.isVisitWithAdminId = isVisitingWithAdminId + }, + + async loadAvailableIconSets(dispatcher: ActionDispatcher) { + const iconSets = await loadAllIconSetsFromBackend() + if (iconSets?.length > 0) { + this.iconSets = iconSets + } + }, + + addDrawingFeature(featureId: string, dispatcher: ActionDispatcher) { + this.featureIds.push(featureId) + }, + + deleteDrawingFeature(featureId: string, dispatcher: ActionDispatcher) { + // TODO: replace with useFeatureStore + // dispatch('clearAllSelectedFeatures', { dispatcher: dispatcher }) + this.featureIds = this.featureIds.filter( + (existingFeatureId) => existingFeatureId !== featureId + ) + }, + + clearDrawingFeatures(dispatcher: ActionDispatcher) { + this.featureIds = [] + }, + + setDrawingFeatures(featureIds: string[], dispatcher: ActionDispatcher) { + this.featureIds = [...featureIds] + }, + + toggleDrawingOverlay( + payload: { online?: boolean; kmlId?: string; title?: string }, + dispatcher: ActionDispatcher + ) { + const { online, kmlId, title = defaultDrawingTitle } = payload + this.drawingOverlay.show = !this.drawingOverlay.show + this.drawingOverlay.title = title + this.online = typeof online === 'boolean' ? online : true + this.temporaryKmlId = kmlId + }, + + setDrawingName(name: string, dispatcher: ActionDispatcher) { + this.name = name + }, + + setEditingMode( + mode: EditMode, + reverseLineStringExtension: boolean, + dispatcher: ActionDispatcher + ) { + this.editingMode = mode + if (mode !== EditMode.EXTEND) { + this.reverseLineStringExtension = false + } else { + this.reverseLineStringExtension = reverseLineStringExtension + } + }, + }, +}) + +export default useDrawingStore diff --git a/packages/viewer/src/store/modules/features.store.js b/packages/viewer/src/store/modules/features.store.js deleted file mode 100644 index 49a8c3ded5..0000000000 --- a/packages/viewer/src/store/modules/features.store.js +++ /dev/null @@ -1,779 +0,0 @@ -import { extentUtils } from '@swissgeo/coordinates' -import log from '@swissgeo/log' -import { containsCoordinate } from 'ol/extent' -import { toRaw } from 'vue' - -import EditableFeature, { EditableFeatureTypes } from '@/api/features/EditableFeature.class' -import getFeature, { identify, identifyOnGeomAdminLayer } from '@/api/features/features.api' -import LayerFeature from '@/api/features/LayerFeature.class' -import { sendFeatureInformationToIFrameParent } from '@/api/iframePostMessageEvent.api' -import { - DEFAULT_FEATURE_COUNT_RECTANGLE_SELECTION, - DEFAULT_FEATURE_COUNT_SINGLE_POINT, -} from '@/config/map.config' -import { - allStylingColors, - allStylingSizes, -} from '@/utils/featureStyleUtils' - -/** @enum */ -export const IdentifyMode = { - /* Clear previous selection and identify features at the given coordinate */ - NEW: 'NEW', - // Toggle selection: remove if already selected, add if not - TOGGLE: 'TOGGLE', -} - -const getEditableFeatureWithId = (state, featureId) => { - return state.selectedEditableFeatures.find( - (selectedFeature) => selectedFeature.id === featureId - ) -} - -export function getFeatureCountForCoordinate(coordinate) { - return coordinate.length === 2 - ? DEFAULT_FEATURE_COUNT_SINGLE_POINT - : DEFAULT_FEATURE_COUNT_RECTANGLE_SELECTION -} - -/** - * Identifies feature at the given coordinates - * - * @param {AbstractLayer[]} config.layers - * @param {[Number, Number] | [Number, Number, Number, Number]} config.coordinate Where to identify, - * either a single point, or an extent (expressed as [minX, maxY, minY, maxY]) - * @param {Number} config.resolution The current map resolution, in meter/pixel - * @param {[Number, Number, Number, Number]} config.mapExtent The current map extent, described as - * [minX, maxX, minY, maxY] - * @param {Number} config.screenWidth Current screen width (map width) in pixel - * @param {Number} config.screenHeight Current screen height (map height) in pixel - * @param {String} config.lang Current lang ISO code - * @param {CoordinateSystem} config.projection Wanted projection with which to request the backend - * @param {Number} [config.featureCount] How many features should be requested. If not given, will - * default to 10 for single coordinate, or 50 for extents. - * @returns {Promise} A promise that will contain all feature identified by the - * different requests (won't be grouped by layer) - */ -export const runIdentify = (config) => { - const { - layers, - coordinate, - resolution, - mapExtent, - screenWidth, - screenHeight, - lang, - projection, - featureCount, - } = config - return new Promise((resolve, reject) => { - const allFeatures = [] - const pendingRequests = [] - const commonParams = { - coordinate, - resolution, - mapExtent, - screenWidth, - screenHeight, - lang, - projection, - featureCount, - } - // for each layer we run a backend request - // NOTE: in theory for the Geoadmin layers we could run one single backend request to API3 instead of one per layer, however - // this would not be more efficient as a single request would take more time that several in parallel (this has been tested). - layers - // only request layers that have getFeatureInfo capabilities (or are flagged has having a tooltip in their config for GeoAdmin layers) - .filter((layer) => layer.hasTooltip) - // filtering out any layer for which their extent doesn't contain the wanted coordinate (no data anyway, no need to request) - .filter( - (layer) => - !layer.extent || - containsCoordinate(extentUtils.flattenExtent(layer.extent), coordinate) - ) - .forEach((layer) => { - pendingRequests.push( - identify({ - layer, - ...commonParams, - }) - ) - }) - // grouping all features from the different requests - Promise.allSettled(pendingRequests) - .then((responses) => { - responses.forEach((response) => { - if (response.status === 'fulfilled' && response.value) { - allFeatures.push(...response.value) - } else { - log.error( - 'Error while identifying features on external layer, response is', - response - ) - // no reject, so that we may see at least the result of requests that have been fulfilled - } - }) - // filtering out duplicates - resolve( - Array.from( - new Map(allFeatures.map((feature) => [feature.id, feature])).values() - ) - ) - }) - .catch((error) => { - log.error('Error while identifying features', error) - reject(error) - }) - }) -} - -/** - * @typedef FeaturesForLayer - * @property {String} layerId - * @property {LayerFeature[]} features - * @property {Number} featureCountForMoreData If there are more data to load, this will be greater - * than 0. If no more data can be requested from the backend, this will be set to 0. - */ - -export default { - state: { - /** @type {FeaturesForLayer[]} */ - selectedFeaturesByLayerId: [], - /** @type Array */ - selectedEditableFeatures: [], - highlightedFeatureId: null, - }, - getters: { - /** @type Array */ - selectedFeatures(state, getters) { - return [...state.selectedEditableFeatures, ...getters.selectedLayerFeatures] - }, - selectedLayerFeatures(state) { - return state.selectedFeaturesByLayerId - .map((featuresForLayer) => featuresForLayer.features) - .flat() - }, - }, - actions: { - /** - * Tells the map to highlight a list of features (place a round marker at their location). - * Those features are currently shown by the tooltip. If in drawing mode, this functions - * tells the store which features are selected (it does not select the features by itself) - * - * @param commit - * @param {SelectableFeature[]} features A list of feature we want to highlight/select on - * the map - * @param {Number} paginationSize How many features were requested, will help set if a layer - * can have more data or not (if its feature count is a multiple of paginationSize) - */ - setSelectedFeatures( - { commit, dispatch, state, rootState }, - { features, paginationSize = DEFAULT_FEATURE_COUNT_SINGLE_POINT, dispatcher } - ) { - // clearing up any relevant selected features stuff - if (state.highlightedFeatureId) { - commit('setHighlightedFeatureId', { - highlightedFeatureId: null, - dispatcher, - }) - } - if (rootState.profile.feature) { - dispatch('setProfileFeature', { feature: null, dispatcher }) - } - /** @type {FeaturesForLayer[]} */ - const layerFeaturesByLayerId = [] - let drawingFeatures = [] - if (Array.isArray(features)) { - const layerFeatures = features.filter((feature) => feature instanceof LayerFeature) - drawingFeatures = features.filter((feature) => feature instanceof EditableFeature) - layerFeatures.forEach((feature) => { - if ( - !layerFeaturesByLayerId.some( - (featureForLayer) => featureForLayer.layerId === feature.layer.id - ) - ) { - layerFeaturesByLayerId.push({ - layerId: feature.layer.id, - features: [], - featureCountForMoreData: paginationSize, - }) - } - const featureForLayer = layerFeaturesByLayerId.find( - (featureForLayer) => featureForLayer.layerId === feature.layer.id - ) - featureForLayer.features.push(feature) - }) - layerFeaturesByLayerId.forEach((layerFeatures) => { - // if less feature than the pagination size are present, we can already tell there won't be more data to load - layerFeatures.featureCountForMoreData = - layerFeatures.features.length % paginationSize === 0 ? paginationSize : 0 - }) - - // as described by this example on our documentation : https://codepen.io/geoadmin/pen/yOBzqM?editors=0010 - // our app should send a message (see https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) - // when a feature is selected while embedded, so that the parent can get the selected feature(s) ID(s) - sendFeatureInformationToIFrameParent(layerFeatures) - } - commit('setSelectedFeatures', { - drawingFeatures, - layerFeaturesByLayerId, - dispatcher, - }) - }, - /** - * Identify features in layers at the given coordinate. - * - * @param dispatch - * @param getters - * @param rootState - * @param {AbstractLayer[]} layers List of layers for which we want to know if features are - * present at given coordinates - * @param {LayerFeature[]} [vectorFeatures=[]] List of existing vector features at given - * coordinate (that should be added to the selected features after identification has been - * run on the backend). Default is `[]` - * @param {[Number, Number] | [Number, Number, Number, Number]} coordinate A point ([x,y]), - * or a rectangle described by a flat extent ([minX, maxX, minY, maxY]). 10 features will - * be requested for a point, 50 for a rectangle. - * @param {IdentifyMode} [identifyMode=IdentifyMode.NEW] - The selection mode: NEW (replace - * selection) or TOGGLE (toggle features in selection). Default is `IdentifyMode.NEW` - * @param dispatcher - * @returns {Promise} As some callers might want to know when identify has been - * done/finished, this returns a promise that will be resolved when this is the case - */ - async identifyFeatureAt( - { dispatch, getters, rootState }, - { layers, coordinate, vectorFeatures = [], identifyMode = IdentifyMode.NEW, dispatcher } - ) { - const featureCount = getFeatureCountForCoordinate(coordinate) - const features = [ - ...vectorFeatures, - ...(await runIdentify({ - layers, - coordinate, - resolution: getters.resolution, - mapExtent: extentUtils.flattenExtent(getters.extent), - screenWidth: rootState.ui.width, - screenHeight: rootState.ui.height, - lang: rootState.i18n.lang, - projection: rootState.position.projection, - featureCount, - })), - ] - if (features.length > 0) { - if (identifyMode === IdentifyMode.NEW) { - dispatch('setSelectedFeatures', { - features, - paginationSize: featureCount, - dispatcher, - }) - } else if (identifyMode === IdentifyMode.TOGGLE) { - // Toggle features: remove if already selected, add if not - const oldFeatures = getters.selectedLayerFeatures - const newFeatures = features - // Use feature.id for comparison - const oldFeatureIds = new Set(oldFeatures.map((f) => f.id)) - const newFeatureIds = new Set(newFeatures.map((f) => f.id)) - // features that are present on the map AND in the identify-request result are meant to be toggled - const deselectedFeatures = oldFeatures.filter((f) => newFeatureIds.has(f.id)) - const newlyAddedFeatures = newFeatures.filter((f) => !oldFeatureIds.has(f.id)) - if ( - // Do not add new features if one existing feature was toggled off. Doing so would confuse - // the user, as it would look like the CTRL+Click had no effect (a new feature was added at - // the same spot as the one that was just toggled off) - deselectedFeatures.length > 0 - ) { - // Set features to all existing features minus those that were toggled off - dispatch('setSelectedFeatures', { - features: oldFeatures.filter((f) => !deselectedFeatures.includes(f)), - paginationSize: featureCount, - dispatcher, - }) - } else if (newlyAddedFeatures.length > 0) { - // no feature was "deactivated" we can add the newly selected features - dispatch('setSelectedFeatures', { - features: newlyAddedFeatures.concat(oldFeatures), - paginationSize: featureCount, - dispatcher, - }) - } else { - dispatch('clearAllSelectedFeatures', { dispatcher }) - } - } - } else { - dispatch('clearAllSelectedFeatures', { dispatcher }) - } - }, - /** - * Loads (if possible) more features for the given layer. - * - * Only GeoAdmin layers support this for the time being. For external layer, as we use - * GetFeatureInfo from WMS, there's no such capabilities (we would have to switch to a WFS - * approach to gain access to similar things). - * - * @param commit - * @param state - * @param getters - * @param rootState - * @param {GeoAdminLayer} layer - * @param {[Number, Number] | [Number, Number, Number, Number]} coordinate A point ([x,y]), - * or a rectangle described by a flat extent ([minX, maxX, minY, maxY]). - * @param dispatcher - */ - loadMoreFeaturesForLayer( - { commit, state, getters, rootState }, - { layer, coordinate, dispatcher } - ) { - const featuresAlreadyLoaded = state.selectedFeaturesByLayerId.find( - (featureForLayer) => featureForLayer.layerId === layer?.id - ) - if (featuresAlreadyLoaded && featuresAlreadyLoaded.featureCountForMoreData > 0) { - identifyOnGeomAdminLayer({ - layer, - coordinate, - resolution: getters.resolution, - mapExtent: getters.extent.flat(), - screenWidth: rootState.ui.width, - screenHeight: rootState.ui.height, - lang: rootState.i18n.lang, - projection: rootState.position.projection, - offset: featuresAlreadyLoaded.features.length, - featureCount: featuresAlreadyLoaded.featureCountForMoreData, - }).then((moreFeatures) => { - const featuresForLayer = state.selectedFeaturesByLayerId.find( - (featureForLayer) => featureForLayer.layerId === layer.id - ) - const canLoadMore = - moreFeatures.length > 0 && - moreFeatures.length % featuresAlreadyLoaded.featureCountForMoreData === 0 - commit('addSelectedFeatures', { - featuresForLayer, - features: moreFeatures, - featureCountForMoreData: canLoadMore - ? featuresAlreadyLoaded.featureCountForMoreData - : 0, - dispatcher, - }) - }) - } else { - log.error( - 'No more features can be loaded for layer', - layer, - 'at coordinate', - coordinate - ) - } - }, - /** Removes all selected features from the map */ - clearAllSelectedFeatures({ commit, dispatch, state, rootState }, { dispatcher }) { - commit('setSelectedFeatures', { - layerFeaturesByLayerId: [], - drawingFeatures: [], - dispatcher, - }) - if (state.highlightedFeatureId) { - commit('setHighlightedFeatureId', { - highlightedFeatureId: null, - dispatcher, - }) - } - if (rootState.profile.feature) { - dispatch('setProfileFeature', { feature: null, dispatcher }) - } - }, - setHighlightedFeatureId({ commit }, { highlightedFeatureId = null, dispatcher }) { - commit('setHighlightedFeatureId', { highlightedFeatureId, dispatcher }) - }, - /** - * In drawing mode, informs the store about the new coordinates of the feature. (It does not - * move the feature.) Only change the coordinates if the feature is editable and part of the - * currently selected features. - * - * Coordinates is an array of coordinate. Marker and text feature have only one entry in - * this array while line and measure store each points describing them in this coordinates - * array - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {Number[][]} coordinates - */ - changeFeatureCoordinates({ commit, state }, { feature, coordinates, geodesicCoordinates }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable && Array.isArray(coordinates)) { - commit('changeFeatureCoordinates', { - feature: selectedFeature, - coordinates, - geodesicCoordinates, - }) - } - }, - - changeFeatureGeometry({ commit, dispatch, state }, { feature, geometry, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable && geometry) { - commit('changeFeatureGeometry', { - feature: selectedFeature, - geometry, - dispatcher, - }) - dispatch('setProfileFeature', { feature: selectedFeature, dispatcher }) - } - }, - /** - * Changes the title of the feature. Only change the title if the feature is editable and - * part of the currently selected features - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {String} title - */ - changeFeatureTitle({ commit, state }, { feature, title, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureTitle', { feature: selectedFeature, title, dispatcher }) - } - }, - /** - * Changes the description of the feature. Only change the description if the feature is - * editable and part of the currently selected features - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {String} description - */ - changeFeatureDescription( - { commit, state }, - { feature, description, showDescriptionOnMap, dispatcher } - ) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureDescription', { - feature: selectedFeature, - description, - showDescriptionOnMap, - dispatcher, - }) - } - }, - changeFeatureShownDescriptionOnMap({ commit, state }, { feature, showDescriptionOnMap }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureShownDescriptionOnMap', { - feature: selectedFeature, - showDescriptionOnMap, - }) - } - }, - /** - * Changes the color used to fill the feature. Only change the color if the feature is - * editable, part of the currently selected features and that the given color is a valid - * color from {@link FeatureStyleColor} - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {FeatureStyleColor} color - */ - changeFeatureColor({ commit, state }, { feature, color, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - const wantedColor = allStylingColors.find( - (styleColor) => styleColor.name === color.name - ) - if (wantedColor && selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureColor', { - feature: selectedFeature, - color: wantedColor, - dispatcher, - }) - } - }, - /** - * Changes the text size of the feature. Only change the text size if the feature is - * editable, part of the currently selected features and that the given size is a valid size - * from {@link FeatureStyleSize} - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {FeatureStyleSize} textSize - */ - changeFeatureTextSize({ commit, state }, { feature, textSize, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - const wantedSize = allStylingSizes.find((size) => size.textScale === textSize.textScale) - if (wantedSize && selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureTextSize', { - feature: selectedFeature, - textSize: wantedSize, - dispatcher, - }) - } - }, - - /** - * Changes the text placement of the title of the feature. Only changes the text placement - * if the feature is editable and part of the currently selected features - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {TextPlacement} textPlacement - * @param dispatcher - */ - changeFeatureTextPlacement({ commit, state }, { feature, textPlacement, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - const wantedPlacement = [ - 'top-left', - 'top', - 'top-right', - 'left', - 'center', - 'right', - 'bottom-left', - 'bottom', - 'bottom-right', - 'unknown' - ].find( - (position) => position === textPlacement - ) - if (wantedPlacement && selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureTextPlacement', { - feature: selectedFeature, - textPlacement: wantedPlacement, - dispatcher, - }) - } - }, - /** - * Changes the text offset of the feature. Only change the text offset if the feature is - * editable and part of the currently selected features - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {Array} textOffset - */ - changeFeatureTextOffset({ commit, state }, { feature, textOffset, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureTextOffset', { - feature: selectedFeature, - textOffset, - dispatcher, - }) - } - }, - /** - * Changes the text color of the feature. Only change the text color if the feature is - * editable, part of the currently selected features and that the given color is a valid - * color from {@link FeatureStyleColor} - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {FeatureStyleColor} textColor - */ - changeFeatureTextColor({ commit, state }, { feature, textColor, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - const wantedColor = allStylingColors.find( - (styleColor) => styleColor.name === textColor.name - ) - if (wantedColor && selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureTextColor', { - feature: selectedFeature, - textColor: wantedColor, - dispatcher, - }) - } - }, - /** - * Changes the icon of the feature. Only change the icon if the feature is editable, part of - * the currently selected features, is a marker type feature and that the given icon is - * valid (non-null) - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {Icon} icon - */ - changeFeatureIcon({ commit, state }, { feature, icon, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if ( - icon && - selectedFeature && - selectedFeature.isEditable && - selectedFeature.featureType === EditableFeatureTypes.MARKER - ) { - commit('changeFeatureIcon', { feature: selectedFeature, icon, dispatcher }) - } - }, - /** - * Changes the icon size of the feature. Only change the icon size if the feature is - * editable, part of the currently selected features, is a marker type feature and that the - * given size is a valid size from {@link FeatureStyleSize} - * - * @param commit - * @param state - * @param {EditableFeature} feature - * @param {FeatureStyleSize} iconSize - */ - changeFeatureIconSize({ commit, state }, { feature, iconSize, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - const wantedSize = allStylingSizes.find((size) => size.textScale === iconSize.textScale) - if ( - wantedSize && - selectedFeature && - selectedFeature.isEditable && - selectedFeature.featureType === EditableFeatureTypes.MARKER - ) { - commit('changeFeatureIconSize', { - feature: selectedFeature, - iconSize: wantedSize, - dispatcher, - }) - } - }, - - /** - * In drawing mode , tells the state if a given feature is being dragged. - * - * @param {EditableFeature} feature - * @param {Boolean} isDragged - */ - changeFeatureIsDragged({ commit, state }, { feature, isDragged, dispatcher }) { - const selectedFeature = getEditableFeatureWithId(state, feature.id) - if (selectedFeature && selectedFeature.isEditable) { - commit('changeFeatureIsDragged', { - feature: selectedFeature, - isDragged: !!isDragged, - dispatcher, - }) - } - }, - /** - * The goal of this function is to refresh the selected features according to changes that - * happened in the store, but outside feature selection. For example, when we change the - * language, we need to update the selected features otherwise we keep them in the old - * language until new features are selected. - * - * @param {Store} store The vue store - * @param {Object} dispatcher The dispatcher - */ - async updateFeatures(store, { dispatcher }) { - const { state, commit, getters, rootState } = store - const featuresPromises = [] - getters.selectedLayerFeatures.forEach((feature) => { - // we avoid requesting the drawings and external layers, they're not handled here - const currentFeatureLayer = rootState.layers.config.find( - (layer) => layer.id === feature.layer.id - ) - if (currentFeatureLayer) { - featuresPromises.push( - getFeature(currentFeatureLayer, feature.id, rootState.position.projection, { - lang: rootState.i18n.lang, - screenWidth: rootState.ui.width, - screenHeight: rootState.ui.height, - mapExtent: extentUtils.flattenExtent(getters.extent), - coordinate: rootState.map.clickInfo?.coordinate, - }) - ) - } - }) - if (featuresPromises.length > 0) { - try { - const responses = await Promise.allSettled(featuresPromises) - const features = responses - .filter((response) => response.status === 'fulfilled') - .map((response) => response.value) - if (features.length > 0) { - const updatedFeaturesByLayerId = state.selectedFeaturesByLayerId.reduce( - (updated_array, layer) => { - const rawLayer = toRaw(layer) - const rawLayerFeatures = rawLayer.features - rawLayer.features = features.reduce((features_array, feature) => { - if (feature.layer.id === rawLayer.layerId) { - features_array.push(feature) - } - return features_array - }, []) - if (rawLayer.features.length === 0) { - rawLayer.features = rawLayerFeatures - } - updated_array.push(rawLayer) - return updated_array - }, - [] - ) - await commit('setSelectedFeatures', { - layerFeaturesByLayerId: updatedFeaturesByLayerId, - drawingFeatures: state.selectedEditableFeatures, - ...dispatcher, - }) - } - } catch (error) { - log.error( - `Error while attempting to update already selected features. error is ${error}` - ) - } - } - }, - }, - mutations: { - setSelectedFeatures(state, { layerFeaturesByLayerId, drawingFeatures }) { - state.selectedFeaturesByLayerId = layerFeaturesByLayerId - state.selectedEditableFeatures = [...drawingFeatures] - }, - addSelectedFeatures(state, { featuresForLayer, features, featureCountForMoreData = 0 }) { - featuresForLayer.features.push(...features) - featuresForLayer.featureCountForMoreData = featureCountForMoreData - }, - setHighlightedFeatureId(state, { highlightedFeatureId }) { - state.highlightedFeatureId = highlightedFeatureId - }, - changeFeatureCoordinates(state, { feature, coordinates, geodesicCoordinates }) { - feature.coordinates = coordinates - feature.geodesicCoordinates = geodesicCoordinates - }, - changeFeatureGeometry(state, { feature, geometry }) { - feature.geometry = geometry - }, - changeFeatureTitle(state, { feature, title }) { - feature.title = title - }, - changeFeatureDescription(state, { feature, description, showDescriptionOnMap }) { - feature.description = description - // Only update showDescriptionOnMap if it's provided - if (showDescriptionOnMap !== undefined) { - feature.showDescriptionOnMap = showDescriptionOnMap - } - }, - changeFeatureShownDescriptionOnMap(state, { feature, showDescriptionOnMap }) { - feature.showDescriptionOnMap = showDescriptionOnMap - }, - changeFeatureColor(state, { feature, color }) { - feature.fillColor = color - }, - changeFeatureTextSize(state, { feature, textSize }) { - feature.textSize = textSize - }, - changeFeatureTextPlacement(state, { feature, textPlacement }) { - feature.textPlacement = textPlacement - }, - changeFeatureTextOffset(state, { feature, textOffset }) { - feature.textOffset = textOffset - }, - changeFeatureTextColor(state, { feature, textColor }) { - feature.textColor = textColor - }, - changeFeatureIcon(state, { feature, icon }) { - feature.icon = icon - }, - changeFeatureIconSize(state, { feature, iconSize }) { - feature.iconSize = iconSize - }, - changeFeatureIsDragged(state, { feature, isDragged }) { - feature.isDragged = isDragged - }, - }, -} diff --git a/packages/viewer/src/store/modules/features.store.ts b/packages/viewer/src/store/modules/features.store.ts new file mode 100644 index 0000000000..40d6b8e4f3 --- /dev/null +++ b/packages/viewer/src/store/modules/features.store.ts @@ -0,0 +1,812 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' +import type { GeoAdminLayer, Layer } from '@geoadmin/layers' +import type { Geometry } from 'geojson' + +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { containsCoordinate, getIntersection as getExtentIntersection } from 'ol/extent' +import { defineStore } from 'pinia' + +import type SelectableFeature from '@/api/features/SelectableFeature.class' +import type { DrawingIcon } from '@/api/icon.api.ts' +import type { ActionDispatcher } from '@/store/store' + +import EditableFeature, { + EditableFeatureTextPlacement, + EditableFeatureTypes, +} from '@/api/features/EditableFeature.class' +import getFeature, { + identify, + type IdentifyConfig, + identifyOnGeomAdminLayer, +} from '@/api/features/features.api' +import LayerFeature from '@/api/features/LayerFeature.class' +import { sendFeatureInformationToIFrameParent } from '@/api/iframePostMessageEvent.api' +import { + DEFAULT_FEATURE_COUNT_RECTANGLE_SELECTION, + DEFAULT_FEATURE_COUNT_SINGLE_POINT, +} from '@/config/map.config' +import { useI18nStore } from '@/store/modules/i18n.store' +import useLayersStore from '@/store/modules/layers.store' +import useMapStore from '@/store/modules/map.store' +import useProfileStore from '@/store/modules/profile.store' +import useUIStore from '@/store/modules/ui.store' +import { type FlatExtent, flattenExtent, type NormalizedExtent } from '@/utils/extentUtils' +import { + allStylingColors, + allStylingSizes, + allStylingTextPlacementsWithUnknown, + FeatureStyleColor, + FeatureStyleSize, +} from '@/utils/featureStyleUtils.ts' + +import usePositionStore from './position.store' + +function getEditableFeatureWithId( + selectedEditableFeatures: EditableFeature[], + featureId: string +): EditableFeature | undefined { + return selectedEditableFeatures.find((selectedFeature) => selectedFeature.id === featureId) +} + +export function getFeatureCountForCoordinate(coordinate: SingleCoordinate | FlatExtent): number { + return coordinate.length === 2 + ? DEFAULT_FEATURE_COUNT_SINGLE_POINT + : DEFAULT_FEATURE_COUNT_RECTANGLE_SELECTION +} + +interface MultipleIdentifyConfig extends Omit { + layers: Layer[] +} + +export enum IdentifyMode { + /** Clear previous selection and identify features at the given coordinate */ + NEW = 'NEW', + /** Toggle selection: remove if already selected, add if not */ + TOGGLE = 'TOGGLE', +} + +/** + * Identifies feature at the given coordinates + * + * @returns A promise that will contain all feature identified by the different requests (won't be + * grouped by layer) + */ +export const identifyOnAllLayers = (config: MultipleIdentifyConfig): Promise => { + const { + layers, + coordinate, + resolution, + mapExtent, + screenWidth, + screenHeight, + lang, + projection, + featureCount = getFeatureCountForCoordinate(coordinate), + } = config + return new Promise((resolve, reject) => { + const allFeatures: LayerFeature[] = [] + const pendingRequests: Promise[] = [] + const commonParams = { + coordinate, + resolution, + mapExtent, + screenWidth, + screenHeight, + lang, + projection, + featureCount, + } + // for each layer we run a backend request + // NOTE: in theory for the Geoadmin layers we could run one single backend request to API3 instead of one per layer, however + // this would not be more efficient as a single request would take more time that several in parallel (this has been tested). + layers + // only request layers that have getFeatureInfo capabilities (or are flagged has having a tooltip in their config for GeoAdmin layers) + .filter((layer) => layer.hasTooltip) + // filtering out any layer for which their extent doesn't contain the wanted coordinate (no data anyway, no need to request) + .filter((layer) => { + if ( + !('extent' in layer) || + (Array.isArray(layer.extent) && [2, 4].includes(layer.extent.length)) + ) { + return true + } + const layerExtent: FlatExtent | NormalizedExtent = layer.extent as + | FlatExtent + | NormalizedExtent + if (coordinate.length === 2) { + return containsCoordinate(flattenExtent(layerExtent), coordinate) + } + return getExtentIntersection( + flattenExtent(layerExtent), + flattenExtent(coordinate) + ).every((value) => !isNaN(value)) + }) + .forEach((layer) => { + pendingRequests.push( + identify({ + layer, + ...commonParams, + }) + ) + }) + // grouping all features from the different requests + Promise.allSettled(pendingRequests) + .then((responses) => { + responses.forEach((response) => { + if (response.status === 'fulfilled' && response.value) { + allFeatures.push(...response.value) + } else { + log.error( + 'Error while identifying features on external layer, response is', + response + ) + // no reject, so that we may see at least the result of requests that have been fulfilled + } + }) + // filtering out duplicates + resolve( + Array.from( + new Map(allFeatures.map((feature) => [feature.id, feature])).values() + ) + ) + }) + .catch((error) => { + log.error({ + title: 'Feature store / identify', + titleStyle: { + backgroundColor: LogPreDefinedColor.Purple, + }, + messages: ['Error while identifying features', error], + }) + reject(new Error(error)) + }) + }) +} + +export interface FeaturesForLayer { + layerId: string + features: LayerFeature[] + /** + * If there are more data to load, this will be greater than 0. If no more data can be requested + * from the backend, this will be set to 0. + */ + featureCountForMoreData: number +} + +export interface FeaturesState { + selectedFeaturesByLayerId: FeaturesForLayer[] + selectedEditableFeatures: EditableFeature[] + highlightedFeatureId: string | undefined +} + +const useFeaturesStore = defineStore('features', { + state: (): FeaturesState => ({ + selectedFeaturesByLayerId: [], + selectedEditableFeatures: [], + highlightedFeatureId: undefined, + }), + getters: { + selectedLayerFeatures(): LayerFeature[] { + return this.selectedFeaturesByLayerId + .map((featuresForLayer) => featuresForLayer.features) + .flat() + }, + + selectedFeatures(): SelectableFeature[] { + return [...this.selectedEditableFeatures, ...this.selectedLayerFeatures] + }, + }, + actions: { + /** + * Tells the map to highlight a list of features (place a round marker at their location). + * Those features are currently shown by the tooltip. If in drawing mode, this functions + * tells the store which features are selected (it does not select the features by itself) + * + * @param payload + * @param payload.features A list of feature we want to highlight/select on the map + * @param payload.paginationSize How many features were requested, will help set if a layer + * can have more data or not (if its feature count is a multiple of paginationSize) + * @param dispatcher + */ + setSelectedFeatures( + payload: { features: SelectableFeature[]; paginationSize?: number }, + dispatcher: ActionDispatcher + ) { + const { features, paginationSize = DEFAULT_FEATURE_COUNT_SINGLE_POINT } = payload + // clearing up any relevant selected features stuff + if (this.highlightedFeatureId) { + this.highlightedFeatureId = undefined + } + const profileStore = useProfileStore() + if (profileStore.feature) { + profileStore.setProfileFeature({ feature: undefined }, dispatcher) + } + const layerFeaturesByLayerId: FeaturesForLayer[] = [] + let drawingFeatures: EditableFeature[] = [] + if (Array.isArray(features)) { + const layerFeatures = features.filter((feature) => feature instanceof LayerFeature) + drawingFeatures = features.filter((feature) => feature instanceof EditableFeature) + layerFeatures.forEach((feature) => { + if ( + !layerFeaturesByLayerId.some( + (featureForLayer) => featureForLayer.layerId === feature.layer.id + ) + ) { + layerFeaturesByLayerId.push({ + layerId: feature.layer.id, + features: [], + featureCountForMoreData: paginationSize, + }) + } + const featureForLayer = layerFeaturesByLayerId.find( + (featureForLayer) => featureForLayer.layerId === feature.layer.id + ) + if (featureForLayer) { + featureForLayer.features.push(feature) + } + }) + layerFeaturesByLayerId.forEach((layerFeatures) => { + // if less feature than the pagination size are present, we can already tell there won't be more data to load + layerFeatures.featureCountForMoreData = + layerFeatures.features.length % paginationSize === 0 ? paginationSize : 0 + }) + + // as described by this example on our documentation: https://codepen.io/geoadmin/pen/yOBzqM?editors=0010 + // our app should send a message (see https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) + // when a feature is selected while embedded, so that the parent can get the selected feature(s) ID(s) + sendFeatureInformationToIFrameParent(layerFeatures) + } + this.selectedFeaturesByLayerId = layerFeaturesByLayerId + this.selectedEditableFeatures = [...drawingFeatures] + }, + + /** + * Identify features in layers at the given coordinate. + * + * @param payload + * @param payload.layers List of layers for which we want to know if features are present at + * given coordinates + * @param payload.vectorFeatures List of existing vector features at given coordinate (that + * should be added to the selected features after identification has been run on the + * backend). Default is `[]` + * @param payload.coordinate A point ([x,y]), or a rectangle described by a flat extent + * ([minX, maxX, minY, maxY]). 10 features will be requested for a point, 50 for a + * rectangle. + * @param dispatcher + * @returns As some callers might want to know when identify has been done/finished, this + * returns a promise that will be resolved when this is the case + */ + async identifyFeatureAt( + payload: { + layers: Layer[] + coordinate: SingleCoordinate | FlatExtent + vectorFeatures: LayerFeature[] + identifyMode?: IdentifyMode + }, + dispatcher: ActionDispatcher + ) { + const { + layers, + coordinate, + vectorFeatures = [], + identifyMode = IdentifyMode.NEW, + } = payload + const featureCount = getFeatureCountForCoordinate(coordinate) + + const i18nStore = useI18nStore() + const positionStore = usePositionStore() + const uiStore = useUIStore() + + const features = [ + ...vectorFeatures, + ...(await identifyOnAllLayers({ + layers, + coordinate, + resolution: positionStore.resolution, + mapExtent: flattenExtent(positionStore.extent), + screenWidth: uiStore.width, + screenHeight: uiStore.height, + lang: i18nStore.lang, + projection: positionStore.projection, + featureCount, + })), + ] + if (features.length > 0) { + if (identifyMode === IdentifyMode.NEW) { + this.setSelectedFeatures( + { + features, + paginationSize: featureCount, + }, + dispatcher + ) + } else if (identifyMode === IdentifyMode.TOGGLE) { + // Toggle features: remove if already selected, add if not + const oldFeatures = this.selectedLayerFeatures + const newFeatures = features + // Use feature.id for comparison + const oldFeatureIds = new Set(oldFeatures.map((f) => f.id)) + const newFeatureIds = new Set(newFeatures.map((f) => f.id)) + // features that are present on the map AND in the identify-request result are meant to be toggled + const deselectedFeatures = oldFeatures.filter((f) => newFeatureIds.has(f.id)) + const newlyAddedFeatures = newFeatures.filter((f) => !oldFeatureIds.has(f.id)) + if ( + // Do not add new features if one existing feature was toggled off. Doing so would confuse + // the user, as it would look like the CTRL+Click had no effect (a new feature was added at + // the same spot as the one that was just toggled off) + deselectedFeatures.length > 0 + ) { + // Set features to all existing features minus those that were toggled off + this.setSelectedFeatures( + { + features: oldFeatures.filter( + (f) => !deselectedFeatures.includes(f) + ), + paginationSize: featureCount, + }, + dispatcher + ) + } else if (newlyAddedFeatures.length > 0) { + // no feature was "deactivated" we can add the newly selected features + this.setSelectedFeatures( + { + features: newlyAddedFeatures.concat(oldFeatures), + paginationSize: featureCount, + }, + dispatcher + ) + } else { + this.clearAllSelectedFeatures(dispatcher) + } + } + } else { + this.clearAllSelectedFeatures(dispatcher) + } + }, + + /** + * Loads (if possible) more features for the given layer. + * + * Only GeoAdmin layers support this for the time being. For external layer, as we use + * GetFeatureInfo from WMS, there's no such capabilities (we would have to switch to a WFS + * approach to gain access to similar things). + * + * @param payload + * @param payload.layer + * @param payload.coordinate A point ([x,y]), or a rectangle described by a flat extent + * ([minX, maxX, minY, maxY]). + * @param dispatcher + */ + loadMoreFeaturesForLayer( + payload: { layer: GeoAdminLayer; coordinate: SingleCoordinate | FlatExtent }, + dispatcher: ActionDispatcher + ) { + const { layer, coordinate } = payload + if (!layer) { + return + } + const featuresAlreadyLoaded = this.selectedFeaturesByLayerId.find( + (featureForLayer) => featureForLayer.layerId === layer.id + ) + if (featuresAlreadyLoaded && featuresAlreadyLoaded.featureCountForMoreData > 0) { + const i18nStore = useI18nStore() + const positionStore = usePositionStore() + const uiStore = useUIStore() + + identifyOnGeomAdminLayer({ + layer, + coordinate, + resolution: positionStore.resolution, + mapExtent: flattenExtent(positionStore.extent), + screenWidth: uiStore.width, + screenHeight: uiStore.height, + lang: i18nStore.lang, + projection: positionStore.projection, + offset: featuresAlreadyLoaded.features.length, + featureCount: featuresAlreadyLoaded.featureCountForMoreData, + }) + .then((moreFeatures) => { + const featuresForLayer = this.selectedFeaturesByLayerId.find( + (featureForLayer) => featureForLayer.layerId === layer.id + ) + if (!featuresForLayer) { + return + } + const canLoadMore = + moreFeatures.length > 0 && + moreFeatures.length % featuresAlreadyLoaded.featureCountForMoreData === + 0 + + featuresForLayer.features.push(...moreFeatures) + featuresForLayer.featureCountForMoreData = canLoadMore + ? featuresAlreadyLoaded.featureCountForMoreData + : 0 + }) + .catch((error) => { + log.error({ + title: 'Feature store / identify', + titleStyle: { + backgroundColor: LogPreDefinedColor.Purple, + }, + messages: ['Error while identifying features', error], + }) + }) + } else { + log.error( + 'No more features can be loaded for layer', + layer, + 'at coordinate', + coordinate + ) + } + }, + + /** Removes all selected features from the map */ + clearAllSelectedFeatures(dispatcher: ActionDispatcher) { + this.selectedFeaturesByLayerId = [] + this.selectedEditableFeatures = [] + if (this.highlightedFeatureId) { + this.highlightedFeatureId = undefined + } + const profileStore = useProfileStore() + if (profileStore.feature) { + profileStore.setProfileFeature({ feature: undefined }, dispatcher) + } + }, + + setHighlightedFeatureId( + highlightedFeatureId: string | undefined, + dispatcher: ActionDispatcher + ) { + this.highlightedFeatureId = highlightedFeatureId + }, + + /** + * In drawing mode, informs the store about the new coordinates of the feature. (It does not + * move the feature.) Only change the coordinates if the feature is editable and part of the + * currently selected features. + * + * Coordinates is an array of coordinate. Marker and text feature have only one entry in + * this array while line and measure store each points describing them in this coordinates + * array + * + * @param payload + * @param payload.feature + * @param payload.coordinates + * @param dispatcher + */ + changeFeatureCoordinates( + payload: { feature: EditableFeature; coordinates: SingleCoordinate[] }, + dispatcher: ActionDispatcher + ) { + const { feature, coordinates } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable && Array.isArray(coordinates)) { + selectedFeature.coordinates = coordinates + } + }, + + changeFeatureGeometry( + payload: { feature: EditableFeature; geometry: Geometry }, + dispatcher: ActionDispatcher + ) { + const { feature, geometry } = payload + + const selectedFeature: EditableFeature | undefined = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable && geometry) { + selectedFeature.geometry = geometry + const profileStore = useProfileStore() + profileStore.setProfileFeature({ feature: selectedFeature }, dispatcher) + } + }, + + /** + * Changes the title of the feature. Only change the title if the feature is editable and + * part of the currently selected features + */ + changeFeatureTitle( + payload: { feature: EditableFeature; title: string }, + dispatcher: ActionDispatcher + ) { + const { feature, title } = payload + const selectedFeature: EditableFeature | undefined = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable) { + selectedFeature.title = title + } + }, + + /** + * Changes the description of the feature. Only change the description if the feature is + * editable and part of the currently selected features + */ + changeFeatureDescription( + payload: { + feature: EditableFeature + description: string + }, + dispatcher: ActionDispatcher + ) { + const { feature, description } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable) { + selectedFeature.description = description + } + }, + + changeFeatureShownDescriptionOnMap( + payload: { feature: EditableFeature; showDescriptionOnMap: boolean }, + dispatcher: ActionDispatcher + ) { + const { feature, showDescriptionOnMap } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable) { + selectedFeature.showDescriptionOnMap = showDescriptionOnMap + } + }, + + /** + * Changes the color used to fill the feature. Only change the color if the feature is + * editable, part of the currently selected features and that the given color is a valid + * color from {@link FeatureStyleColor} + */ + changeFeatureColor( + payload: { feature: EditableFeature; color: FeatureStyleColor }, + dispatcher: ActionDispatcher + ) { + const { feature, color } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + const wantedColor = allStylingColors.find( + (styleColor) => styleColor.name === color.name + ) + if (wantedColor && selectedFeature && selectedFeature.isEditable) { + selectedFeature.fillColor = color + } + }, + + /** + * Changes the text size of the feature. Only change the text size if the feature is + * editable, part of the currently selected features and that the given size is a valid size + * from {@link FeatureStyleSize} + */ + changeFeatureTextSize( + payload: { feature: EditableFeature; textSize: FeatureStyleSize }, + dispatcher: ActionDispatcher + ) { + const { feature, textSize } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + const wantedSize = allStylingSizes.find((size) => size.textScale === textSize.textScale) + if (wantedSize && selectedFeature && selectedFeature.isEditable) { + selectedFeature.textSize = textSize + } + }, + + /** + * Changes the text placement of the title of the feature. Only changes the text placement + * if the feature is editable and part of the currently selected features + */ + changeFeatureTextPlacement( + payload: { feature: EditableFeature; textPlacement: EditableFeatureTextPlacement }, + dispatcher: ActionDispatcher + ) { + const { feature, textPlacement } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + const wantedPlacement = allStylingTextPlacementsWithUnknown.find( + (position) => position === textPlacement + ) + if (wantedPlacement && selectedFeature && selectedFeature.isEditable) { + selectedFeature.textPlacement = textPlacement + } + }, + + /** + * Changes the text offset of the feature. Only change the text offset if the feature is + * editable and part of the currently selected features + */ + changeFeatureTextOffset( + payload: { feature: EditableFeature; textOffset: number[] }, + dispatcher: ActionDispatcher + ) { + const { feature, textOffset } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable) { + selectedFeature.textOffset = textOffset + } + }, + + /** + * Changes the text color of the feature. Only change the text color if the feature is + * editable, part of the currently selected features and that the given color is a valid + * color from {@link FeatureStyleColor} + */ + changeFeatureTextColor( + payload: { feature: EditableFeature; textColor: FeatureStyleColor }, + dispatcher: ActionDispatcher + ) { + const { feature, textColor } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + const wantedColor = allStylingColors.find( + (styleColor) => styleColor.name === textColor.name + ) + if (wantedColor && selectedFeature && selectedFeature.isEditable) { + selectedFeature.textColor = textColor + } + }, + + /** + * Changes the icon of the feature. Only change the icon if the feature is editable, part of + * the currently selected feature, is a marker type feature and that the given icon is valid + * (non-null) + */ + changeFeatureIcon( + payload: { feature: EditableFeature; icon: DrawingIcon }, + dispatcher: ActionDispatcher + ) { + const { feature, icon } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if ( + icon && + selectedFeature && + selectedFeature.isEditable && + selectedFeature.featureType === EditableFeatureTypes.MARKER + ) { + selectedFeature.icon = icon + } + }, + + /** + * Changes the icon size of the feature. Only change the icon size if the feature is + * editable, part of the currently selected features, is a marker type feature and that the + * given size is a valid size from {@link FeatureStyleSize} + */ + changeFeatureIconSize( + payload: { feature: EditableFeature; iconSize: FeatureStyleSize }, + dispatcher: ActionDispatcher + ) { + const { feature, iconSize } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + const wantedSize = allStylingSizes.find((size) => size.textScale === iconSize.textScale) + if ( + wantedSize && + selectedFeature && + selectedFeature.isEditable && + selectedFeature.featureType === EditableFeatureTypes.MARKER + ) { + selectedFeature.iconSize = iconSize + } + }, + + /** In drawing mode, tells the state if a given feature is being dragged. */ + changeFeatureIsDragged( + payload: { feature: EditableFeature; isDragged: boolean }, + dispatcher: ActionDispatcher + ) { + const { feature, isDragged } = payload + const selectedFeature = getEditableFeatureWithId( + this.selectedEditableFeatures, + feature.id + ) + if (selectedFeature && selectedFeature.isEditable) { + selectedFeature.isDragged = isDragged + } + }, + + /** + * The goal of this function is to refresh the selected features according to changes that + * happened in the store, but outside feature selection. For example, when we change the + * language, we need to update the selected features otherwise we keep them in the old + * language until new features are selected. + */ + async updateFeatures(dispatcher: ActionDispatcher) { + const featuresPromises: Promise[] = [] + + const i18nStore = useI18nStore() + const layersStore = useLayersStore() + const mapStore = useMapStore() + const positionStore = usePositionStore() + const uiStore = useUIStore() + + this.selectedLayerFeatures.forEach((feature) => { + // we avoid requesting the drawings and external layers, they're not handled here + const currentFeatureLayer = layersStore.config.find( + (layer) => layer.id === feature.layer.id + ) + if (currentFeatureLayer) { + featuresPromises.push( + getFeature(currentFeatureLayer, feature.id, positionStore.projection, { + lang: i18nStore.lang, + screenWidth: uiStore.width, + screenHeight: uiStore.height, + mapExtent: flattenExtent(positionStore.extent), + coordinate: mapStore.clickInfo?.coordinate, + }) + ) + } + }) + if (featuresPromises.length > 0) { + try { + const responses = await Promise.allSettled(featuresPromises) + const features: LayerFeature[] = responses + .filter((response) => response.status === 'fulfilled') + .map((response) => response.value) + if (features.length > 0) { + const update: FeaturesForLayer[] = this.selectedFeaturesByLayerId.map( + (featuresForLayer) => { + const existingFeatures: LayerFeature[] = featuresForLayer.features + // running through the features we received from the backend, and replacing existing feature if a match is found + const updatedFeatures = existingFeatures.map( + features.find( + (feature: LayerFeature) => + feature.id === existingFeatures.id + ) ?? existingFeatures + ) + return featuresForLayer + } + ) + // const updatedFeaturesByLayerId: FeaturesForLayer[] = + // this.selectedFeaturesByLayerId.map((featuresForLayer) => { + // const clone = cloneDeep(featuresForLayer) + // clone.features = clone.features.map((feature) => { + // if (feature.layer.id === featuresForLayer.layerId) { + // features_array.push(feature) + // } + // return features_array + // }) + // if (featuresForLayer.features.length === 0) { + // featuresForLayer.features = features + // } + // updatedLayers.push(featuresForLayer) + // return updatedLayers + // }) + // await commit('setSelectedFeatures', { + // layerFeaturesByLayerId: updatedFeaturesByLayerId, + // drawingFeatures: state.selectedEditableFeatures, + // ...dispatcher, + // }) + } + } catch (error) { + log.error( + `Error while attempting to update already selected features. error is ${error}` + ) + } + } + }, + }, +}) + +export default useFeaturesStore diff --git a/packages/viewer/src/store/modules/geolocation.store.js b/packages/viewer/src/store/modules/geolocation.store.js deleted file mode 100644 index 8dd6dc444e..0000000000 --- a/packages/viewer/src/store/modules/geolocation.store.js +++ /dev/null @@ -1,94 +0,0 @@ -import log from '@swissgeo/log' -import { isNumber } from '@swissgeo/numbers' - -const state = { - /** - * Flag telling if the user has activated the geolocation feature - * - * @type Boolean - */ - active: false, - - /** - * Flag telling if the geolocation usage has been denied by the user in his/her browser settings - * - * @type Boolean - */ - denied: false, - - /** - * Flag telling if the geolocation position should always be at the center of the app - * - * @type {Boolean} - */ - tracking: true, - - /** - * Device position in the current application projection [x, y] - * - * @type {Number[] | null} - */ - position: null, - - /** - * Accuracy of the geolocation position, in meters - * - * @type {Number} - */ - accuracy: 0, -} - -const getters = {} - -const actions = { - setGeolocation: ({ commit }, args) => { - commit('setGeolocationActive', args) - }, - toggleGeolocation: ({ commit, state }, { dispatcher }) => { - commit('setGeolocationActive', { active: !state.active, dispatcher }) - }, - setGeolocationTracking: ({ commit }, args) => commit('setGeolocationTracking', args), - setGeolocationDenied: ({ commit }, { denied, dispatcher }) => { - commit('setGeolocationDenied', { denied, dispatcher }) - if (denied) { - commit('setGeolocationActive', { active: false, dispatcher }) - commit('setGeolocationTracking', { tracking: false, dispatcher }) - } - }, - setGeolocationPosition: ({ commit }, { position, dispatcher }) => { - if (Array.isArray(position) && position.length === 2) { - commit('setGeolocationPosition', { position, dispatcher }) - } else { - log.debug('Invalid geolocation position received, ignoring', position) - } - }, - setGeolocationAccuracy: ({ commit }, { accuracy, dispatcher }) => { - if (isNumber(accuracy)) { - commit('setGeolocationAccuracy', { accuracy: Number(accuracy), dispatcher }) - } else { - log.error(`Invalid geolocation accuracy: ${accuracy}`) - } - }, - setGeolocationData: ({ commit }, { position, accuracy, dispatcher }) => { - commit('setGeolocationData', { position, accuracy, dispatcher }) - }, -} - -const mutations = { - setGeolocationActive: (state, { active }) => (state.active = active), - setGeolocationDenied: (state, { denied }) => (state.denied = denied), - setGeolocationTracking: (state, { tracking }) => (state.tracking = tracking), - setGeolocationAccuracy: (state, { accuracy }) => (state.accuracy = accuracy), - setGeolocationPosition: (state, { position }) => (state.position = position), - setGeolocationData: (state, { position, accuracy }) => { - state.position = position - state.accuracy = accuracy - }, -} - -export default { - state, - getters, - actions, - mutations, -} diff --git a/packages/viewer/src/store/modules/geolocation.store.ts b/packages/viewer/src/store/modules/geolocation.store.ts new file mode 100644 index 0000000000..843a9d86d9 --- /dev/null +++ b/packages/viewer/src/store/modules/geolocation.store.ts @@ -0,0 +1,91 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' + +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { isNumber } from '@geoadmin/numbers' +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +export interface GeolocationState { + /** Flag telling if the user has activated the geolocation feature */ + active: boolean + /** Flag telling if the user has denied the geolocation usage in his/her browser settings */ + denied: boolean + /** Flag telling if the geolocation position should always be at the center of the app */ + tracking: boolean + /** Device position in the current application projection [x, y] */ + position: SingleCoordinate | undefined + /** Accuracy of the geolocation position, in meters */ + accuracy: number +} + +const useGeolocationStore = defineStore('geolocation', { + state: (): GeolocationState => ({ + active: false, + denied: false, + tracking: true, + position: undefined, + accuracy: 0, + }), + getters: {}, + actions: { + setGeolocation(active: boolean, dispatcher: ActionDispatcher) { + this.active = active + }, + + toggleGeolocation(dispatcher: ActionDispatcher) { + this.active = !this.active + }, + + setGeolocationTracking(isTracking: boolean, dispatcher: ActionDispatcher) { + this.tracking = isTracking + }, + + setGeolocationDenied(isDenied: boolean, dispatcher: ActionDispatcher) { + this.denied = isDenied + if (this.denied) { + this.active = false + this.tracking = false + } + }, + + setGeolocationPosition(position: SingleCoordinate, dispatcher: ActionDispatcher) { + if (Array.isArray(position) && position.length === 2) { + this.position = position + } else { + log.debug({ + title: 'Geolocation store', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid geolocation position received', position], + }) + } + }, + + setGeolocationAccuracy(accuracy: number, dispatcher: ActionDispatcher) { + if (isNumber(accuracy)) { + this.accuracy = Number(accuracy) + } else { + log.error({ + title: 'Geolocation store', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid geolocation accuracy received', accuracy], + }) + } + }, + + setGeolocationData( + position: SingleCoordinate, + accuracy: number, + dispatcher: ActionDispatcher + ) { + this.setGeolocationPosition(position, dispatcher) + this.setGeolocationAccuracy(accuracy, dispatcher) + }, + }, +}) + +export default useGeolocationStore diff --git a/packages/viewer/src/store/modules/i18n.store.js b/packages/viewer/src/store/modules/i18n.store.js deleted file mode 100644 index 08e780d4ab..0000000000 --- a/packages/viewer/src/store/modules/i18n.store.js +++ /dev/null @@ -1,40 +0,0 @@ -import i18n, { langToLocal } from '@/modules/i18n' - -/** - * The name of the mutation for lang changes - * - * @type {String} - */ -export const SET_LANG_MUTATION_KEY = 'setLang' - -const state = { - /** - * The current language used by this application, expressed as an country ISO code - * (`en`,`de`,`fr,etc...) - * - * @type String - */ - lang: i18n.global.locale, -} - -const getters = {} - -const actions = { - setLang({ commit }, args) { - commit(SET_LANG_MUTATION_KEY, args) - }, -} - -const mutations = {} - -mutations[SET_LANG_MUTATION_KEY] = function (state, { lang }) { - state.lang = lang.toLowerCase() - i18n.global.locale = langToLocal(lang.toLowerCase()) -} - -export default { - state, - getters, - actions, - mutations, -} diff --git a/packages/viewer/src/store/modules/i18n.store.ts b/packages/viewer/src/store/modules/i18n.store.ts new file mode 100644 index 0000000000..06d94777b2 --- /dev/null +++ b/packages/viewer/src/store/modules/i18n.store.ts @@ -0,0 +1,36 @@ +import { defineStore } from 'pinia' + +import i18n, { + defaultLocal, + isSupportedLang, + langToLocale, + type SupportedLang, +} from '@/modules/i18n' + +export interface I18nState { + /** + * The current language used by this application, expressed as an country ISO code + * (`en`,`de`,`fr,etc...) + */ + lang: SupportedLang +} + +function enforceStartupLangIsSupported(lang: string): SupportedLang { + if (isSupportedLang(lang)) { + return lang + } + return 'en' +} + +export const useI18nStore = defineStore('i18n', { + state: (): I18nState => ({ + lang: enforceStartupLangIsSupported(defaultLocal), + }), + getters: {}, + actions: { + setLang(lang: SupportedLang) { + this.lang = lang + i18n.global.locale.value = langToLocale(lang) + }, + }, +}) diff --git a/packages/viewer/src/store/modules/layers.store.js b/packages/viewer/src/store/modules/layers.store.js deleted file mode 100644 index 409f463075..0000000000 --- a/packages/viewer/src/store/modules/layers.store.js +++ /dev/null @@ -1,995 +0,0 @@ -import { extentUtils, WGS84 } from '@swissgeo/coordinates' -import log from '@swissgeo/log' -import { ErrorMessage } from '@swissgeo/log/Message' - -import AbstractLayer from '@/api/layers/AbstractLayer.class' -import LayerTypes from '@/api/layers/LayerTypes.enum' -import { EXTERNAL_PROVIDER_WHITELISTED_URL_REGEXES } from '@/config/regex.config' -import { DEFAULT_OLDEST_YEAR, DEFAULT_YOUNGEST_YEAR } from '@/config/time.config' -import { getGpxExtent } from '@/utils/gpxUtils' -import { getKmlExtent, parseKmlName } from '@/utils/kmlUtils' - -/** - * Check if a layer match with the layerId, isExternal, and baseUrl - * - * @param {string} layerId ID of the layer to compare - * @param {boolean | null} isExternal If the layer must be external, not, or both (null) - * @param {string | null} baseUrl Base URL of the layer(s) to retrieve. If null, accept all - * @param {AbstractLayer} layerToMatch Layer to compare with - * @returns {boolean} - */ -function matchTwoLayers(layerId, isExternal = null, baseUrl = null, layerToMatch) { - if (layerToMatch === null) { - return false - } - const matchesLayerId = layerToMatch.id === layerId - const matchesIsExternal = isExternal === null || layerToMatch.isExternal === isExternal - const matchesBaseUrl = baseUrl === null || layerToMatch.baseUrl === baseUrl - return matchesLayerId && matchesIsExternal && matchesBaseUrl -} - -function checkLayerUrlWhitelisting(layer_base_url) { - return !!EXTERNAL_PROVIDER_WHITELISTED_URL_REGEXES.find( - (regex) => !!layer_base_url.match(regex) - ) -} - -const getActiveLayersById = (state, layerId, isExternal = null, baseUrl = null) => { - return state.activeLayers.filter((layer) => matchTwoLayers(layerId, isExternal, baseUrl, layer)) -} -const getActiveLayerByIndex = (state, index) => state.activeLayers.at(index) - -const cloneActiveLayerConfig = (getters, layer) => { - const clone = getters.getLayerConfigById(layer.id)?.clone() ?? null - if (clone) { - if (typeof layer.visible === 'boolean') { - clone.visible = layer.visible - } - if (typeof layer.opacity === 'number') { - clone.opacity = layer.opacity - } - if (layer.customAttributes) { - const { year, updateDelay } = layer.customAttributes - if (year && clone.timeConfig) { - clone.timeConfig.updateCurrentTimeEntry(clone.timeConfig.getTimeEntryForYear(year)) - } - if (updateDelay) { - clone.updateDelay = updateDelay - } - } - } - return clone -} - -const state = { - /** - * Current background layer ID - * - * @type string - */ - currentBackgroundLayerId: null, - /** - * Currently active layers (that have been selected by the user from the search bar or the layer - * tree) - * - * Layers are ordered from bottom to top (last layer is shown on top of all the others) - * - * @type AbstractLayer[] - */ - activeLayers: [], - /** - * All layers' config available to this app - * - * @type GeoAdminLayer[] - */ - config: [], - /** - * A layer to show on the map when hovering a layer (catalog and search) but not in the list of - * active layers. - * - * @type AbstractLayer - */ - previewLayer: null, - /** - * Year being picked by the time slider. The format is YYYY (but might evolve in the future in - * the ISO 8601 date format direction, meaning YYYY-MM-DD, hence the String type). - * - * We store it outside the time config of layers so that layers revert back to their specific - * chosen timestamp when the time slider is closed. That also means that the year picked by the - * time slider doesn't end up in the URL params too. - * - * @type Number - */ - previewYear: null, - /** - * System layers. List of system layers that are added on top and cannot be directly controlled - * by the user. - * - * @type AbstractLayer[] - */ - systemLayers: [], -} - -const getters = { - /** - * Return the current background layer from the list of layers via ID - * - * @returns {AbstractLayer} The current background layer - */ - currentBackgroundLayer: (state, getters) => { - return getters.getLayerConfigById(state.currentBackgroundLayerId) - }, - /** - * Filter all the active layers and gives only those who are visible on the map. - * - * This includes system layers and the preview layer. The time enabled layer with invalid year - * are filtered out. - * - * Layers are ordered from bottom to top (last layer is shown on top of all the others) - * - * @returns {AbstractLayer[]} All layers that are currently visible on the map - */ - visibleLayers: (state) => { - const visibleLayers = state.activeLayers.filter((layer) => { - // timeslider is not used on layer having only one timestamp as it doesn't make sense - // there. - if ( - layer.timeConfig && - layer.hasMultipleTimestamps && - layer.timeConfig.currentTimeEntry === null - ) { - return false - } - return layer.visible - }) - if (state.previewLayer !== null) { - visibleLayers.push(state.previewLayer) - } - return visibleLayers.concat(state.systemLayers.filter((layer) => layer.visible)) - }, - - /** - * Return the visible layer on top (layer with visible flag to true) - * - * @returns {AbstractLayer | null} The visible layer or null if no layer are visible - */ - visibleLayerOnTop: (state, getters) => { - if (getters.visibleLayers.length > 0) { - return getters.visibleLayers.slice(-1)[0] - } - return null - }, - - /** - * Get current KML layer selected for drawing. - * - * That is the KML layer that will be used when the drawing mode is opened. - * - * When no KML layer is in active layers then null is returned. - * - * @returns {KMLLayer | null} - */ - activeKmlLayer: (state) => - state.activeLayers.findLast( - (layer) => layer.visible && layer.type === LayerTypes.KML && !layer.isExternal - ) ?? undefined, - - /** - * Get index of active layer by ID. - * - * When there exists no layer with this ID then -1 is returned. - * - * @returns {number} - */ - getIndexOfActiveLayerById: (state) => (layerId) => { - return state.activeLayers.findIndex((layer) => layer.id === layerId) - }, - - /** - * All layers in the config that have the flag `background` to `true` (that can be shown as a - * background layer). - * - * @returns {[AbstractLayer]} List of background layers. - */ - backgroundLayers: (state, _) => - state.config.filter((layer) => layer.isBackground && layer.idIn3d), - - /** - * Retrieves a layer config metadata defined by its unique ID - * - * @returns {AbstractLayer | null} - */ - getLayerConfigById: (state) => (geoAdminLayerId) => - state.config.find((layer) => layer.id === geoAdminLayerId) ?? null, - - /** - * Retrieves active layer(s) by layerID, isExternal, and baseUrl. - * - * @param {string} layerId ID of the layer(s) to retrieve - * @param {boolean | null} isExternal If the layer must be external, not, or both (null) - * @param {string | null} baseUrl Base URL of the layer(s) to retrieve. If null, accept all - * baseUrl - * @returns {[AbstractLayer]} All active layers matching the ID, isExternal, and baseUrl - */ - getActiveLayersById: - (state) => - (layerId, isExternal = null, baseUrl = null) => { - return state.activeLayers.filter((layer) => - matchTwoLayers(layerId, isExternal, baseUrl, layer) - ) - }, - - /** - * Retrieves layer(s) by ID, isExternal, and baseUrl properties. - * - * Search in active layer and in preview layer - * - * @param {string} layerId ID of the layer(s) to retrieve - * @param {boolean | null} isExternal If the layer must be external, not, or both (null) - * @param {string | null} baseUrl Base URL of the layer(s) to retrieve. If null, accept all - * baseUrl - * @returns {[AbstractLayer]} All active layers matching the ID - */ - getLayersById: - (state) => - (layerId, isExternal = null, baseUrl = null) => { - const layers = state.activeLayers.filter((layer) => - matchTwoLayers(layerId, isExternal, baseUrl, layer) - ) - if (matchTwoLayers(layerId, isExternal, baseUrl, state.previewLayer)) { - layers.push(state.previewLayer) - } - return layers - }, - - /** - * Retrieves active layer by index - * - * @param {number} index Index of the layer to retrieve - * @returns {AbstractLayer | null} Active layer or null if the index is invalid - */ - getActiveLayerByIndex: (state) => (index) => { - if (index < 0 || index === undefined || index === null) { - throw new Error(`Failed to get ActiveLayer by index: invalid index ${index}`) - } - return state.activeLayers.at(index) ?? null - }, - - /** - * Get visiblelayers with time config. (Preview layers and system layers are filtered) - * - * @returns {GeoAdminLayer[]} List of layers with time config - */ - visibleLayersWithTimeConfig: (state) => - // Here we cannot take the getter visibleLayers as it also contain the preview and system - // layers as well as the layer without valid current timeEntry are filtered out - state.activeLayers.filter((layer) => layer.visible && layer.hasMultipleTimestamps), - - /** - * Returns true if the layer comes from a third party (external layer or KML layer). - * - * KML layer are treated as external when they are generated by another user (no adminId). - * - * @param {string} layerId Layer ID of the layer to check for data disclaimer - * @returns {Boolean} - */ - hasDataDisclaimer: - (state, getters) => - (layerId, isExternal = null, baseUrl = null) => { - const layer = getters.getActiveLayersById(layerId, isExternal, baseUrl)[0] - return ( - (layer?.isExternal && !checkLayerUrlWhitelisting(baseUrl)) || - (layer?.type === LayerTypes.KML && !layer?.adminId) - ) - }, - - /** - * Returns true if the layer comes from a third party (external layer or KML layer) which has - * been imported from a local file. - * - * KML layer are treated as external when they are generated by another user (no adminId). - * - * @param {AbstractLayer | null} layer Layer to check for data disclaimer - * @returns {Boolean} - */ - isLocalFile: () => (layer) => { - if (!layer) return false - const isBaseUrlValidUrl = /^\w+:\/\//.test(layer?.baseUrl) - return ( - !isBaseUrlValidUrl && - (layer?.isExternal || (layer?.type === LayerTypes.KML && !layer?.adminId)) - ) - }, - - /** - * Returns true if any layer comes from a third party (external layer or KML layer) which has - * been imported from a local file. - * - * KML layer are treated as external when they are generated by another user (no adminId). - * - * @returns {Boolean} - */ - hasAnyLocalFile: (state, getters) => () => { - return state.activeLayers.some((layer) => getters.isLocalFile(layer)) - }, - - youngestYear: (state) => - state.config.reduce((youngestYear, layer) => { - if (layer.hasMultipleTimestamps && youngestYear < layer.timeConfig.years[0]) { - return layer.timeConfig.years[0] - } - return youngestYear - }, DEFAULT_YOUNGEST_YEAR), - - oldestYear: (state) => - state.config.reduce((oldestYear, layer) => { - if ( - layer.hasMultipleTimestamps && - oldestYear > layer.timeConfig.years[layer.timeConfig.years.length - 1] - ) { - return layer.timeConfig.years[layer.timeConfig.years.length - 1] - } - return oldestYear - }, DEFAULT_OLDEST_YEAR), -} - -const actions = { - /** - * Will set the background to the given layer (or layer ID), but only if this layer's - * configuration states that this layer can be a background layer (isBackground flag) - * - * @param {String | null} bgLayerId The background layer id object - * @param {string} dispatcher Action dispatcher name - */ - setBackground({ commit, getters }, { bgLayerId }) { - if (bgLayerId === null || bgLayerId === 'void') { - // setting it to no background - commit('setBackground', { bgLayerId: null }) - } - if (getters.getLayerConfigById(bgLayerId)?.isBackground) { - commit('setBackground', { bgLayerId: bgLayerId }) - } - }, - - /** - * Sets the configuration of all available layers for this application - * - * Will add layers back, if some were already added before the config was changed - * - * @param {AbstractLayer[]} config - * @param {string} dispatcher Action dispatcher name - */ - setLayerConfig({ commit, state, getters }, { config, dispatcher }) { - const activeLayerBeforeConfigChange = [...state.activeLayers] - commit('setLayerConfig', { config, dispatcher }) - const layers = activeLayerBeforeConfigChange.map((layer) => { - const layerConfig = getters.getLayerConfigById(layer.id) - if (layerConfig) { - // If we found a layer config we use as it might have changed the i18n translation - const clone = layerConfig.clone() - clone.visible = layer.visible - clone.opacity = layer.opacity - clone.customAttributes = layer.customAttributes - if (layer.timeConfig) { - clone.timeConfig.updateCurrentTimeEntry( - clone.timeConfig.getTimeEntryForYear(layer.timeConfig.currentYear) - ) - } - return clone - } else { - // if no config is found, then it is a layer that is not managed, like for example - // the KML layers, in this case we take the old active configuration as fallback. - return layer.clone() - } - }) - commit('setLayers', { layers: layers, dispatcher }) - }, - - /** - * Add a layer on top of the active layers. - * - * It will do so by cloning the config that is given, or the one that matches the layer ID in - * the layers' config. This is done so that we may add one "layer" multiple time to the active - * layers list (for instance having a time enabled layer added multiple time with a different - * timestamp) - * - * @param {AbstractLayer} layer - * @param {String} layerId - * @param {ActiveLayerConfig} layerConfig - * @param {Boolean} zoomToLayerExtent - * @param {string} dispatcher Action dispatcher name - */ - addLayer( - { commit, dispatch, getters }, - { layer = null, layerId = null, layerConfig = null, zoomToLayerExtent = false, dispatcher } - ) { - // creating a clone of the config, so that we do not modify the initial config of the app - // (it is possible to add one layer many times, so we want to always have the correct - // default values when we add it, not the settings from the layer already added) - let clone = null - if (layer) { - clone = layer.clone() - } else if (layerConfig) { - // Get the AbstractLayer Config object, we need to clone it in order - clone = cloneActiveLayerConfig(getters, layerConfig) - } else if (layerId) { - clone = getters.getLayerConfigById(layerId)?.clone() ?? null - } - if (clone) { - commit('addLayer', { layer: clone, dispatcher }) - if (zoomToLayerExtent && layer.extent) { - dispatch('zoomToExtent', { - extent: layer.extent, - dispatcher, - }) - } - } else { - log.error('no layer found for payload:', layer, layerId, layerConfig, dispatcher) - } - }, - - /** - * Sets the list of active layers. This replace the existing list. - * - * NOTE: the layers array is automatically deep cloned - * - * @param {[AbstractLayer | ActiveLayerConfig | String]} layers List of active layers - * @param {string} dispatcher Action dispatcher name - */ - setLayers({ commit, getters }, { layers, dispatcher }) { - const clones = layers - .map((layer) => { - let clone = null - if (layer instanceof AbstractLayer) { - clone = layer.clone() - } else if (layer instanceof Object) { - clone = cloneActiveLayerConfig(getters, layer) - } else if (layer instanceof String || typeof layer === 'string') { - // should be string - clone = getters.getLayerConfigById(layer)?.clone() ?? null - } - return clone - }) - .filter((layer) => layer !== null) - commit('setLayers', { layers: clones, dispatcher }) - }, - - /** - * Remove a layer by ID or by index. - * - * @param {string} layerId Layer ID to removed. NOTE: this removes all layer with the matching - * ID! - * @param {number} index Index of the layer to remove - * @param {string} dispatcher Action dispatcher name - */ - removeLayer( - { commit }, - { index = null, layerId = null, isExternal = null, baseUrl = null, dispatcher } - ) { - if (layerId) { - commit('removeLayersById', { layerId, isExternal, baseUrl, dispatcher }) - } else if (index !== null) { - commit('removeLayerByIndex', { index, dispatcher }) - } else { - log.error( - `Failed to remove layer: invalid parameter: ${index}, ${layerId}, ${dispatcher}` - ) - } - }, - - /** - * Full or partial update of a layer at index in the active layer list - * - * @param {String} layerId ID of the layer we want to update - * @param {AbstractLayer | { any: any }} values Full layer object (AbstractLayer) to update or - * an object with the properties to update (partial update) - * @param {string} dispatcher Action dispatcher name - */ - updateLayer({ commit }, { layerId, values, dispatcher }) { - commit('updateLayer', { layerId, values, dispatcher }) - }, - - /** - * Full or partial update of layers in the active layer list. The update is done by IDs and - * updates all layer matching the IDs - * - * @param {[AbstractLayer | { id: String; any: any }]} layers List of full layer object - * (AbstractLayer) to update or an object with the layer ID to update and any property to - * update (partial update) - * @param {string} dispatcher Action dispatcher name - */ - updateLayers({ commit, getters }, { layers, dispatcher }) { - const updatedLayers = layers - .map((layer) => { - if (layer instanceof AbstractLayer) { - return layer - } else { - const layers2Update = getters.getActiveLayersById( - layer.id, - layer.isExternal, - layer.baseUrl - ) - if (!layers2Update) { - throw new Error( - `Failed to updateLayers: "${layer.id}" not found in active layers` - ) - } - return layers2Update.map((layer2Update) => { - const updatedLayer = layer2Update.clone() - Object.entries(layer).forEach( - (entry) => (updatedLayer[entry[0]] = entry[1]) - ) - return updatedLayer - }) - } - }) - .flat() - commit('updateLayers', { layers: updatedLayers, dispatcher }) - }, - - /** - * Clear all active layers - * - * @param {string} dispatcher Action dispatcher name - */ - clearLayers({ commit }, args) { - commit('clearLayers', args) - }, - - /** - * Toggle the layer visibility - * - * @param {number} index Index of the layer to toggle - * @param {string} dispatcher Action dispatcher name - */ - toggleLayerVisibility({ commit }, { index, dispatcher }) { - commit('toggleLayerVisibility', { index, dispatcher }) - }, - - /** - * Set layer visibility flag - * - * @param {number} index Index of the layer to set - * @param {Boolean} visible Visible flag value - * @param {string} dispatcher Action dispatcher name - */ - setLayerVisibility({ commit }, payload) { - commit('setLayerVisibility', payload) - }, - - /** - * Set layer opacity - * - * @param {number} index Index of the layer to set - * @param {number} opacity Opacity value to set - * @param {string} dispatcher Action dispatcher name - */ - setLayerOpacity({ commit }, payload) { - commit('setLayerOpacity', payload) - }, - - /** - * Set layer current year - * - * @param {number} index Index of the layer to set - * @param {number | null} year Year to set as current, or null - * @param {string} dispatcher Action dispatcher name - */ - setTimedLayerCurrentYear({ commit, getters }, { index, year, dispatcher }) { - const layer = getters.getActiveLayerByIndex(index) - if (!layer) { - throw new Error(`Failed to setTimedLayerCurrentYear: invalid index ${index}`) - } - // checking that the year exists in this timeConfig - if (!layer.timeConfig) { - throw new Error( - `Failed to setTimedLayerCurrentYear: layer at index ${index} is not a timed layer` - ) - } - commit('setLayerYear', { - layer, - year: year, - dispatcher, - }) - // if this layer has a 3D counterpart, we also update its timestamp (keep it in sync) - if (layer.idIn3d) { - const layerIn3d = getters.getLayerConfigById(layer.idIn3d) - if (layerIn3d?.timeConfig) { - commit('setLayerYear', { layer: layerIn3d, year, dispatcher }) - } - } - }, - - /** - * Move an active layer to the given index - * - * @param {number} index Index of the layer to move front - * @param {number} newIndex Index to move the layer to - * @param {string} dispatcher Action dispatcher name - */ - moveActiveLayerToIndex({ commit, getters }, { index, newIndex, dispatcher }) { - const activeLayer = getters.getActiveLayerByIndex(index) - if (!activeLayer) { - throw new Error(`Failed to moveActiveLayerToIndex: invalid index ${index}`) - } - // checking if the layer can be put one step front - if (newIndex < state.activeLayers.length && newIndex >= 0) { - commit('moveActiveLayerToIndex', { - index, - newIndex, - dispatcher, - }) - } else { - throw new Error(`Failed to moveActiveLayerToIndex: invalid new index ${newIndex}`) - } - }, - - /** - * Set the preview layer - * - * @param {AbstractLayer | String | null} layer Layer to set as preview or layer id to set as - * preview or null to clear the preview layer - * @param {string} dispatcher Action dispatcher name - */ - setPreviewLayer({ commit, getters }, { layer, dispatcher }) { - if (layer === null) { - commit('setPreviewLayer', { layer: null, dispatcher }) - } else { - let clone = null - if (layer instanceof AbstractLayer) { - clone = layer.clone() - } else { - clone = getters.getLayerConfigById(layer)?.clone() - if (!clone) { - throw new Error(`Failed to setPreviewLayer: layer ${layer} not found in config`) - } - } - clone.visible = true - commit('setPreviewLayer', { layer: clone, dispatcher }) - } - }, - - /** - * Clear the preview layer - * - * @param {string} dispatcher Action dispatcher name - */ - clearPreviewLayer({ commit }, { dispatcher }) { - commit('setPreviewLayer', { layer: null, dispatcher }) - }, - - /** - * Set preview year to all visible layers with time config - * - * @param {number} year Year to set - * @param {string} dispatcher Action dispatcher name - */ - setPreviewYear({ commit }, { year, dispatcher }) { - if (isNaN(year)) { - log.error('Invalid year value given in setPreviewYear, ignoring', year) - } else { - commit('setPreviewYear', { year, dispatcher }) - } - }, - - /** - * Clear preview year - * - * @param {string} dispatcher Action dispatcher name - */ - clearPreviewYear({ commit }, { dispatcher }) { - commit('setPreviewYear', { year: null, dispatcher }) - }, - - /** - * Add a layer error translation key. - * - * NOTE: This set the error key to all layers matching the ID, isExternal, and baseUrl - * properties. - * - * @param {string} layerId Layer ID of the layer to set the error - * @param {boolean | null} isExternal If the layer must be external, not, or both (null) - * @param {string | null} baseUrl Base URL of the layer(s). If null, accept all - * @param {ErrorMessage} error Error translation key to add - * @param {string} dispatcher Action dispatcher name - */ - addLayerError({ commit, getters }, { layerId, isExternal, baseUrl, error, dispatcher }) { - const layers = getters.getLayersById(layerId, isExternal, baseUrl) - if (layers.length === 0) { - throw new Error( - `Failed to add layer error key "${layerId}", layer not found in active layers` - ) - } - const updatedLayers = layers.map((layer) => { - const clone = layer.clone() - clone.addErrorMessage(error) - if (clone.isLoading) { - clone.isLoading = false - } - return clone - }) - commit('updateLayers', { layers: updatedLayers, dispatcher }) - }, - - /** - * Remove a layer error translation key. - * - * NOTE: This set the error key to all layers matching the ID, isExternal, and baseUrl - * properties. - * - * @param {string} layerId Layer ID of the layer to set the error - * @param {boolean | null} isExternal If the layer must be external, not, or both (null) - * @param {string | null} baseUrl Base URL of the layer(s). If null, accept all - * @param {ErrorMessage} error Error translation key to remove - * @param {string} dispatcher Action dispatcher name - */ - removeLayerError({ commit, getters }, { layerId, isExternal, baseUrl, error, dispatcher }) { - const layers = getters.getLayersById(layerId, isExternal, baseUrl) - if (layers.length === 0) { - throw new Error( - `Failed to remove layer error key "${layerId}", layer not found in active layers` - ) - } - const updatedLayers = layers.map((layer) => { - const clone = layer.clone() - clone.removeErrorMessage(error) - return clone - }) - commit('updateLayers', { layers: updatedLayers, dispatcher }) - }, - - /** - * Remove all layer error translation keys. - * - * NOTE: This set the error key to all layers matching the ID. - * - * @param {string} layerId Layer ID of the layer to clear the error keys - * @param {string} dispatcher Action dispatcher name - */ - clearLayerErrors({ commit, getters }, { layerId, dispatcher }) { - const layers = getters.getLayersById(layerId) - if (layers.length === 0) { - throw new Error( - `Failed to clear layer error keys "${layerId}", layer not found in active layers` - ) - } - const updatedLayers = layers.map((layer) => { - const clone = layer.clone() - clone.clearErrorMessages() - return clone - }) - commit('updateLayers', { layers: updatedLayers, dispatcher }) - }, - - /** - * Set KML/GPX layer(s) with its data and metadata. - * - * NOTE: all matching layer id will be set. - * - * @param {string} layerId Layer ID of KML to update - * @param {string} [data] Data KML data to set - * @param {object} [metadata] KML metadata to set (only for geoadmin KMLs). Default is `null` - * @param {Map} [linkFiles] Map of KML link files. Those files are usually - * sent with the kml inside a KMZ archive and can be referenced inside the KML (e.g. icon, - * image, ...). - * @param {string} dispatcher Action dispatcher name - */ - setKmlGpxLayerData( - { commit, getters, rootState }, - { layerId, data, metadata, linkFiles, dispatcher } - ) { - const layers = getters.getActiveLayersById(layerId) - if (!layers) { - throw new Error( - `Failed to update GPX/KML layer data/metadata "${layerId}", ` + - `layer not found in active layers` - ) - } - const updatedLayers = layers.map((layer) => { - const clone = layer.clone() - if (data) { - let extent - if (clone.type === LayerTypes.KML) { - clone.name = parseKmlName(data) - if (!clone.name || clone.name === '') { - clone.name = clone.kmlFileUrl - } - clone.kmlData = data - extent = getKmlExtent(data) - } else if (clone.type === LayerTypes.GPX) { - // The name of the GPX is derived from the metadata below - clone.gpxData = data - extent = getGpxExtent(data) - } - clone.isLoading = false - - // Always clean up the error messages before doing the check - const emptyFileErrorMessage = new ErrorMessage('kml_gpx_file_empty') - const outOfBoundsErrorMessage = new ErrorMessage('imported_file_out_of_bounds') - clone.removeErrorMessage(emptyFileErrorMessage) - clone.removeErrorMessage(outOfBoundsErrorMessage) - - if (!extent) { - clone.addErrorMessage(emptyFileErrorMessage) - } else if ( - !extentUtils.getExtentIntersectionWithCurrentProjection( - extent, - WGS84, - rootState.position.projection - ) - ) { - clone.addErrorMessage(outOfBoundsErrorMessage) - } - } - if (metadata) { - if (clone.type === LayerTypes.KML) { - clone.kmlMetadata = metadata - } else if (clone.type === LayerTypes.GPX) { - clone.gpxMetadata = metadata - clone.name = metadata.name ?? 'GPX' - } - } - if (linkFiles && clone.type === LayerTypes.KML) { - clone.linkFiles = linkFiles - } - return clone - }) - commit('updateLayers', { layers: updatedLayers, dispatcher }) - }, - /** - * Add a system layer - * - * NOTE: unlike the activeLayers, systemLayers cannot have duplicate and they are added/remove - * by ID - * - * @param {AbstractLayer} layer - * @param {String} dispatcher - */ - addSystemLayer({ commit }, { layer, dispatcher }) { - commit('addSystemLayer', { layer, dispatcher }) - }, - /** - * Update a system layer - * - * @param {AbstractLayer | Object} layer - * @param {String} dispatcher - */ - updateSystemLayer({ commit }, { layer, dispatcher }) { - commit('updateSystemLayer', { layer, dispatcher }) - }, - /** - * Remove a system layer - * - * NOTE: unlike the activeLayers, systemLayers cannot have duplicate and they are added/remove - * by ID - * - * @param {AbstractLayer} layer - * @param {String} dispatcher - */ - removeSystemLayer({ commit }, { layerId, dispatcher }) { - commit('removeSystemLayer', { layerId, dispatcher }) - }, - /** - * Set all system layers - * - * @param {[AbstractLayer]} layers - * @param {String} dispatcher - */ - setSystemLayers({ commit }, { layers, dispatcher }) { - commit('setSystemLayers', { layers, dispatcher }) - }, -} - -const mutations = { - setBackground(state, { bgLayerId }) { - state.currentBackgroundLayerId = bgLayerId - }, - setLayerConfig(state, { config }) { - state.config = config - }, - addLayer(state, { layer }) { - state.activeLayers.push(layer) - }, - setLayers(state, { layers }) { - state.activeLayers = layers - }, - updateLayer(state, { layerId, values }) { - const layer2Update = state.activeLayers.find((layer) => layer.id === layerId) - if (!(layer2Update instanceof AbstractLayer)) { - throw new Error(`Failed to updateLayer: no layer found with ID ${layerId}`) - } - Object.assign(layer2Update, values) - }, - updateLayers(state, { layers }) { - layers.forEach((layer) => { - getActiveLayersById(state, layer.id, layer.isExternal, layer.baseUrl).forEach( - (layer2Update) => { - log.debug(`update layer`, layer2Update, layer) - Object.assign(layer2Update, layer) - } - ) - }) - }, - removeLayersById(state, { layerId, isExternal = null, baseUrl = null }) { - state.activeLayers = state.activeLayers.filter( - (layer) => !matchTwoLayers(layerId, isExternal, baseUrl, layer) - ) - }, - removeLayerByIndex(state, { index }) { - state.activeLayers.splice(index, 1) - }, - clearLayers(state) { - state.activeLayers = [] - }, - toggleLayerVisibility(state, { index }) { - const layer = getActiveLayerByIndex(state, index) - if (!layer) { - throw new Error(`Failed to toggleLayerVisibility at index ${index}: invalid index`) - } - layer.visible = !layer.visible - }, - setLayerVisibility(state, { index, visible }) { - const layer = getActiveLayerByIndex(state, index) - if (!layer) { - throw new Error(`Failed to setLayerVisibility at index ${index}: invalid index`) - } - if (layer) { - layer.visible = visible - } - }, - setLayerOpacity(state, { index, opacity }) { - const layer = getActiveLayerByIndex(state, index) - if (!layer) { - throw new Error(`Failed to setLayerOpacity at index ${index}: invalid index`) - } - layer.opacity = Number(opacity) - }, - setLayerYear(state, { layer, year }) { - layer.timeConfig.updateCurrentTimeEntry(layer.timeConfig.getTimeEntryForYear(year)) - }, - moveActiveLayerToIndex(state, { index, newIndex }) { - const removed = state.activeLayers.splice(index, 1) - state.activeLayers.splice(newIndex, 0, removed[0]) - }, - setPreviewLayer(state, { layer }) { - state.previewLayer = layer - }, - setPreviewYear(state, { year }) { - state.previewYear = year - }, - addSystemLayer(state, { layer }) { - if (state.systemLayers.find((l) => l.id === layer.id)) { - throw new Error(`Cannot add system layer ${layer.id}: duplicate`) - } - state.systemLayers.push(layer) - }, - updateSystemLayer(state, { layer }) { - const layer2Update = state.systemLayers.find((l) => l.id === layer.id) - if (!layer2Update) { - throw new Error(`Cannot update system layer ${layer.id}: layer not found`) - } - if (layer instanceof AbstractLayer) { - Object.assign(layer2Update, layer) - } else { - Object.entries(layer).forEach((entry) => (layer2Update[entry[0]] = entry[1])) - } - }, - removeSystemLayer(state, { layerId }) { - const index = state.systemLayers.findIndex((l) => l.id === layerId) - if (index < 0) { - log.warn(`Cannot remove layer ${layerId}: layer not found`) - } else { - state.systemLayers.splice(index, 1) - } - }, - setSystemLayers(state, { layers }) { - state.systemLayers = layers - }, -} - -export default { - state, - getters, - actions, - mutations, -} diff --git a/packages/viewer/src/store/modules/layers.store.ts b/packages/viewer/src/store/modules/layers.store.ts new file mode 100644 index 0000000000..bf9608e273 --- /dev/null +++ b/packages/viewer/src/store/modules/layers.store.ts @@ -0,0 +1,1145 @@ +import type { + GeoAdminGeoJSONLayer, + GPXLayer, + KMLLayer, + KMLMetadata, + LayerTimeConfigEntry, +} from '@swissgeo/layers' +import type { Interval } from 'luxon' +import type { GPXMetadata } from 'ol/format/GPX' + +import { WGS84 } from '@swissgeo/coordinates' +import { + addErrorMessageToLayer, + clearErrorMessages, + type GeoAdminLayer, + type Layer, + LayerType, + removeErrorMessageFromLayer, +} from '@swissgeo/layers' +import { layerUtils, timeConfigUtils } from '@swissgeo/layers/utils' +import log, { LogPreDefinedColor } from '@swissgeo/log' +import { ErrorMessage } from '@swissgeo/log/Message' +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +import { DEFAULT_OLDEST_YEAR, DEFAULT_YOUNGEST_YEAR } from '@/config/time.config' +import usePositionStore from '@/store/modules/position.store' +import type { FlatExtent } from '@swissgeo/coordinates' +import { extentUtils } from '@swissgeo/coordinates' +import { getGpxExtent } from '@/utils/gpxUtils' +import { getKmlExtent, parseKmlName } from '@/utils/kmlUtils' + +export interface LayersState { + /** Current background layer ID */ + currentBackgroundLayerId: string | undefined + /** + * Currently active layers (that have been selected by the user from the search bar or the layer + * tree) + * + * Layers are ordered from bottom to top (last layer is shown on top of all the others) + */ + activeLayers: Layer[] + /** All layers' config available to this app */ + config: GeoAdminLayer[] + /** + * A layer to show on the map when hovering a layer (catalog and search) but not in the list of + * active layers. + */ + previewLayer: Layer | undefined + /** + * Interval being picked by the time slider. When set to a valid interval, each active layer + * with multiple time entries will be looked up for matching time entry. Their current time + * entry will be set accordingly (or set to undefined if no matching time entry is found) + */ + previewInterval: Interval | undefined + /** + * System layers. List of system layers that are added on top and cannot be directly controlled + * by the user. + */ + systemLayers: Layer[] +} + +/** + * Check if a layer match with the layerId, isExternal, and baseUrl + * + * @param layerId ID of the layer to compare + * @param isExternal If the layer must be external, not, or both (null) + * @param baseUrl Base URL of the layer(s) to retrieve. If null, accept all + * @param layerToMatch Layer to compare with + */ +function matchTwoLayers( + layerId: string, + isExternal?: boolean, + baseUrl?: string, + layerToMatch?: Layer +): boolean { + if (!layerToMatch) { + return false + } + const matchesLayerId = layerToMatch.id === layerId + const matchesIsExternal = isExternal === undefined || layerToMatch.isExternal === isExternal + const matchesBaseUrl = baseUrl === undefined || layerToMatch.baseUrl === baseUrl + return matchesLayerId && matchesIsExternal && matchesBaseUrl +} + +const cloneActiveLayerConfig = (sourceLayer: Layer, activeLayerConfig: Partial) => { + const clone = layerUtils.cloneLayer(sourceLayer) + if (clone) { + if (typeof activeLayerConfig.isVisible === 'boolean') { + clone.isVisible = activeLayerConfig.isVisible + } + if (typeof activeLayerConfig.opacity === 'number') { + clone.opacity = activeLayerConfig.opacity + } + if (activeLayerConfig.customAttributes) { + const { year, updateDelay } = activeLayerConfig.customAttributes + if (year && clone.timeConfig) { + timeConfigUtils.updateCurrentTimeEntry( + clone.timeConfig, + timeConfigUtils.getTimeEntryForYear( + clone.timeConfig, + typeof year === 'number' ? year : parseInt(year) + ) + ) + } + if (updateDelay && clone.type === LayerType.GEOJSON) { + ;(clone as GeoAdminGeoJSONLayer).updateDelay = updateDelay + } + } + } + return clone +} + +const useLayersStore = defineStore('layers', { + state: (): LayersState => ({ + currentBackgroundLayerId: undefined, + activeLayers: [], + config: [], + previewLayer: undefined, + previewInterval: undefined, + systemLayers: [], + }), + getters: { + getActiveLayersById(): ( + layerId: string, + isExternal?: boolean, + baseUrl?: string + ) => Layer[] { + /** + * @param layerId ID of the layer(s) to retrieve + * @param isExternal If the layer must be external, not, or both (not set = undefined) + * @param baseUrl Base URL of the layer(s) to retrieve. If undefined, accept all baseUrl + */ + return (layerId: string, isExternal?: boolean, baseUrl?: string): Layer[] => { + return this.activeLayers.filter((layer) => + matchTwoLayers(layerId, isExternal, baseUrl, layer) + ) + } + }, + + getActiveLayerByIndex() { + return (index: number): Layer | undefined => this.activeLayers.at(index) + }, + + /** + * Return the current background layer from the list of layers via ID + * + * @returns The current background layer + */ + currentBackgroundLayer(): Layer | undefined { + if (!this.currentBackgroundLayerId) { + return + } + return this.getLayerConfigById(this.currentBackgroundLayerId) + }, + + /** + * Filter all the active layers and gives only those who are visible on the map. + * + * This includes system layers and the preview layer. The time enabled layer with invalid + * year are filtered out. + * + * Layers are ordered from bottom to top (last layer is shown on top of all the others) + * + * @returns All layers that are currently visible on the map + */ + visibleLayers(): Layer[] { + const visibleLayers = this.activeLayers.filter((layer) => { + // If the currently selected time entry is null (aka, the time selected has no data), + // it is like the layer is not visible (even though the checkbox is still active) + if ( + layer.timeConfig && + timeConfigUtils.hasMultipleTimestamps(layer) && + layer.timeConfig.currentTimeEntry === null + ) { + return false + } + return layer.isVisible + }) + if (this.previewLayer) { + visibleLayers.push(this.previewLayer) + } + if (this.systemLayers.length > 0) { + visibleLayers.push(...this.systemLayers.filter((layer) => layer.isVisible)) + } + return visibleLayers + }, + + /** + * Return the visible layer on top + * + * @returns The top visible layer or undefined if no layers are visible + */ + visibleLayerOnTop(): Layer | undefined { + if (this.visibleLayers.length > 0) { + return this.visibleLayers.slice(-1)[0] + } + return undefined + }, + + /** + * Get the current KML layer selected for drawing. + * + * That is the KML layer that will be used when the drawing mode is opened. + * + * When no KML layer is in active layers, undefined is returned. + */ + activeKmlLayer(): KMLLayer | undefined { + const kmlLayer = this.activeLayers.findLast( + (layer) => layer.isVisible && layer.type === LayerType.KML && !layer.isExternal + ) + if (kmlLayer) { + return kmlLayer as KMLLayer + } + return undefined + }, + + /** + * Get index of the active layer by ID. + * + * When there exists no layer with this ID then -1 is returned. + */ + getIndexOfActiveLayerById() { + return (layerId: string): number => + this.activeLayers.findIndex((layer) => layer.id === layerId) + }, + + /** + * All layers in the config that have the flag `background` to `true` (that can be shown as + * a background layer). + * + * @returns List of background layers. + */ + backgroundLayers(): GeoAdminLayer[] { + return this.config.filter((layer: GeoAdminLayer) => layer.isBackground && layer.idIn3d) + }, + + /** Retrieves a layer config metadata defined by its unique ID */ + getLayerConfigById() { + return (geoAdminLayerId: string): GeoAdminLayer | undefined => + this.config.find((layer) => layer.id === geoAdminLayerId) + }, + + /** + * Retrieves layer(s) by ID, isExternal, and baseUrl properties. + * + * Search in active layer and in preview layer + * + * @returns All active layers matching the ID + */ + getLayersById() { + /** + * @param layerId ID of the layer(s) to retrieve + * @param isExternal If the layer must be external, not, or both (not set = undefined) + * @param baseUrl Base URL of the layer(s) to retrieve. If undefined, accept all baseUrl + */ + return (layerId: string, isExternal?: boolean, baseUrl?: string): Layer[] => { + const layers = this.activeLayers.filter((layer) => + matchTwoLayers(layerId, isExternal, baseUrl, layer) + ) + if ( + this.previewLayer !== undefined && + matchTwoLayers(layerId, isExternal, baseUrl, this.previewLayer) + ) { + layers.push(this.previewLayer) + } + return layers + } + }, + + /** + * Get visibleLayers with time config. (Preview layers and system layers are filtered) + * + * @returns List of layers with time config + */ + visibleLayersWithTimeConfig(): Layer[] { + // Here we cannot take the getter visibleLayers as it also contains the preview and system + // layers as well as the layer without valid current timeEntry are filtered out + return this.activeLayers.filter( + (layer) => layer.isVisible && timeConfigUtils.hasMultipleTimestamps(layer) + ) + }, + + /** + * Returns true if the layer comes from a third party (external layer or KML layer). + * + * KML layer are treated as external when they are generated by another user (no adminId). + */ + hasDataDisclaimer() { + /** + * @param layerId ID of the layer(s) to retrieve + * @param isExternal If the layer must be external, not, or both (not set = undefined) + * @param baseUrl Base URL of the layer(s) to retrieve. If undefined, accept all baseUrl + */ + return (layerId: string, isExternal?: boolean, baseUrl?: string): boolean => { + return this.getActiveLayersById(layerId, isExternal, baseUrl).some( + (layer: Layer) => + layer?.isExternal || (layer?.type === LayerType.KML && !layer?.adminId) + ) + } + }, + + /** + * Returns true if the layer comes from a third party (external layer or KML layer) which + * has been imported from a local file. + * + * KML layer are treated as external when they are generated by another user (no adminId). + */ + isLocalFile() { + return (layer?: Layer): boolean => { + if (!layer) { + return false + } + const isBaseUrlValidUrl = /^\w+:\/\//.test(layer?.baseUrl) + return ( + !isBaseUrlValidUrl && + (layer?.isExternal || (layer?.type === LayerType.KML && !layer?.adminId)) + ) + } + }, + + /** + * Returns true if any layer comes from a third party (external layer or KML layer) which + * has been imported from a local file. + * + * KML layer are treated as external when they are generated by another user (no adminId). + */ + hasAnyLocalFile(): boolean { + return this.activeLayers.some((layer) => this.isLocalFile(layer)) + }, + + youngestYear(): number { + return this.config.reduce((youngestYear: number, layer: GeoAdminLayer): number => { + if (!layer.timeConfig || !timeConfigUtils.hasMultipleTimestamps(layer)) { + return youngestYear + } + const youngestLayerYear: number | undefined = + timeConfigUtils.getYearFromLayerTimeEntry(layer.timeConfig.timeEntries[0]) + if (youngestLayerYear && youngestYear < youngestLayerYear) { + return youngestLayerYear + } + return youngestYear + }, DEFAULT_YOUNGEST_YEAR) + }, + + oldestYear(): number { + return this.config.reduce((oldestYear, layer) => { + if (!layer.timeConfig || !timeConfigUtils.hasMultipleTimestamps(layer)) { + return oldestYear + } + const oldestLayerYear: number | undefined = + timeConfigUtils.getYearFromLayerTimeEntry( + layer.timeConfig.timeEntries.slice(-1)[0] + ) + if (oldestLayerYear && oldestYear > oldestLayerYear) { + return oldestLayerYear + } + return oldestYear + }, DEFAULT_OLDEST_YEAR) + }, + }, + actions: { + /** + * Will set the background to the given layer (or layer ID), but only if this layer's + * configuration states that this layer can be a background layer (isBackground flag) + * + * @param bgLayerId The background layer id object + * @param dispatcher Action dispatcher name + */ + setBackground(bgLayerId: string | undefined, dispatcher: ActionDispatcher): void { + if (bgLayerId === undefined || bgLayerId === 'void') { + // setting it to no background + this.currentBackgroundLayerId = undefined + } + if (bgLayerId && this.getLayerConfigById(bgLayerId)?.isBackground) { + this.currentBackgroundLayerId = bgLayerId + } else { + log.debug({ + title: 'Layers store / setBackground', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: `Layer ${bgLayerId} is not a background layer, ignoring`, + }) + } + }, + + /** + * Sets the configuration of all available layers for this application + * + * Will add layers back, if some were already added before the config was changed + */ + setLayerConfig(config: GeoAdminLayer[], dispatcher: ActionDispatcher): void { + const activeLayerBeforeConfigChange = [...this.activeLayers] + if (Array.isArray(config)) { + this.config = [...config] + } + this.activeLayers = activeLayerBeforeConfigChange.map((layer) => { + const layerConfig: GeoAdminLayer | undefined = this.getLayerConfigById(layer.id) + if (layerConfig) { + // If we found a layer config we use as it might have changed the i18n translation + const clone = layerUtils.cloneLayer(layerConfig) + clone.isVisible = layer.isVisible + clone.opacity = layer.opacity + clone.customAttributes = layer.customAttributes + if (layer.timeConfig && layer.timeConfig.currentTimeEntry && clone.timeConfig) { + const currentTimeEntry: LayerTimeConfigEntry = + layer.timeConfig.currentTimeEntry + timeConfigUtils.updateCurrentTimeEntry( + clone.timeConfig, + clone.timeConfig.timeEntries.find( + (entry) => entry.timestamp === currentTimeEntry.timestamp + ) + ) + } + return clone + } else { + // if no config is found, then it is a layer that is not managed, like for example + // the KML layers, in this case we take the old active configuration as fallback. + return layerUtils.cloneLayer(layer) + } + }) + }, + + /** + * Add a layer on top of the active layers. + * + * It will do so by cloning the config that is given, or the one that matches the layer ID + * in the layers' config. This is done so that we may add one "layer" multiple time to the + * active layers list (for instance having a time enabled layer added multiple time with a + * different timestamp) + * + * @param payload + * @param dispatcher + */ + addLayer( + payload: { + layer?: Layer + layerId?: string + layerConfig?: Partial + zoomToLayerExtent?: boolean + }, + dispatcher: ActionDispatcher + ) { + const { layer, layerId, layerConfig, zoomToLayerExtent = false } = payload + + let initialLayer: Layer | undefined = layer + if (!initialLayer && layerId) { + initialLayer = this.getLayerConfigById(layerId) + } + if (!initialLayer) { + log.error({ + title: 'Layers store / addLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['no layer found for payload:', layer, layerId], + }) + return + } + + // Creating a clone of the config, so that we do not modify the initial config of the app. + // It is possible to add one layer many times, so we want to always have the correct + // default values when we add it, not the settings from the layer already added. + let clone: Layer | undefined + if (initialLayer) { + clone = layerUtils.cloneLayer(initialLayer) + } else if (layerConfig) { + // Get the Layer Config object, we need to clone it in order + clone = cloneActiveLayerConfig(initialLayer, layerConfig) + } else if (layerId) { + const layerConfig = this.getLayerConfigById(layerId) + if (layerConfig) { + clone = layerUtils.cloneLayer(layerConfig) + } + } + if (clone) { + this.activeLayers.push(clone) + if ( + zoomToLayerExtent && + 'extent' in initialLayer && + Array.isArray(initialLayer.extent) && + initialLayer.extent.length === 4 + ) { + const layerExtent = initialLayer.extent as FlatExtent + usePositionStore().zoomToExtent( + { + extent: layerExtent, + }, + dispatcher + ) + } + } else { + log.error({ + title: 'Layers store / addLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'no layer found for payload:', + layer, + layerId, + layerConfig, + dispatcher, + ], + }) + } + }, + + /** + * Sets the list of active layers. This replaces the existing list. + * + * NOTE: the layer array is automatically deep cloned + */ + setLayers(layers: Layer[] | Partial[] | string[], dispatcher: ActionDispatcher) { + this.activeLayers = layers + .map((layer) => { + let clone: Layer | undefined + if (typeof layer === 'string') { + const matchingLayer = this.getLayerConfigById(layer) + if (matchingLayer) { + clone = layerUtils.cloneLayer(matchingLayer) + } + } else if ('id' in layer && typeof layer.id === 'string') { + const matchingLayer = this.getLayersById(layer.id) + if (matchingLayer.length) { + clone = layerUtils.cloneLayer(matchingLayer[0]) + } + } + return clone + }) + .filter((layer) => !!layer) + }, + + /** + * Remove a layer by ID or by index. + * + * @param payload + * @param payload.layerId Layer ID to remove. NOTE: this removes all layers with the + * matching ID! Use index or UUID if you want to be 100% sure only one layer will be + * removed. + * @param payload.index Index, in the active layers list, of the layer to remove + * @param payload.isExternal If the layer must be external, not, or both (undefined) + * @param payload.baseUrl Base URL of the layer(s) to retrieve. If undefined, accept all + */ + removeLayer( + payload: { index?: number; layerId?: string; isExternal?: boolean; baseUrl?: string }, + dispatcher: ActionDispatcher + ) { + const { index, layerId, isExternal, baseUrl } = payload + if (layerId) { + this.activeLayers = this.activeLayers.filter( + (layer) => !matchTwoLayers(layerId, isExternal, baseUrl, layer) + ) + } else if (index !== undefined) { + this.activeLayers.splice(index, 1) + } else { + log.error({ + title: 'Layers store / removeLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to remove layer: invalid parameter', + index, + layerId, + dispatcher, + ], + }) + } + }, + + /** + * Full or partial update of a layer at index in the active layer list + * + * @param payload + * @param payload.layerId ID of the layer we want to update + * @param payload.values Full layer object (Layer) to update or an object with the + * properties to update (partial update) + * @param dispatcher + */ + updateLayer( + payload: { layerId: string; values: Partial }, + dispatcher: ActionDispatcher + ) { + const { layerId, values } = payload + const layer2Update = this.activeLayers.find((layer) => layer.id === layerId) + if (layer2Update) { + Object.assign(layer2Update, values) + } else { + log.error({ + title: 'Layers store / updateLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to update layer: invalid layerId (no matching layer found)', + layerId, + dispatcher, + ], + }) + } + }, + + /** + * Full or partial update of layers in the active layer list. The update is done by IDs and + * updates all layer matching the IDs + * + * @param layers List of full layer object (Layer) to update or an object with the layer ID + * to update and any property to update (partial update) + * @param dispatcher + */ + updateLayers(layers: Partial[], dispatcher: ActionDispatcher) { + layers + .map((layer) => { + if (typeof layer === 'object' && 'id' in layer && layer.id !== undefined) { + const layers2Update = this.getActiveLayersById( + layer.id, + layer.isExternal, + layer.baseUrl + ) + if (!layers2Update) { + throw new Error( + `Failed to updateLayers: "${layer.id}" not found in active layers` + ) + } + return layers2Update.map((layer2Update) => { + const updatedLayer = layerUtils.cloneLayer(layer2Update) + Object.assign(updatedLayer, layer) + return updatedLayer + }) + } else { + log.error({ + title: 'Layers store / updateLayers', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to updateLayers: insufficient data to update layer (missing id, or wrong type of layer received)', + layer, + dispatcher, + ], + }) + } + }) + .flat() + .filter((layer) => layer !== undefined) + .forEach((layer) => { + this.getActiveLayersById(layer.id, layer.isExternal, layer.baseUrl).forEach( + (layer2Update) => { + Object.assign(layer2Update, layer) + } + ) + }) + }, + + /** Clear all active layers */ + clearLayers(dispatcher: ActionDispatcher) { + this.activeLayers = [] + }, + + /** + * Toggle the layer visibility of the layer corresponding to this index, in the active layer + * list + */ + toggleLayerVisibility(index: number, dispatcher: ActionDispatcher) { + const layer = this.getActiveLayerByIndex(index) + if (layer) { + layer.isVisible = !layer.isVisible + } else { + log.error({ + title: 'Layers store / toggleLayerVisibility', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Failed to toggleLayerVisibility: invalid index', index, dispatcher], + }) + } + }, + + /** Set a layer's visibility flag */ + setLayerVisibility(index: number, isVisible: boolean, dispatcher: ActionDispatcher) { + const layer = this.getActiveLayerByIndex(index) + if (layer) { + layer.isVisible = isVisible + } else { + log.error({ + title: 'Layers store / setLayerVisibility', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Failed to setLayerVisibility: invalid index', index, dispatcher], + }) + } + }, + + /** Set a layer's opacity */ + setLayerOpacity(index: number, opacity: number, dispatcher: ActionDispatcher) { + const layer = this.getActiveLayerByIndex(index) + if (layer) { + layer.opacity = Number(opacity) + } else { + log.error({ + title: 'Layers store / setLayerOpacity', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Failed to setLayerOpacity: invalid index', index, dispatcher], + }) + } + }, + + /** Set layer current year */ + setTimedLayerCurrentTimeEntry( + index: number, + timeEntry: LayerTimeConfigEntry | undefined, + dispatcher: ActionDispatcher + ) { + const layer = this.getActiveLayerByIndex(index) + if (layer && layer.timeConfig) { + timeConfigUtils.updateCurrentTimeEntry(layer.timeConfig, timeEntry) + // if this layer has a 3D counterpart, we also update its time entry (keep it in sync) + if ('idIn3d' in layer && layer.idIn3d !== undefined) { + const geoadminLayer = layer as GeoAdminLayer + const layerIn3d = this.getLayerConfigById(geoadminLayer.idIn3d as string) + if (layerIn3d?.timeConfig) { + timeConfigUtils.updateCurrentTimeEntry(layerIn3d.timeConfig, timeEntry) + } + } + } else { + log.error({ + title: 'Layers store / setTimedLayerCurrentTimeEntry', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to setTimedLayerCurrentTimeEntry: invalid index or layer (not time-enabled)', + index, + layer, + dispatcher, + ], + }) + } + }, + + /** Move an active layer to the given index */ + moveActiveLayerToIndex(index: number, newIndex: number, dispatcher: ActionDispatcher) { + if (newIndex >= this.activeLayers.length || newIndex < 0) { + log.error({ + title: 'Layers store / moveActiveLayerToIndex', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to moveActiveLayerToIndex: invalid new index', + newIndex, + index, + dispatcher, + ], + }) + return + } + const activeLayer = this.getActiveLayerByIndex(index) + if (!activeLayer) { + log.error({ + title: 'Layers store / moveActiveLayerToIndex', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to moveActiveLayerToIndex: invalid index, no layer found', + index, + ], + }) + return + } + const removed = this.activeLayers.splice(index, 1) + this.activeLayers.splice(newIndex, 0, removed[0]) + }, + + /** Set the preview layer */ + setPreviewLayer(layer: Layer | string, dispatcher: ActionDispatcher) { + let clone + if (typeof layer === 'object') { + // got the layer, thus we copy it directly + clone = layerUtils.cloneLayer(layer) + } else { + // got an ID, look for the layer + const matchingLayer = this.getLayerConfigById(layer) + if (matchingLayer) { + clone = layerUtils.cloneLayer(matchingLayer) + } + } + if (!clone) { + log.error({ + title: 'Layers store / setPreviewLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to setPreviewLayer: invalid layer identifier or layer object', + layer, + dispatcher, + ], + }) + return + } + clone.isVisible = true + this.previewLayer = clone + }, + + /** Clear the preview layer */ + clearPreviewLayer(dispatcher: ActionDispatcher) { + this.previewLayer = undefined + }, + + /** + * Will take the first time entry that matches the interval for each layer (if multiple + * entries are possible), or set the current time entry to undefined if no matching time + * entry is found in the layer. + */ + setPreviewInterval(interval: Interval, dispatcher: ActionDispatcher) { + if (!interval.isValid) { + log.error({ + title: 'Layers store / setPreviewInterval', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to setPreviewInterval: invalid interval', + interval, + dispatcher, + ], + }) + return + } + this.previewInterval = interval + this.activeLayers + .filter((layer) => timeConfigUtils.hasMultipleTimestamps(layer)) + .forEach((layer) => { + if (!layer.timeConfig) { + return + } + layer.timeConfig.currentTimeEntry = timeConfigUtils.getTimeEntryForInterval( + layer, + interval + ) + }) + }, + + /** Clear preview year */ + clearPreviewInterval(dispatcher: ActionDispatcher) { + this.previewInterval = undefined + // we leave the active layers as they were and do not revert to any default time entry + // (what was selected through the time slider is permanent) + }, + + /** + * Add a layer error translation key. + * + * NOTE: This set the error key to all layers matching the ID, isExternal, and baseUrl + * properties. + */ + addLayerError( + payload: { + layerId: string + isExternal?: boolean + baseUrl?: string + error: ErrorMessage + }, + dispatcher: ActionDispatcher + ) { + const { layerId, isExternal, baseUrl, error } = payload + const layers: Layer[] = this.getLayersById(layerId, isExternal, baseUrl) + if (layers.length === 0) { + log.error({ + title: 'Layers store / addLayerError', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to add layer error: invalid layerId (no matching layer found)', + layerId, + isExternal, + baseUrl, + ], + }) + return + } + const updatedLayers = layers.map((layer) => { + const clone = layerUtils.cloneLayer(layer) + addErrorMessageToLayer(clone, error) + if (clone.isLoading) { + clone.isLoading = false + } + return clone + }) + this.updateLayers(updatedLayers, dispatcher) + }, + + /** + * Remove a layer error translation key. + * + * NOTE: This set the error key to all layers matching the ID, isExternal, and baseUrl + * properties. + */ + removeLayerError( + payload: { + layerId: string + isExternal?: boolean + baseUrl?: string + error: ErrorMessage + }, + dispatcher: ActionDispatcher + ) { + const { layerId, isExternal, baseUrl, error } = payload + const layers = this.getLayersById(layerId, isExternal, baseUrl) + if (layers.length === 0) { + log.error({ + title: 'Layers store / removeLayerError', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to remove layer error: invalid layerId (no matching layer found)', + layerId, + isExternal, + baseUrl, + ], + }) + return + } + const updatedLayers = layers.map((layer) => { + const clone = layerUtils.cloneLayer(layer) + removeErrorMessageFromLayer(clone, error) + return clone + }) + this.updateLayers(updatedLayers, dispatcher) + }, + + /** + * Remove all layer error translation keys. + * + * NOTE: This set the error key to all layers matching the ID. + */ + clearLayerErrors(layerId: string, dispatcher: ActionDispatcher) { + const layers = this.getLayersById(layerId) + if (layers.length === 0) { + log.error({ + title: 'Layers store / clearLayerErrors', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to clear layer errors: invalid layerId (no matching layer found)', + layerId, + ], + }) + return + } + const updatedLayers = layers.map((layer) => { + const clone = layerUtils.cloneLayer(layer) + clearErrorMessages(clone) + return clone + }) + this.updateLayers(updatedLayers, dispatcher) + }, + + /** + * Set KML/GPX layer(s) with its data and metadata. + * + * NOTE: all matching layer id will be set. + * + * @param payload + * @param payload.layerId Layer ID of KML to update + * @param payload.data Data KML data to set + * @param payload.metadata KML metadata to set (only for geoadmin KMLs). + * @param payload.linkFiles Map of KML link files. Those files are usually sent with the kml + * inside a KMZ archive and can be referenced inside the KML (e.g. icon, image, ...). + * @param dispatcher + */ + setKmlGpxLayerData( + payload: { + layerId: string + data?: string + metadata?: KMLMetadata | GPXMetadata + kmlInternalFiles?: Map + }, + dispatcher: ActionDispatcher + ) { + const { layerId, data, metadata, kmlInternalFiles } = payload + const layers = this.getActiveLayersById(layerId) + if ( + !layers || + layers.some((layer) => [LayerType.KML, LayerType.GPX].includes(layer.type)) + ) { + log.error({ + title: 'Layers store / setKmlGpxLayerData', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to setKmlGpxLayerData: invalid layerId (no matching layer found)', + layerId, + ], + }) + return + } + const updatedLayers = layers.map((layer) => { + const clone = layerUtils.cloneLayer(layer) as GPXLayer | KMLLayer + if (data) { + let extent: FlatExtent | undefined + if (clone.type === LayerType.KML) { + const kmlLayer = clone as KMLLayer + let kmlName: string | undefined = parseKmlName(data) + if (!kmlName || kmlName === '') { + kmlName = kmlLayer.kmlFileUrl + } + if (kmlName) { + kmlLayer.name = kmlName + } + kmlLayer.kmlData = data + extent = getKmlExtent(data) + } else if (clone.type === LayerType.GPX) { + const gpxLayer = clone as GPXLayer + // The name of the GPX is derived from the metadata below + gpxLayer.gpxData = data + extent = getGpxExtent(data) + } + clone.isLoading = false + + // Always clean up the error messages before doing the check + const emptyFileErrorMessage = new ErrorMessage('kml_gpx_file_empty') + const outOfBoundsErrorMessage = new ErrorMessage('imported_file_out_of_bounds') + removeErrorMessageFromLayer(clone, emptyFileErrorMessage) + removeErrorMessageFromLayer(clone, outOfBoundsErrorMessage) + + if (!extent) { + addErrorMessageToLayer(clone, emptyFileErrorMessage) + } else if ( + !extentUtils.getExtentIntersectionWithCurrentProjection( + extent, + WGS84, + usePositionStore().projection + ) + ) { + addErrorMessageToLayer(clone, outOfBoundsErrorMessage) + } + } + if (metadata) { + if (clone.type === LayerType.KML) { + const kmlLayer = clone as KMLLayer + kmlLayer.kmlMetadata = metadata as KMLMetadata + } else if (clone.type === LayerType.GPX) { + const gpxLayer = clone as GPXLayer + const gpxMetadata = metadata as GPXMetadata + gpxLayer.gpxMetadata = gpxMetadata + gpxLayer.name = gpxMetadata.name ?? 'GPX' + } + } + if (kmlInternalFiles && clone.type === LayerType.KML) { + const kmlLayer = clone as KMLLayer + kmlLayer.internalFiles = kmlInternalFiles + } + return clone + }) + this.updateLayers(updatedLayers, dispatcher) + }, + + /** + * Add a system layer + * + * NOTE: unlike the activeLayers, systemLayers cannot have duplicate and they are + * added/remove by ID + */ + addSystemLayer(layer: Layer, dispatcher: ActionDispatcher) { + if (this.systemLayers.find((systemLayer) => systemLayer.id === layer.id)) { + log.error({ + title: 'Layers store / addSystemLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Failed to add system layer: duplicate layer ID', layer, dispatcher], + }) + } else { + this.systemLayers.push(layer) + } + }, + + /** Update a system layer */ + updateSystemLayer(layer: Partial, dispatcher: ActionDispatcher) { + const layer2Update = this.systemLayers.find( + (systemLayer) => systemLayer.id === layer.id + ) + if (!layer2Update) { + log.error({ + title: 'Layers store / updateSystemLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Failed to update system layer: invalid layerId (no matching layer found)', + layer, + dispatcher, + ], + }) + return + } + Object.assign(layer2Update, layer) + }, + + /** + * Remove a system layer + * + * NOTE: unlike the activeLayers, systemLayers cannot have duplicate and they are + * added/remove by ID + */ + removeSystemLayer(layerId: string, dispatcher: ActionDispatcher) { + const index = this.systemLayers.findIndex((systemLayer) => systemLayer.id === layerId) + if (index === -1) { + log.warn({ + title: 'Layers store / removeSystemLayer', + titleStyle: { + backgroundColor: LogPreDefinedColor.Yellow, + }, + messages: [ + 'Failed to remove system layer: invalid layerId (no matching layer found)', + layerId, + dispatcher, + ], + }) + } else { + this.systemLayers.splice(index, 1) + } + }, + + /** Set all system layers */ + setSystemLayers(layers: Layer[], dispatcher: ActionDispatcher) { + this.systemLayers = [...layers] + }, + }, +}) + +export default useLayersStore diff --git a/packages/viewer/src/store/modules/map.store.js b/packages/viewer/src/store/modules/map.store.js deleted file mode 100644 index 6544872b06..0000000000 --- a/packages/viewer/src/store/modules/map.store.js +++ /dev/null @@ -1,176 +0,0 @@ -/** @enum */ -export const ClickType = { - /* Any action that triggers the context menu, so for example right click with a mouse or - a long click with the finger on a touch device.*/ - CONTEXTMENU: 'CONTEXTMENU', - /* A single click, with the left mouse button or with the finger on a touch device */ - LEFT_SINGLECLICK: 'LEFT_SINGLECLICK', - /* A single click with CTRL button pressed */ - CTRL_LEFT_SINGLECLICK: 'CTRL_LEFT_SINGLECLICK', - /* Drawing a box with ctrl and dragging a left click */ - DRAW_BOX: 'DRAW_BOX', -} - -export class ClickInfo { - /** - * @param {[Number, Number] | [Number, Number, Number, Number]} clickInfo.coordinate Coordinate - * or extent Of the last click expressed in the current mapping projection - * @param {[Number, Number]} [clickInfo.pixelCoordinate=[]] Position of the last click on the - * screen [x, y] in pixels (counted from top left corner). Default is `[]` - * @param {SelectableFeature[]} [clickInfo.features=[]] List of potential features (geoJSON or - * KML) that where under the click. Default is `[]` - * @param {ClickType} [clickInfo.clickType=ClickType.LEFT_SINGLECLICK] Which button of the mouse - * has been used to make this click. Default is `ClickType.LEFT_SINGLECLICK` - */ - constructor(clickInfo) { - const { - coordinate = [], - pixelCoordinate = [], - features = [], - clickType = ClickType.LEFT_SINGLECLICK, - } = clickInfo - this.coordinate = [...coordinate] - this.pixelCoordinate = [...pixelCoordinate] - this.features = [...features] - this.clickType = clickType - } -} - -/** - * Module that describe specific interaction with the map (dragging, clicking) and also serves as a - * way to tell the map where to highlight stuff, or place a pin (in order to keep the rest of the - * app ignorant of the mapping framework) - */ -export default { - state: { - /** - * Information about the last click that has occurred on the map - * - * @type ClickInfo - */ - clickInfo: null, - /** - * Coordinate of the dropped pin on the map. If null, no pin will be shown. - * - * @type Array - */ - pinnedLocation: null, - /** - * Will be used to show the location of search entries when they are hovered. If we use the - * same pinned location as the one above, the pinned location is lost as soon as another one - * is hovered. Meaning that the search bar is still filled with a search query, but no - * pinned location is present anymore. - * - * @type Array - */ - previewedPinnedLocation: null, - /** - * Coordinate of the locationPop on the map. If null, locationPopup will not be shown. - * - * @type Array - */ - locationPopupCoordinates: null, - /** - * Tells if the map is in print mode, meaning it will jump to a higher zoom level early. - * - * @type Boolean - */ - printMode: false, - /** - * Coordinates of the rectangle selection extent, if null no rectangle selection is active. - * - * @type Array - */ - rectangleSelectionExtent: null, - }, - actions: { - /** - * Sets all information about the last click that occurred on the map - * - * @param commit - * @param {ClickInfo} clickInfo - */ - click: ({ commit }, { clickInfo, dispatcher }) => { - commit('setClickInfo', { clickInfo, dispatcher }) - - if (clickInfo.clickType === ClickType.DRAW_BOX) { - // If the click is a box selection, we set the rectangle selection extent to the - // coordinates of the click. - commit('setRectangleSelectionExtent', { extent: clickInfo.coordinate, dispatcher }) - } else if (clickInfo.clickType === ClickType.CTRL_LEFT_SINGLECLICK) { - // If the click is a ctrl left single click, we keep the rectangle selection extent - } else { - // For any other click type, we clear the rectangle selection extent - commit('setRectangleSelectionExtent', { extent: null, dispatcher }) - } - }, - - clearClick: ({ commit }, { dispatcher }) => { - commit('setClickInfo', { clickInfo: null, dispatcher }) - commit('setRectangleSelectionExtent', { extent: null, dispatcher }) - }, - /** - * Sets the dropped pin on the map, if coordinates are null the dropped pin is removed - * - * @param commit - * @param {Number[]} coordinates Dropped pin location expressed in EPSG:3857 - */ - setPinnedLocation: ({ commit }, { coordinates, dispatcher }) => { - if (Array.isArray(coordinates) && coordinates.length === 2) { - commit('setPinnedLocation', { coordinates, dispatcher }) - } else { - commit('setPinnedLocation', { coordinates: null, dispatcher }) - } - }, - /** - * @param commit - * @param {Number[]} coordinates Dropped pin location expressed in EPSG:3857 - */ - setPreviewedPinnedLocation({ commit }, { coordinates, dispatcher }) { - if (Array.isArray(coordinates) && coordinates.length === 2) { - commit('setPreviewedPinnedLocation', { coordinates, dispatcher }) - } else { - commit('setPreviewedPinnedLocation', { coordinates: null, dispatcher }) - } - }, - clearPinnedLocation({ commit }, { dispatcher }) { - commit('setPinnedLocation', { coordinates: null, dispatcher }) - }, - clearLocationPopupCoordinates({ commit }, { dispatcher }) { - commit('setLocationPopupCoordinates', { coordinates: null, dispatcher }) - }, - /** - * Sets the locationPopup on the map, if coordinates are null the locationPopup is removed - * - * @param commit - * @param {Number[]} coordinates Location expressed in EPSG:3857 - */ - setLocationPopupCoordinates: ({ commit }, { coordinates, dispatcher }) => { - if (Array.isArray(coordinates) && coordinates.length === 2) { - commit('setLocationPopupCoordinates', { coordinates, dispatcher }) - } else { - commit('setLocationPopupCoordinates', { coordinates: null, dispatcher }) - } - }, - setPrintMode: ({ commit }, { mode, dispatcher }) => - commit('setPrintMode', { mode: !!mode, dispatcher }), - setRectangleSelectionExtent: ({ commit }, { extent, dispatcher }) => { - if (Array.isArray(extent) && extent.length === 4) { - commit('setRectangleSelectionExtent', { extent, dispatcher }) - } else { - commit('setRectangleSelectionExtent', { extent: null, dispatcher }) - } - }, - }, - mutations: { - setClickInfo: (state, { clickInfo }) => (state.clickInfo = clickInfo), - setPinnedLocation: (state, { coordinates }) => (state.pinnedLocation = coordinates), - setPreviewedPinnedLocation: (state, { coordinates }) => - (state.previewedPinnedLocation = coordinates), - setLocationPopupCoordinates: (state, { coordinates }) => - (state.locationPopupCoordinates = coordinates), - setPrintMode: (state, { mode }) => (state.printMode = mode), - setRectangleSelectionExtent: (state, { extent }) => - (state.rectangleSelectionExtent = extent), - }, -} diff --git a/packages/viewer/src/store/modules/map.store.ts b/packages/viewer/src/store/modules/map.store.ts new file mode 100644 index 0000000000..ef238961e9 --- /dev/null +++ b/packages/viewer/src/store/modules/map.store.ts @@ -0,0 +1,130 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' + +import { defineStore } from 'pinia' + +import type SelectableFeature from '@/api/features/SelectableFeature.class' +import type { ActionDispatcher } from '@/store/store' +import type { FlatExtent } from '@/utils/extentUtils.ts' + +export enum ClickType { + /* Any action that triggers the context menu, so for example right click with a mouse or + a long click with the finger on a touch device.*/ + CONTEXTMENU, + /* A single click, with the left mouse button or with the finger on a touch device */ + LEFT_SINGLECLICK, +} + +export interface ClickInfo { + coordinate: SingleCoordinate + pixelCoordinate?: SingleCoordinate + features?: SelectableFeature[] + clickType?: ClickType +} + +/** + * Module that describes specific interaction with the map (dragging, clicking) and also serves as a + * way to tell the map where to highlight stuff or place a pin (to keep the rest of the app ignorant + * of the mapping framework) + */ +export interface MapState { + /** Information about the last click that has occurred on the map */ + clickInfo: ClickInfo | undefined + /** Coordinate of the dropped pin on the map. If null, no pin will be shown. */ + pinnedLocation: SingleCoordinate | undefined + /** + * Will be used to show the location of search entries when they are hovered. If we use the same + * pinned location as the one above, the pinned location is lost as soon as another one is + * hovered. Meaning that the search bar is still filled with a search query, but no pinned + * location is present anymore. + */ + previewedPinnedLocation: SingleCoordinate | undefined + /** Coordinate of the locationPop on the map. If null, locationPopup will not be shown. */ + locationPopupCoordinates: SingleCoordinate | undefined + /** Tells if the map is in print mode, meaning it will jump to a higher zoom level early. */ + printMode: boolean + rectangleSelectionExtent: FlatExtent | undefined +} + +const useMapStore = defineStore('map', { + state: (): MapState => ({ + clickInfo: undefined, + pinnedLocation: undefined, + previewedPinnedLocation: undefined, + locationPopupCoordinates: undefined, + printMode: false, + rectangleSelectionExtent: undefined, + }), + actions: { + /** Sets all information about the last click that occurred on the map* */ + click(clickInfo: ClickInfo | undefined, dispatcher: ActionDispatcher) { + this.clickInfo = clickInfo + }, + + clearClick(dispatcher: ActionDispatcher) { + this.clickInfo = undefined + }, + + /** Sets the dropped pin on the map. If coordinates are undefined, the dropped pin is removed */ + setPinnedLocation(coordinates: SingleCoordinate | undefined, dispatcher: ActionDispatcher) { + if (Array.isArray(coordinates) && coordinates.length === 2) { + this.pinnedLocation = coordinates + } else { + this.pinnedLocation = undefined + } + }, + + setPreviewedPinnedLocation( + coordinates: SingleCoordinate | undefined, + dispatcher: ActionDispatcher + ) { + if (Array.isArray(coordinates) && coordinates.length === 2) { + this.previewedPinnedLocation = coordinates + } else { + this.previewedPinnedLocation = undefined + } + }, + + clearPinnedLocation(dispatcher: ActionDispatcher) { + this.previewedPinnedLocation = undefined + }, + + clearLocationPopupCoordinates(dispatcher: ActionDispatcher) { + this.locationPopupCoordinates = undefined + }, + + /** + * Sets the locationPopup on the map. Ff coordinates are undefined, the locationPopup is + * removed + */ + setLocationPopupCoordinates( + coordinates: SingleCoordinate | undefined, + dispatcher: ActionDispatcher + ) { + if (Array.isArray(coordinates) && coordinates.length === 2) { + this.locationPopupCoordinates = coordinates + } else { + this.locationPopupCoordinates = undefined + } + }, + + /** + * Sets the map in (or out of) print mode. + * + * Print mode was added for the new headless print service to test out some + * OpenLayers-specific setup when printing. + */ + setPrintMode(isActive: boolean, dispatcher: ActionDispatcher) { + this.printMode = isActive + }, + + setRectangleSelectionExtent(extent: FlatExtent | undefined, dispatcher: ActionDispatcher) { + if (Array.isArray(extent) && extent.length === 4) { + this.rectangleSelectionExtent = extent + } else { + this.rectangleSelectionExtent = undefined + } + }, + }, +}) + +export default useMapStore diff --git a/packages/viewer/src/store/modules/position.store.js b/packages/viewer/src/store/modules/position.store.js deleted file mode 100644 index 2663ea50d4..0000000000 --- a/packages/viewer/src/store/modules/position.store.js +++ /dev/null @@ -1,482 +0,0 @@ -import { - allCoordinateSystems, - CoordinateSystem, - extentUtils, - LV95, - WGS84, -} from '@swissgeo/coordinates' -import log from '@swissgeo/log' -import { wrapDegrees } from '@swissgeo/numbers' -import proj4 from 'proj4' - -import { DEFAULT_PROJECTION } from '@/config/map.config' -import { LV95Format } from '@/utils/coordinates/coordinateFormat' - -/** @enum */ -export const CrossHairs = { - cross: 'cross', - circle: 'circle', - bowl: 'bowl', - point: 'point', - marker: 'marker', -} - -/** - * Normalizes any angle so that -PI < result <= PI - * - * @param {Number} rotation Angle in radians - * @returns Normalized angle in radians in range -PI < result <= PI - */ -export function normalizeAngle(rotation) { - while (rotation > Math.PI) { - rotation -= 2 * Math.PI - } - while (rotation < -Math.PI || Math.abs(rotation + Math.PI) < 1e-9) { - rotation += 2 * Math.PI - } - // Automatically fully northen the map if the user has set it approximately to the north. - if (Math.abs(rotation) < 1e-2) { - rotation = 0 - } - return rotation -} - -/** - * Reprojects an extent to the target projection. - * - * @param {Array} extent - The extent to reproject, array of point - * @param {String} sourceProjection - The source projection's EPSG Code of the extent. - * @param {String} targetProjection - The target projection's EPSG Code to reproject to. - * @returns {Array} - The reprojected extent. - */ -function reprojectExtent(extent, sourceProjection, targetProjection) { - return extent.map((point) => proj4(sourceProjection, targetProjection, point)) -} - -/** - * Structure of the camera position - * - * @typedef CameraPosition - * @property {Number} x X position of the camera in the 3D reference system (metric mercator) - * @property {Number} y Y position of the camera in the 3D reference system (metric mercator) - * @property {Number} z Z altitude of the camera in the 3D reference system (meters) - * @property {Number} heading Degrees of camera rotation on the heading axis ("compass" axis) - * @property {Number} pitch Degrees of camera rotation on the pitch axis ("nose up and down" axis) - * @property {Number} roll Degrees of camera rotation on the roll axis ("barrel roll" axis, like if - * the camera was a plane) - */ - -const state = { - /** - * The display format selected for the mousetracker - * - * @type {String} - */ - displayedFormatId: LV95Format.id, - - /** - * The map zoom level, which define the resolution of the view - * - * @type {Number} - */ - // some unit tests fail because DEFAULT_PROJECTION is somehow not yet defined when they are run - // hence the `?.` operator - zoom: DEFAULT_PROJECTION?.getDefaultZoom(), - - /** - * The map rotation expressed so that -Pi < rotation <= Pi - * - * @type {Number} - */ - rotation: 0, - - /** - * Flag which indicates if openlayers map rotates to align with true / magnetic north (only - * possible if device has orientation capabilities) - * - * @type {Boolean} - */ - autoRotation: false, - - /** - * Flag which indicates if the device has orientation capabilities (e.g. can use map auto - * rotate) - * - * @type {Boolean} - */ - hasOrientation: false, - - /** - * Center of the view expressed with the current projection - * - * @type {Number[]} - */ - // some unit tests fail because DEFAULT_PROJECTION is somehow not yet defined when they are run - // hence the `?.` operator - center: DEFAULT_PROJECTION?.bounds.center, - - /** - * Projection used to express the position (and subsequently used to define how the mapping - * framework will have to work under the hood) - * - * If LV95 is chosen, the map will use custom resolution to fit Swisstopo's Landeskarte specific - * zooms (or scales) so that zoom levels will fit the different maps we have (1:500'000, - * 1:100'000, etc...) - * - * @type {CoordinateSystem} - */ - projection: DEFAULT_PROJECTION, - - /** @type {CrossHairs} */ - crossHair: null, - - /** @type {Number[]} */ - crossHairPosition: null, - - /** - * Position of the view when we are in 3D, always expressed in EPSG:3857 (only projection system - * that works with Cesium) - * - * Will be set to null when the 3D map is not active - * - * @type {CameraPosition | null} - */ - camera: null, -} - -const getters = { - /** - * The center of the map reprojected in EPSG:4326 - * - * @param state - * @returns {Number[]} - */ - centerEpsg4326: (state) => { - const centerEpsg4326Unrounded = proj4(state.projection.epsg, WGS84.epsg, state.center) - return [ - WGS84.roundCoordinateValue(centerEpsg4326Unrounded[0]), - WGS84.roundCoordinateValue(centerEpsg4326Unrounded[1]), - ] - }, - /** - * Resolution of the view expressed in meter per pixel - * - * @type {Number} - */ - resolution: (state) => { - return state.projection.getResolutionForZoomAndCenter(state.zoom, state.center) - }, - - /** - * The extent of the view, expressed with two coordinates numbers (`[ bottomLeft, topRight ]`) - * - * @param state - * @param getters - * @param rootState - * @returns {[[number, number], [number, number]]} - */ - extent: (state, getters, rootState) => { - const halfScreenInMeter = { - width: (rootState.ui.width / 2) * getters.resolution, - height: (rootState.ui.height / 2) * getters.resolution, - } - // calculating extent with resolution - const bottomLeft = [ - state.projection.roundCoordinateValue(state.center[0] - halfScreenInMeter.width), - state.projection.roundCoordinateValue(state.center[1] - halfScreenInMeter.height), - ] - const topRight = [ - state.projection.roundCoordinateValue(state.center[0] + halfScreenInMeter.width), - state.projection.roundCoordinateValue(state.center[1] + halfScreenInMeter.height), - ] - return [bottomLeft, topRight] - }, - /** - * Flag telling if the current extent is contained into the LV95 bounds (meaning only things - * from our LV 95 services are currently in display) - * - * @param state - * @param getters - * @returns {Boolean} - */ - isExtentOnlyWithinLV95Bounds(state, getters) { - let [currentExtentBottomLeft, currentExtentTopRight] = getters.extent - const lv95boundsInCurrentProjection = LV95.getBoundsAs(state.projection) - return ( - lv95boundsInCurrentProjection.isInBounds(currentExtentBottomLeft) && - lv95boundsInCurrentProjection.isInBounds(currentExtentTopRight) - ) - }, -} - -const actions = { - setDisplayedFormatId({ commit }, { displayedFormatId, dispatcher }) { - commit('setDisplayedFormatId', { displayedFormatId, dispatcher }) - }, - /** - * @param commit - * @param state - * @param zoom The new wanted zoom level - * @param source Source of this change, for debug purposes (won't be stored, will be in output - * of the debug console) - */ - setZoom({ commit, state }, { zoom, dispatcher }) { - if (typeof zoom !== 'number' || zoom < 0) { - return - } - commit('setZoom', { zoom: state.projection.roundZoomLevel(zoom), dispatcher }) - }, - setRotation({ commit }, { rotation, dispatcher }) { - if (typeof rotation !== 'number') { - return - } - rotation = normalizeAngle(rotation) - commit('setRotation', { rotation, dispatcher }) - }, - setAutoRotation({ commit }, { autoRotation, dispatcher }) { - commit('setAutoRotation', { autoRotation, dispatcher }) - }, - setHasOrientation({ commit }, { hasOrientation, dispatcher }) { - commit('setHasOrientation', { hasOrientation, dispatcher }) - }, - /** - * @param commit - * @param center The new center, either an array of two numbers, or an object with {x, y} - * properties - * @param dispatcher Source of this change, for debug purposes (won't be stored, will be in - * output of the debug console) - */ - setCenter: ({ commit }, { center, dispatcher }) => { - if ( - !center || - (Array.isArray(center) && center.length !== 2) || - (!Array.isArray(center) && (!center.x || !center.y)) - ) { - log.error('bad center received, ignoring', center, 'dispatcher:', dispatcher) - return - } - if (Array.isArray(center)) { - if (state.projection.epsg !== LV95.epsg || LV95.isInBounds(center[0], center[1])) { - commit('setCenter', { - x: center[0], - y: center[1], - dispatcher, - }) - } else { - log.warn('center received is out of bounds, ignoring') - } - } else { - if (state.projection.epsg !== LV95.epsg || LV95.isInBounds(center.x, center.y)) { - const { x, y } = center - commit('setCenter', { x, y, dispatcher }) - } else { - log.warn('center received is out of bounds, ignoring') - } - } - }, - zoomToExtent: ( - { commit, state, rootState }, - { extent, extentProjection, maxZoom, dispatcher } - ) => { - // If the extentProjection is not defined, we assume the extent is in the current projection - // and we don't need to reproject it. - if (extentProjection?.epsg && extentProjection.epsg !== state.projection.epsg) { - extent = reprojectExtent(extent, extentProjection.epsg, state.projection.epsg) - } - const normalizedExtent = extent ? extentUtils.normalizeExtent(extent) : null - if (normalizedExtent && Array.isArray(normalizedExtent) && normalizedExtent.length === 2) { - // Convert extent points to WGS84 as adding the coordinates in metric gives incorrect results. - const points = [ - proj4(state.projection.epsg, WGS84.epsg, normalizedExtent[0]), - proj4(state.projection.epsg, WGS84.epsg, normalizedExtent[1]), - ] - // Calculate center of extent and convert position back to the wanted projection - // Based on: https://github.com/Turfjs/turf/blob/v6.5.0/packages/turf-center/index.ts - const centerOfExtent = proj4(WGS84.epsg, state.projection.epsg, [ - (points[0][0] + points[1][0]) / 2.0, // minX + maxX / 2 - (points[0][1] + points[1][1]) / 2.0, // minY + maxY / 2 - ]) - - if (centerOfExtent && Array.isArray(centerOfExtent) && centerOfExtent.length === 2) { - commit('setCenter', { - x: centerOfExtent[0], - y: centerOfExtent[1], - dispatcher: `${dispatcher}/zoomToExtent`, - }) - } - const extentSize = { - width: normalizedExtent[1][0] - normalizedExtent[0][0], - height: normalizedExtent[1][1] - normalizedExtent[0][1], - } - let targetResolution - // if the extent's height is greater than width, we base our resolution calculation on that - if (extentSize.height > extentSize.width) { - targetResolution = - extentSize.height / (rootState.ui.height - rootState.ui.headerHeight) - } else { - targetResolution = extentSize.width / rootState.ui.width - } - - const zoomForResolution = state.projection.getZoomForResolutionAndCenter( - targetResolution, - centerOfExtent - ) - // if maxZoom is not set, we set it to the current projection value to - // have a 1:25000 ratio. - const computedMaxZoom = maxZoom ?? state.projection.get1_25000ZoomLevel() - // Zoom levels are fixed value with LV95, the one calculated is the fixed zoom the closest to the floating - // zoom level required to show the full extent on the map (scale to fill). - // So the view will be too zoomed-in to have an overview of the extent. - // We then set the zoom level to the one calculated minus one (expect when the calculated zoom is 0...). - // We also cannot zoom further than the maxZoom specified if it is specified - commit('setZoom', { - zoom: Math.min(Math.max(zoomForResolution - 1, 0), computedMaxZoom), - dispatcher: `${dispatcher}/zoomToExtent`, - }) - } - }, - increaseZoom: ({ dispatch, state }) => - dispatch('setZoom', { - zoom: state.projection.roundZoomLevel(state.zoom, true) + 1, - dispatcher: 'position.store/increaseZoom', - }), - decreaseZoom: ({ dispatch, state }) => - dispatch('setZoom', { - zoom: state.projection.roundZoomLevel(state.zoom, true) - 1, - dispatcher: 'position.store/decreaseZoom', - }), - /** - * @param {CrossHairs | String | null} crossHair - * @param {Number[] | null} crossHairPosition - */ - setCrossHair: ({ commit, state }, { crossHair, crossHairPosition, dispatcher }) => { - if (crossHair === null) { - commit('setCrossHair', { crossHair: null, dispatcher }) - commit('setCrossHairPosition', { crossHairPosition: null, dispatcher }) - } else if (crossHair in CrossHairs) { - commit('setCrossHair', { crossHair: CrossHairs[crossHair], dispatcher }) - - // if a position is defined as param we use it - if (crossHairPosition) { - commit('setCrossHairPosition', { crossHairPosition: crossHairPosition, dispatcher }) - } else { - // if no position was given, we use the current center of the map as crosshair position - commit('setCrossHairPosition', { crossHairPosition: state.center, dispatcher }) - } - } - }, - /** - * @param commit - * @param {CameraPosition} position - * @param source Source of this change, for debug purposes (won't be stored, will be in output - * of the debug console) - */ - setCameraPosition({ commit }, { position, dispatcher }) { - // position can be null (in 2d mode), therefore do not wrap it in this case - const wrappedPosition = position - ? { - x: position.x, - y: position.y, - z: position.z, - // wrapping all angle-based values so that they do not exceed a full-circle value - roll: wrapDegrees(position.roll), - pitch: wrapDegrees(position.pitch), - heading: wrapDegrees(position.heading), - } - : null - commit('setCameraPosition', { position: wrappedPosition, dispatcher }) - }, - setProjection({ commit, state }, { projection, dispatcher }) { - let matchingProjection - if (projection instanceof CoordinateSystem) { - matchingProjection = projection - } else if (typeof projection === 'number' || projection instanceof Number) { - matchingProjection = allCoordinateSystems.find( - (coordinateSystem) => coordinateSystem.epsgNumber === projection - ) - } else if (typeof projection === 'string' || projection instanceof String) { - matchingProjection = allCoordinateSystems.find( - (coordinateSystem) => - coordinateSystem.epsg === projection || - coordinateSystem.epsgNumber === parseInt(projection) - ) - } - if (matchingProjection.epsg === state.projection.epsg) { - log.debug( - 'Attempt at setting the same projection than the one already set in the store, ignoring' - ) - return - } - if (matchingProjection) { - const oldProjection = state.projection - // reprojecting the center of the map - const [x, y] = proj4(oldProjection.epsg, matchingProjection.epsg, state.center) - commit('setCenter', { x, y, dispatcher: 'position.store/setProjection' }) - // adapting the zoom level (if needed) - if (oldProjection.usesMercatorPyramid && !matchingProjection.usesMercatorPyramid) { - commit('setZoom', { - zoom: matchingProjection.transformStandardZoomLevelToCustom(state.zoom), - dispatcher: 'position.store/setProjection', - }) - } else if ( - !oldProjection.usesMercatorPyramid && - matchingProjection.usesMercatorPyramid - ) { - commit('setZoom', { - zoom: oldProjection.transformCustomZoomLevelToStandard(state.zoom), - dispatcher: 'position.store/setProjection', - }) - } - if ( - !oldProjection.usesMercatorPyramid && - !matchingProjection.usesMercatorPyramid && - oldProjection.epsg !== matchingProjection.epsg - ) { - // we have to revert the old projection zoom level to standard, and then transform it to the new projection custom zoom level - commit('setZoom', { - zoom: oldProjection.transformCustomZoomLevelToStandard( - matchingProjection.transformStandardZoomLevelToCustom(state.zoom) - ), - dispatcher: 'position.store/setProjection', - }) - } - - if (state.crossHairPosition) { - commit('setCrossHairPosition', { - crossHairPosition: proj4( - oldProjection.epsg, - matchingProjection.epsg, - state.crossHairPosition - ), - dispatcher: 'position.store/setProjection', - }) - } - - commit('setProjection', { projection: matchingProjection, dispatcher }) - } else { - log.error('Unsupported projection', projection) - } - }, -} - -const mutations = { - setDisplayedFormatId: (state, { displayedFormatId }) => - (state.displayedFormatId = displayedFormatId), - setZoom: (state, { zoom }) => (state.zoom = zoom), - setRotation: (state, { rotation }) => (state.rotation = rotation), - setAutoRotation: (state, { autoRotation }) => (state.autoRotation = autoRotation), - setHasOrientation: (state, { hasOrientation }) => (state.hasOrientation = hasOrientation), - setCenter: (state, { x, y }) => (state.center = [x, y]), - setCrossHair: (state, { crossHair }) => (state.crossHair = crossHair), - setCrossHairPosition: (state, { crossHairPosition }) => - (state.crossHairPosition = crossHairPosition), - setCameraPosition: (state, { position }) => (state.camera = position), - setProjection: (state, { projection }) => (state.projection = projection), -} - -export default { - state, - getters, - actions, - mutations, -} diff --git a/packages/viewer/src/store/modules/position.store.ts b/packages/viewer/src/store/modules/position.store.ts new file mode 100644 index 0000000000..be8ff1edb7 --- /dev/null +++ b/packages/viewer/src/store/modules/position.store.ts @@ -0,0 +1,463 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' +import type { Position } from 'geojson' + +import { + allCoordinateSystems, + CoordinateSystem, + CustomCoordinateSystem, + LV95, + StandardCoordinateSystem, + SwissCoordinateSystem, + WGS84, +} from '@geoadmin/coordinates' +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { isNumber, wrapDegrees } from '@geoadmin/numbers' +import { center, points } from '@turf/turf' +import { defineStore } from 'pinia' +import proj4 from 'proj4' + +import type { ActionDispatcher } from '@/store/store' + +import { DEFAULT_PROJECTION } from '@/config/map.config' +import useUIStore from '@/store/modules/ui.store' +import { CoordinateFormat, LV95Format } from '@/utils/coordinates/coordinateFormat' +import { + type FlatExtent, + type NormalizedExtent, + normalizeExtent, + projExtent, +} from '@/utils/extentUtils' + +/** + * Normalizes any angle so that -PI < result <= PI + * + * @param rotation Angle in radians + * @returns Normalized angle in radians in range -PI < result <= PI + */ +export function normalizeAngle(rotation: number): number { + while (rotation > Math.PI) { + rotation -= 2 * Math.PI + } + while (rotation < -Math.PI || Math.abs(rotation + Math.PI) < 1e-9) { + rotation += 2 * Math.PI + } + // Automatically fully northen the map if the user has set it approximately to the north. + if (Math.abs(rotation) < 1e-2) { + rotation = 0 + } + return rotation +} + +export enum CrossHairs { + cross = 'cross', + circle = 'circle', + bowl = 'bowl', + point = 'point', + marker = 'marker', +} + +export interface CameraPosition { + /** X position of the camera in the 3D reference system (metric mercator) */ + x: number + /** Y position of the camera in the 3D reference system (metric mercator) */ + y: number + /** Z altitude of the camera in the 3D reference system (meters) */ + z: number + /** Degrees of camera rotation on the heading axis ("compass" axis) */ + heading: number + /** Degrees of camera rotation on the pitch axis ("nose up and down" axis) */ + pitch: number + /** + * Degrees of camera rotation on the roll axis ("barrel roll" axis, like if the camera was a + * plane) + */ + roll: number +} + +export interface PositionState { + /** The display format selected for the mouse tracker */ + displayFormat: CoordinateFormat + /** The map zoom level, which define the resolution of the view */ + zoom: number + /** The map rotation expressed so that -Pi < rotation <= Pi */ + rotation: number + /** + * Flag which indicates if openlayers map rotates to align with true / magnetic north (only + * possible if the device has orientation capabilities) + */ + autoRotation: boolean + /** + * Flag which indicates if the device has orientation capabilities (e.g. can use map auto + * rotate) + */ + hasOrientation: boolean + /** Center of the view expressed with the current projection */ + center: SingleCoordinate + /** + * Projection used to express the position (and subsequently used to define how the mapping + * framework will have to work under the hood) + * + * If LV95 is chosen, the map will use custom resolution to fit Swisstopo's Landeskarte specific + * zooms (or scales) so that zoom levels will fit the different maps we have (1:500'000, + * 1:100'000, etc...) + */ + projection: CoordinateSystem + crossHair: CrossHairs | undefined + crossHairPosition: number[] | undefined + /** + * Position of the view when we are in 3D, always expressed in EPSG:3857 (only projection system + * that works with Cesium) + * + * Will be set to null when the 3D map is not active + */ + camera: CameraPosition | undefined +} + +const usePositionStore = defineStore('position', { + state: (): PositionState => ({ + displayFormat: LV95Format, + // some unit tests fail because DEFAULT_PROJECTION is somehow not yet defined when they are run + // hence the `?.` operator + zoom: DEFAULT_PROJECTION?.getDefaultZoom(), + rotation: 0, + autoRotation: false, + hasOrientation: false, + // some unit tests fail because DEFAULT_PROJECTION is somehow not yet defined when they are run + // hence the `?.` operator + center: DEFAULT_PROJECTION?.bounds.center, + projection: DEFAULT_PROJECTION, + crossHair: undefined, + crossHairPosition: undefined, + camera: undefined, + }), + getters: { + /** The center of the map reprojected in EPSG:4326 */ + centerEpsg4326(): SingleCoordinate { + const centerEpsg4326Unrounded = proj4(this.projection.epsg, WGS84.epsg, this.center) + return [ + WGS84.roundCoordinateValue(centerEpsg4326Unrounded[0]), + WGS84.roundCoordinateValue(centerEpsg4326Unrounded[1]), + ] + }, + + /** Resolution of the view expressed in meter per pixel */ + resolution(): number { + return this.projection.getResolutionForZoomAndCenter(this.zoom, this.center) + }, + + /** + * The extent of the view, expressed with two coordinates numbers (`[ bottomLeft, topRight + * ]`) + */ + extent(): NormalizedExtent { + const uiStore = useUIStore() + const halfScreenInMeter = { + width: (uiStore.width / 2) * this.resolution, + height: (uiStore.height / 2) * this.resolution, + } + // calculating extent with resolution + const bottomLeft: SingleCoordinate = [ + this.projection.roundCoordinateValue(this.center[0] - halfScreenInMeter.width), + this.projection.roundCoordinateValue(this.center[1] - halfScreenInMeter.height), + ] + const topRight: SingleCoordinate = [ + this.projection.roundCoordinateValue(this.center[0] + halfScreenInMeter.width), + this.projection.roundCoordinateValue(this.center[1] + halfScreenInMeter.height), + ] + return [bottomLeft, topRight] + }, + + /** + * Flag telling if the current extent is contained into the LV95 bounds (meaning only things + * from our LV 95 services are currently in display) + */ + isExtentOnlyWithinLV95Bounds(): boolean { + const [currentExtentBottomLeft, currentExtentTopRight] = this.extent + const lv95boundsInCurrentProjection = LV95.getBoundsAs(this.projection) + return !!( + lv95boundsInCurrentProjection?.isInBounds( + currentExtentBottomLeft[0], + currentExtentBottomLeft[1] + ) && + lv95boundsInCurrentProjection?.isInBounds( + currentExtentTopRight[0], + currentExtentTopRight[1] + ) + ) + }, + }, + actions: { + setDisplayedFormat(displayedFormat: CoordinateFormat, dispatcher: ActionDispatcher) { + this.displayFormat = displayedFormat + }, + + setZoom(zoom: number, dispatcher: ActionDispatcher) { + if (!isNumber(zoom) || zoom < 0) { + log.error({ + title: 'Position store / setZoom', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid zoom level', zoom, dispatcher], + }) + return + } + this.zoom = this.projection.roundZoomLevel(zoom) + }, + + increaseZoom(dispatcher: ActionDispatcher) { + if (this.projection instanceof SwissCoordinateSystem) { + // for Swiss coordinate system, there's an extra param to trigger normalization + // (snapping to the closest rounded value) + this.zoom = this.projection.roundZoomLevel(this.zoom, true) + 1 + } + this.zoom = this.projection.roundZoomLevel(this.zoom) + 1 + }, + + decreaseZoom(dispatcher: ActionDispatcher) { + if (this.projection instanceof SwissCoordinateSystem) { + // for Swiss coordinate system, there's an extra param to trigger normalization + // (snapping to the closest rounded value) + this.zoom = this.projection.roundZoomLevel(this.zoom, true) - 1 + } + this.zoom = this.projection.roundZoomLevel(this.zoom) - 1 + }, + + zoomToExtent( + payload: { + extent: FlatExtent | NormalizedExtent + extentProjection?: CoordinateSystem + maxZoom?: number + }, + dispatcher: ActionDispatcher + ) { + const { extent, extentProjection, maxZoom } = payload + + // Convert extent points to WGS84 as TurfJS needs them in this format + const normalizedWGS84Extent: NormalizedExtent = projExtent( + extentProjection ?? this.projection, + WGS84, + normalizeExtent(extent) + ) + if ( + normalizedWGS84Extent && + Array.isArray(normalizedWGS84Extent) && + normalizedWGS84Extent.length === 2 + ) { + // Calculate the center of the extent and convert it back to the wanted projection + const centerOfExtent: SingleCoordinate = proj4( + WGS84.epsg, + this.projection.epsg, + center( + points([ + normalizedWGS84Extent[0] as Position, + normalizedWGS84Extent[1] as Position, + ]) + ).geometry.coordinates + ) as SingleCoordinate + + if ( + centerOfExtent && + Array.isArray(centerOfExtent) && + centerOfExtent.length === 2 + ) { + this.center = centerOfExtent + } + const extentSize = { + width: normalizedWGS84Extent[1][0] - normalizedWGS84Extent[0][0], + height: normalizedWGS84Extent[1][1] - normalizedWGS84Extent[0][1], + } + + const uiStore = useUIStore() + let targetResolution + // if the extent's height is greater than width, we base our resolution calculation on that + if (extentSize.height > extentSize.width) { + targetResolution = extentSize.height / (uiStore.height - uiStore.headerHeight) + } else { + targetResolution = extentSize.width / uiStore.width + } + + const zoomForResolution = this.projection.getZoomForResolutionAndCenter( + targetResolution, + centerOfExtent + ) + // if maxZoom is not set, we set it to the current projection value to + // have a 1:25000 ratio. + const computedMaxZoom = maxZoom ?? this.projection.get1_25000ZoomLevel() + // Zoom levels are fixed value with LV95, the one calculated is the fixed zoom the closest to the floating + // zoom level required to show the full extent on the map (scale to fill). + // So the view will be too zoomed-in to have an overview of the extent. + // We then set the zoom level to the one calculated minus one (expect when the calculated zoom is 0...). + // We also cannot zoom further than the maxZoom specified if it is specified + this.zoom = Math.min(Math.max(zoomForResolution - 1, 0), computedMaxZoom) + } + }, + + setRotation(rotation: number, dispatcher: ActionDispatcher) { + if (!isNumber(rotation)) { + log.error({ + title: 'Position store / setRotation', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid rotation', rotation, dispatcher], + }) + return + } + this.rotation = normalizeAngle(rotation) + }, + + setAutoRotation(autoRotation: boolean, dispatcher: ActionDispatcher) { + this.autoRotation = autoRotation + }, + + setHasOrientation(hasOrientation: boolean, dispatcher: ActionDispatcher) { + this.hasOrientation = hasOrientation + }, + + setCenter(center: SingleCoordinate, dispatcher: ActionDispatcher) { + if (!center || (Array.isArray(center) && center.length !== 2)) { + log.error({ + title: 'Position store / setCenter', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid center, ignoring', center, dispatcher], + }) + return + } + if (!this.projection.isInBounds(center[0], center[1])) { + this.center = center + } else { + log.warn({ + title: 'Position store / setCenter', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Center received is out of projection bounds, ignoring', + this.projection, + this.center, + dispatcher, + ], + }) + } + }, + + setCrossHair( + payload: { + crossHair?: CrossHairs + crossHairPosition?: SingleCoordinate + }, + dispatcher: ActionDispatcher + ) { + const { crossHair, crossHairPosition } = payload + if (!crossHair) { + this.crossHair = undefined + this.crossHairPosition = undefined + } else if (crossHair in CrossHairs) { + this.crossHair = crossHair + // if a position is defined as param we use it + // if no position was given, we use the current center of the map as crosshair position + this.crossHairPosition = crossHairPosition ?? this.center + } + }, + + setCameraPosition(position: CameraPosition, dispatcher: ActionDispatcher) { + // position can be null (in 2d mode), we do not wrap it in this case + this.camera = position + ? { + x: position.x, + y: position.y, + z: position.z, + // wrapping all angle-based values so that they do not exceed a full-circle value + roll: wrapDegrees(position.roll), + pitch: wrapDegrees(position.pitch), + heading: wrapDegrees(position.heading), + } + : undefined + }, + + setProjection( + projection: CoordinateSystem | number | string, + dispatcher: ActionDispatcher + ) { + let matchingProjection: CoordinateSystem | undefined + if (projection instanceof CoordinateSystem) { + matchingProjection = projection + } else if (typeof projection === 'number' || isNumber(projection)) { + matchingProjection = allCoordinateSystems.find( + (coordinateSystem) => coordinateSystem.epsgNumber === projection + ) + } else { + matchingProjection = allCoordinateSystems.find( + (coordinateSystem) => + coordinateSystem.epsg === projection || + coordinateSystem.epsgNumber === parseInt(projection) + ) + } + if (matchingProjection) { + if (matchingProjection.epsg === this.projection.epsg) { + log.debug({ + title: 'Position store / setProjection', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Projection already set, ignoring', + this.projection, + matchingProjection, + dispatcher, + ], + }) + return + } + const oldProjection: CoordinateSystem = this.projection + // reprojecting the center of the map + this.center = proj4(oldProjection.epsg, matchingProjection.epsg, this.center) + // adapting the zoom level (if needed) + if ( + oldProjection instanceof StandardCoordinateSystem && + matchingProjection instanceof CustomCoordinateSystem + ) { + this.zoom = matchingProjection.transformStandardZoomLevelToCustom(this.zoom) + } else if ( + oldProjection instanceof CustomCoordinateSystem && + matchingProjection instanceof StandardCoordinateSystem + ) { + this.zoom = oldProjection.transformCustomZoomLevelToStandard(this.zoom) + } + if ( + oldProjection instanceof CustomCoordinateSystem && + matchingProjection instanceof CustomCoordinateSystem && + oldProjection.epsg !== matchingProjection.epsg + ) { + // we have to revert the old projection zoom level to standard, and then transform it to the new projection custom zoom level + this.zoom = oldProjection.transformCustomZoomLevelToStandard( + matchingProjection.transformStandardZoomLevelToCustom(this.zoom) + ) + } + + if (this.crossHairPosition) { + this.crossHairPosition = proj4( + oldProjection.epsg, + matchingProjection.epsg, + this.crossHairPosition + ) + } + + this.projection = matchingProjection + } else { + log.error({ + title: 'Position store / setProjection', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Unsupported projection', projection, dispatcher], + }) + } + }, + }, +}) + +export default usePositionStore diff --git a/packages/viewer/src/store/modules/print.store.js b/packages/viewer/src/store/modules/print.store.js deleted file mode 100644 index 5fac5161c2..0000000000 --- a/packages/viewer/src/store/modules/print.store.js +++ /dev/null @@ -1,84 +0,0 @@ -import log from '@swissgeo/log' - -import { readPrintCapabilities } from '@/api/print.api' -import { PRINT_DEFAULT_DPI } from '@/config/print.config' - -export default { - state: { - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - layouts: [], - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - selectedLayout: null, - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - selectedScale: null, - printSectionShown: false, - printExtent: [], - config: { - dpi: PRINT_DEFAULT_DPI, - layout: 'A4_L', - }, - }, - getters: { - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - printLayoutSize(state) { - const mapAttributes = state.selectedLayout?.attributes.find( - (attribute) => attribute.name === 'map' - ) - - return { - width: mapAttributes?.clientParams?.width?.default ?? 0, - height: mapAttributes?.clientParams?.height?.default ?? 0, - } - }, - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - selectedDPI(state) { - const mapAttributes = state.selectedLayout.attributes.find( - (attribute) => attribute.name === 'map' - ) - return mapAttributes?.clientInfo?.maxDPI - }, - printExtent(state) { - return state.printExtent - }, - }, - actions: { - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - async loadPrintLayouts({ commit }, { dispatcher }) { - try { - const layouts = await readPrintCapabilities() - commit('setPrintLayouts', { layouts, dispatcher }) - } catch (error) { - log.error('Error while loading print layouts', error) - } - }, - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setSelectedScale({ commit }, { scale, dispatcher }) { - commit('setSelectedScale', { scale, dispatcher }) - }, - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setSelectedLayout({ commit }, { layout, dispatcher }) { - commit('setSelectedLayout', { layout, dispatcher }) - }, - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setPrintSectionShown({ commit }, { show, dispatcher }) { - commit('setPrintSectionShown', { show, dispatcher }) - }, - setPrintExtent({ commit }, { printExtent, dispatcher }) { - commit('setPrintExtent', { printExtent, dispatcher }) - }, - setPrintConfig({ commit }, { config, dispatcher }) { - commit('setPrintConfig', { config, dispatcher }) - }, - }, - mutations: { - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setPrintLayouts: (state, { layouts }) => (state.layouts = layouts), - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setSelectedLayout: (state, { layout }) => (state.selectedLayout = layout), - /** @deprecated Should be removed as soon as we've switched to the new print backend */ - setSelectedScale: (state, { scale }) => (state.selectedScale = scale), - setPrintSectionShown: (state, { show }) => (state.printSectionShown = show), - setPrintExtent: (state, { printExtent }) => (state.printExtent = printExtent), - setPrintConfig: (state, { config }) => (state.config = config), - }, -} diff --git a/packages/viewer/src/store/modules/print.store.ts b/packages/viewer/src/store/modules/print.store.ts new file mode 100644 index 0000000000..43db1b9668 --- /dev/null +++ b/packages/viewer/src/store/modules/print.store.ts @@ -0,0 +1,97 @@ +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' +import type { FlatExtent } from '@/utils/extentUtils' + +import { PrintLayout, readPrintCapabilities } from '@/api/print.api' +import { PRINT_DEFAULT_DPI } from '@/config/print.config' + +export interface NewPrintServiceConfig { + dpi: number + layout: string +} + +export interface PrintState { + layouts: PrintLayout[] + selectedLayout: PrintLayout | undefined + selectedScale: number | undefined + printSectionShown: boolean + printExtent: FlatExtent | undefined + config: NewPrintServiceConfig +} + +export interface PrintLayoutSize { + width: number + height: number +} + +const usePrintStore = defineStore('print', { + state: (): PrintState => ({ + layouts: [], + selectedLayout: undefined, + selectedScale: undefined, + printSectionShown: false, + printExtent: undefined, + config: { + dpi: PRINT_DEFAULT_DPI, + layout: 'A4_L', + }, + }), + getters: { + printLayoutSize(): PrintLayoutSize { + const mapAttributes = this.selectedLayout?.attributes.find( + (attribute) => attribute.name === 'map' + ) + + return { + width: mapAttributes?.clientParams?.width?.default ?? 0, + height: mapAttributes?.clientParams?.height?.default ?? 0, + } + }, + + selectedDPI(): number | undefined { + const mapAttributes = this.selectedLayout?.attributes.find( + (attribute) => attribute.name === 'map' + ) + return mapAttributes?.clientInfo?.maxDPI + }, + }, + actions: { + async loadPrintLayouts(dispatcher: ActionDispatcher) { + try { + this.layouts = await readPrintCapabilities() + } catch (error) { + log.error({ + title: 'Print store / loadPrintLayouts', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Error while loading print layouts', error], + }) + } + }, + + setSelectedScale(scale: number | undefined, dispatcher: ActionDispatcher) { + this.selectedScale = scale + }, + + setSelectedLayout(layout: PrintLayout | undefined, dispatcher: ActionDispatcher) { + this.selectedLayout = layout + }, + + setPrintSectionShown(show: boolean, dispatcher: ActionDispatcher) { + this.printSectionShown = show + }, + + setPrintExtent(printExtent: FlatExtent | undefined, dispatcher: ActionDispatcher) { + this.printExtent = printExtent + }, + + setPrintConfig(config: NewPrintServiceConfig, dispatcher: ActionDispatcher) { + this.config = config + }, + }, +}) + +export default usePrintStore diff --git a/packages/viewer/src/store/modules/profile.store.js b/packages/viewer/src/store/modules/profile.store.js deleted file mode 100644 index 2f73c2836e..0000000000 --- a/packages/viewer/src/store/modules/profile.store.js +++ /dev/null @@ -1,215 +0,0 @@ -import log from '@swissgeo/log' -import { bbox, lineString } from '@turf/turf' -import cloneDeep from 'lodash/cloneDeep' - -/** @param {SelectableFeature} feature */ -export function canFeatureShowProfile(feature) { - return ['MultiLineString', 'LineString', 'Polygon', 'MultiPolygon'].includes( - feature?.geometry?.type - ) -} - -function canPointsBeStitched(p1, p2, tolerance = 10.0) { - return ( - (p1[0] === p2[0] && p1[1] === p2[1]) || - Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)) <= tolerance - ) -} - -/** - * @param {SingleCoordinate[]} currentLine - * @param {SingleCoordinate[][]} remainingLines - * @param {number[]} previouslyUsedIndexes - * @param {number} tolerance - * @returns {Object} - */ -function stitchMultiLineStringRecurse( - currentLine, - remainingLines, - previouslyUsedIndexes = [], - tolerance = 10.0 -) { - let currentLineBeingStitched = [...currentLine] - let someStitchHappened = false - const usedIndex = [...previouslyUsedIndexes] - - remainingLines.forEach((line, index) => { - // if line was already used elsewhere, skip it - if (usedIndex.includes(index)) { - return - } - - const firstPoint = currentLineBeingStitched[0] - const lastPoint = currentLineBeingStitched[currentLineBeingStitched.length - 1] - const lineFirstPoint = line[0] - const lineLastPoint = line[line.length - 1] - - if (canPointsBeStitched(firstPoint, lineLastPoint, tolerance)) { - currentLineBeingStitched = [...line, ...currentLineBeingStitched] - someStitchHappened = true - usedIndex.push(index) - } else if (canPointsBeStitched(firstPoint, lineFirstPoint, tolerance)) { - currentLineBeingStitched = [...line.toReversed(), ...currentLineBeingStitched] - someStitchHappened = true - usedIndex.push(index) - } else if (canPointsBeStitched(lastPoint, lineFirstPoint, tolerance)) { - currentLineBeingStitched = [...currentLineBeingStitched, ...line] - someStitchHappened = true - usedIndex.push(index) - } else if (canPointsBeStitched(lastPoint, lineLastPoint, tolerance)) { - currentLineBeingStitched = [...currentLineBeingStitched, ...line.toReversed()] - someStitchHappened = true - usedIndex.push(index) - } - }) - - if (someStitchHappened) { - return stitchMultiLineStringRecurse(currentLineBeingStitched, remainingLines, usedIndex) - } - - return { result: currentLineBeingStitched, usedIndex } -} - -/** - * Stitch together connected LineStrings in a MultiLineString geometry. - * - * @param {SingleCoordinate[][]} lines Elements of the MultiLineString. - * @param {number} tolerance How far away (in meters) two points can be and still be considered - * "stitch-candidate" - * @returns {SingleCoordinate[][]} Elements stitched togethers, if possible, or simply left as is if - * not. - */ -export function stitchMultiLine(lines, tolerance = 10.0) { - const results = [] - const globalUsedIndexes = [] - lines.forEach((line, index) => { - // if line was already used, we can skip it (it's already included in some other line) - if (globalUsedIndexes.includes(index)) { - return - } - const { result, usedIndex } = stitchMultiLineStringRecurse( - line, - lines, - [index, ...globalUsedIndexes], - tolerance - ) - globalUsedIndexes.push(...usedIndex) - results.push(result) - }) - return results -} - -export default { - state: { - feature: null, - simplifyGeometry: true, - /** - * The index of the current feature segment to highlight in the profile - * - * @type {Number} - */ - currentFeatureSegmentIndex: 0, - }, - getters: { - /** - * @param {State} state - * @returns {boolean} True if the profile feature is a LineString or Polygon - */ - isProfileFeatureMultiFeature(state) { - return ['MultiPolygon', 'MultiLineString'].includes(state.feature?.geometry?.type) - }, - currentProfileCoordinates(state, getters) { - if (!state.feature) { - return null - } - if (getters.isProfileFeatureMultiFeature) { - return state.feature.geometry.coordinates[state.currentFeatureSegmentIndex] - } - if (state.feature.geometry.type === 'Polygon') { - return state.feature.geometry.coordinates[0] - } - return state.feature.geometry.coordinates - }, - /** - * @param state - * @param getters - * @returns {number[] | null} - */ - currentProfileExtent(state, getters) { - if (!state.feature) { - return null - } - return bbox(lineString(getters.currentProfileCoordinates)) - }, - }, - actions: { - /** - * Sets the current feature segment index. This is used to highlight the current segment of - * a feature is inspecting the feature profile. - * - * @param commit - * @param {Number} index The index of the segment to highlight - */ - setCurrentFeatureSegmentIndex({ commit }, { index = 0, dispatcher }) { - commit('setCurrentFeatureSegmentIndex', { index, dispatcher }) - }, - /** - * Sets the GeoJSON geometry for which we want a profile and request this profile from the - * backend (if the geometry is valid) - * - * Only GeoJSON LineString and Polygon types are supported to request a profile. - * - * @param {SelectableFeature | null} feature A feature which has a LineString or Polygon - * geometry, and for which we want to show a height profile (or `null` if the profile - * should be cleared/hidden) - * @param {Boolean} simplifyGeometry If set to true, the geometry of the feature will be - * simplified before being sent to the profile backend. This is useful in case the data - * comes from an unfiltered GPS source (GPX track), and not simplifying the track could - * lead to a coastal paradox (meaning the hiking time will be way of the charts because of - * all the small jumps due to GPS errors) - * @param dispatcher - */ - setProfileFeature({ commit }, { feature = null, simplifyGeometry = false, dispatcher }) { - if (feature === null) { - commit('setProfileFeature', { feature: null, dispatcher }) - } else if (canFeatureShowProfile(feature)) { - // the feature comes from vuex, so if we mutate it directly it raises an error - const profileFeature = cloneDeep(feature) - if (profileFeature.geometry.type === 'MultiLineString') { - // attempting to simplify the multiline into fewer "segments" - profileFeature.geometry.coordinates = stitchMultiLine( - profileFeature.geometry.coordinates, - // empirically tested with Veloland layer, gives the best results without a tradeoff - 50.0 /* m */ - ) - } - // if the geometry is a MultiPolygon, we need to flatten it one level, so it can get processed as segments - if (profileFeature.geometry.type === 'MultiPolygon') { - profileFeature.geometry.coordinates = profileFeature.geometry.coordinates.flat(1) - } - // Reset segment index to 0 when changing to a feature without multiple segments - if (profileFeature.geometry.coordinates.length <= 1) { - commit('setCurrentFeatureSegmentIndex', { index: 0, dispatcher }) - } - commit('setProfileFeature', { feature: profileFeature, dispatcher }) - } else { - log.warn('Geometry type not supported to show a profile, ignoring', feature) - } - commit('setProfileSimplifyGeometry', { - simplifyGeometry: !!simplifyGeometry, - dispatcher, - }) - }, - }, - mutations: { - setProfileFeature(state, { feature }) { - state.feature = feature - }, - setProfileSimplifyGeometry(state, { simplifyGeometry }) { - state.simplifyGeometry = simplifyGeometry - }, - setCurrentFeatureSegmentIndex(state, { index }) { - state.currentFeatureSegmentIndex = index - }, - }, -} diff --git a/packages/viewer/src/store/modules/profile.store.ts b/packages/viewer/src/store/modules/profile.store.ts new file mode 100644 index 0000000000..b96422d8f7 --- /dev/null +++ b/packages/viewer/src/store/modules/profile.store.ts @@ -0,0 +1,241 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' + +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { bbox, lineString } from '@turf/turf' +import cloneDeep from 'lodash/cloneDeep' +import { defineStore } from 'pinia' + +import type SelectableFeature from '@/api/features/SelectableFeature.class' +import type { ActionDispatcher } from '@/store/store' +import type { FlatExtent } from '@/utils/extentUtils' + +export function canFeatureShowProfile(feature?: SelectableFeature): boolean { + return ( + !!feature?.geometry && + ['MultiLineString', 'LineString', 'Polygon', 'MultiPolygon'].includes( + feature?.geometry?.type + ) + ) +} + +function canPointsBeStitched( + p1: SingleCoordinate, + p2: SingleCoordinate, + tolerance: number = 10.0 +): boolean { + return ( + (p1[0] === p2[0] && p1[1] === p2[1]) || + Math.sqrt(Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2)) <= tolerance + ) +} + +function stitchMultiLineStringRecurse( + currentLine: SingleCoordinate[], + remainingLines: SingleCoordinate[][], + previouslyUsedIndexes: number[] = [], + tolerance: number = 10.0 +): { result: SingleCoordinate[]; usedIndex: number[] } { + let currentLineBeingStitched: SingleCoordinate[] = [...currentLine] + let someStitchHappened: boolean = false + const usedIndex: number[] = [...previouslyUsedIndexes] + + remainingLines.forEach((line, index) => { + // if line was already used elsewhere, skip it + if (usedIndex.includes(index)) { + return + } + + const firstPoint: SingleCoordinate = currentLineBeingStitched[0] + const lastPoint: SingleCoordinate = + currentLineBeingStitched[currentLineBeingStitched.length - 1] + const lineFirstPoint: SingleCoordinate = line[0] + const lineLastPoint: SingleCoordinate = line[line.length - 1] + + if (canPointsBeStitched(firstPoint, lineLastPoint, tolerance)) { + currentLineBeingStitched = [...line, ...currentLineBeingStitched] + someStitchHappened = true + usedIndex.push(index) + } else if (canPointsBeStitched(firstPoint, lineFirstPoint, tolerance)) { + currentLineBeingStitched = [...line.toReversed(), ...currentLineBeingStitched] + someStitchHappened = true + usedIndex.push(index) + } else if (canPointsBeStitched(lastPoint, lineFirstPoint, tolerance)) { + currentLineBeingStitched = [...currentLineBeingStitched, ...line] + someStitchHappened = true + usedIndex.push(index) + } else if (canPointsBeStitched(lastPoint, lineLastPoint, tolerance)) { + currentLineBeingStitched = [...currentLineBeingStitched, ...line.toReversed()] + someStitchHappened = true + usedIndex.push(index) + } + }) + + if (someStitchHappened) { + return stitchMultiLineStringRecurse(currentLineBeingStitched, remainingLines, usedIndex) + } + + return { result: currentLineBeingStitched, usedIndex } +} + +/** + * Stitch together connected LineStrings in a MultiLineString geometry. + * + * @param lines Elements of the MultiLineString. + * @param tolerance How far away (in meters) two points can be and still be considered + * "stitch-candidate" + * @returns Elements stitched togethers, if possible, or simply left as is if not. + */ +export function stitchMultiLine( + lines: SingleCoordinate[][], + tolerance: number = 10.0 +): SingleCoordinate[][] { + const results: SingleCoordinate[][] = [] + const globalUsedIndexes: number[] = [] + lines.forEach((line: SingleCoordinate[], index: number) => { + // if line was already used, we can skip it (it's already included in some other line) + if (globalUsedIndexes.includes(index)) { + return + } + const { result, usedIndex } = stitchMultiLineStringRecurse( + line, + lines, + [index, ...globalUsedIndexes], + tolerance + ) + globalUsedIndexes.push(...usedIndex) + results.push(result) + }) + return results +} + +export interface ProfileState { + feature: SelectableFeature | undefined + simplifyGeometry: boolean + /** + * Tells which part of a MultiLineString or Polygon is to be shown as the profile. Will also be + * used jointly with the currentMultiFeatureIndex when dealing with MultiPolygons + */ + currentFeatureGeometryIndex: number +} + +const useProfileStore = defineStore('profile', { + state: (): ProfileState => ({ + feature: undefined, + simplifyGeometry: true, + currentFeatureGeometryIndex: 0, + }), + getters: { + /** @returns True if the profile feature is a LineString or Polygon */ + isProfileFeatureMultiFeature(): boolean { + return ( + !!this.feature?.geometry && + ['MultiPolygon', 'MultiLineString'].includes(this.feature?.geometry?.type) + ) + }, + + /** + * Checks if the profile feature is a MultiPolygon describing multiple "rings" (aka polygons + * with holes, or disjointed parts) + */ + hasProfileFeatureMultipleGeometries(): boolean { + return ( + !!this.feature?.geometry && + this.feature.geometry?.type === 'MultiPolygon' && + this.feature.geometry.coordinates.length > 1 + ) + }, + + currentProfileCoordinates(): SingleCoordinate[] | undefined { + if (!this.feature || !this.feature?.geometry) { + return + } + if (this.feature.geometry.type === 'MultiPolygon') { + // if the geometry is a MultiPolygon, we need to flatten it one level, so it can get processed as the other types + return this.feature.geometry.coordinates.flat(1)[ + this.currentFeatureGeometryIndex + ] as SingleCoordinate[] + } + if ( + this.feature.geometry.type === 'MultiLineString' || + this.feature.geometry.type === 'Polygon' + ) { + return this.feature.geometry.coordinates[ + this.currentFeatureGeometryIndex + ] as SingleCoordinate[] + } + if (this.feature.geometry.type === 'LineString') { + return this.feature.geometry.coordinates as SingleCoordinate[] + } + return + }, + + currentProfileExtent(): FlatExtent | undefined { + if (!this.currentProfileCoordinates) { + return + } + return bbox(lineString(this.currentProfileCoordinates)) as FlatExtent + }, + }, + actions: { + /** + * Sets the current feature segment index. This is used to highlight the current segment of + * a feature is inspecting the feature profile. + */ + setCurrentFeatureSegmentIndex(index: number, dispatcher: ActionDispatcher) { + this.currentFeatureGeometryIndex = index ?? 0 + }, + + /** + * Sets the GeoJSON geometry for which we want a profile and request this profile from the + * backend (if the geometry is valid) + * + * Only GeoJSON LineString and Polygon types are supported to request a profile. + * + * @param payload + * @param payload.feature A feature which has a LineString or Polygon geometry, and for + * which we want to show a height profile (or `null` if the profile should be + * cleared/hidden) + * @param payload.simplifyGeometry If set to true, the geometry of the feature will be + * simplified before being sent to the profile backend. This is useful in case the data + * comes from an unfiltered GPS source (GPX track). Not simplifying the track could lead + * to a coastal paradox (meaning the hiking time will be way of the charts because of all + * the small jumps due to GPS errors) + * @param dispatcher + */ + setProfileFeature( + payload: { feature?: SelectableFeature; simplifyGeometry?: boolean }, + dispatcher: ActionDispatcher + ) { + const { feature, simplifyGeometry = false } = payload + if (!feature) { + this.feature = undefined + } else if (canFeatureShowProfile(feature)) { + // the feature comes from vuex, so if we mutate it directly it raises an error + const profileFeature = cloneDeep(feature) + if (!profileFeature.geometry) { + return + } + if (profileFeature.geometry.type === 'MultiLineString') { + // attempting to simplify the multiline into fewer "geometries" + profileFeature.geometry.coordinates = stitchMultiLine( + profileFeature.geometry.coordinates as SingleCoordinate[][], + // empirically tested with Veloland layer, gives the best results without a tradeoff + 50.0 /* m */ + ) + } + this.feature = profileFeature + } else { + log.warn({ + title: 'Profile store / setProfileFeature', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Geometry type not supported to show a profile, ignoring', feature], + }) + } + this.simplifyGeometry = simplifyGeometry + }, + }, +}) + +export default useProfileStore diff --git a/packages/viewer/src/store/modules/search.store.js b/packages/viewer/src/store/modules/search.store.js deleted file mode 100644 index 4d23c332b6..0000000000 --- a/packages/viewer/src/store/modules/search.store.js +++ /dev/null @@ -1,349 +0,0 @@ -import { constants, coordinatesUtils, extentUtils, LV03 } from '@swissgeo/coordinates' -import log from '@swissgeo/log' -import GeoJSON from 'ol/format/GeoJSON' - -import getFeature from '@/api/features/features.api' -import LayerFeature from '@/api/features/LayerFeature.class' -import LayerTypes from '@/api/layers/LayerTypes.enum' -import reframe from '@/api/lv03Reframe.api' -import search, { SearchResultTypes } from '@/api/search.api' -import { isWhat3WordsString, retrieveWhat3WordsLocation } from '@/api/what3words.api' -import { FeatureInfoPositions } from '@/store/modules/ui.store' -import coordinateFromString from '@/utils/coordinates/coordinateExtractors' -import { parseGpx } from '@/utils/gpxUtils' -import { parseKml } from '@/utils/kmlUtils' - -const state = { - /** - * The search query, will trigger a search to the backend if it contains 3 or more characters - * - * @type String - */ - query: '', - /** - * Search results from the backend for the current query - * - * @type {SearchResult[]} - */ - results: [], - - /** - * If true, the first search result will be automatically selected - * - * @type {Boolean} - */ - autoSelect: false, -} - -const getters = {} - -/** - * Returns the appropriate result for autoselection from a list of search results. - * - * If there is only one result, it returns that result. Otherwise, it tries to find a result with - * the resultType of LOCATION. If such a result is found, it returns that result. If no result with - * resultType LOCATION is found, it returns the first result in the list. - * - * @param {SearchResult[]} results - The list of search results. - * @returns {SearchResult} - The selected search result for autoselection. - */ -function getResultForAutoselect(results) { - if (results.length === 1) { - return results[0] - } - // Try to find a result with resultType LOCATION - const locationResult = results.find( - (result) => result.resultType === SearchResultTypes.LOCATION - ) - - // If a location result is found, return it; otherwise, return the first result - return locationResult ?? results[0] -} - -const actions = { - setAutoSelect: ({ commit }, { value = false, dispatcher }) => { - commit('setAutoSelect', { value, dispatcher }) - }, - - /** - * @param {vuex} vuex - * @param {Object} payload - * @param {String} payload.query - */ - setSearchQuery: async ( - { commit, rootState, dispatch, getters }, - { query = '', originUrlParam = false, dispatcher } - ) => { - let results = [] - commit('setSearchQuery', { query, dispatcher }) - // only firing search if query is longer than or equal to 2 chars - if (query.length >= 2) { - const currentProjection = rootState.position.projection - // checking first if this corresponds to a set of coordinates (or a what3words) - const extractedCoordinate = coordinateFromString(query) - let what3wordLocation = null - if (!extractedCoordinate && isWhat3WordsString(query)) { - try { - what3wordLocation = await retrieveWhat3WordsLocation(query, currentProjection) - } catch (error) { - log.info( - `Query "${query}" is not a valid What3Words, fallback to service search`, - error - ) - what3wordLocation = null - } - } - - if (extractedCoordinate) { - let coordinates = [...extractedCoordinate.coordinate] - if (extractedCoordinate.coordinateSystem !== currentProjection) { - // special case for LV03 input, we can't use proj4 to transform them into - // LV95 or others, as the deformation between LV03 and the others is not constant. - // So we pass through a LV95 reframe (done by a backend service that knows all deformations between the two) - // and then go to the wanted coordinate system - if (extractedCoordinate.coordinateSystem === LV03) { - coordinates = await reframe({ - inputProjection: LV03, - inputCoordinates: coordinates, - outputProjection: currentProjection, - }) - } else { - coordinates = coordinatesUtils.reprojectAndRound( - extractedCoordinate.coordinateSystem, - currentProjection, - coordinates - ) - } - } - const dispatcherCoordinate = `${dispatcher}/search.store/setSearchQuery/coordinate` - dispatch('setCenter', { - center: coordinates, - dispatcher: dispatcherCoordinate, - }) - if (!currentProjection.usesMercatorPyramid) { - dispatch('setZoom', { - zoom: currentProjection.transformStandardZoomLevelToCustom( - constants.STANDARD_ZOOM_LEVEL_1_25000_MAP - ), - dispatcher: dispatcherCoordinate, - }) - } else { - dispatch('setZoom', { - zoom: constants.STANDARD_ZOOM_LEVEL_1_25000_MAP, - dispatcher: dispatcherCoordinate, - }) - } - dispatch('setPinnedLocation', { coordinates, dispatcher: dispatcherCoordinate }) - } else if (what3wordLocation) { - const dispatcherWhat3words = `${dispatcher}/search.store/setSearchQuery/what3words` - dispatch('setCenter', { - center: what3wordLocation, - dispatcher: dispatcherWhat3words, - }) - if (!currentProjection.usesMercatorPyramid) { - dispatch('setZoom', { - zoom: currentProjection.transformStandardZoomLevelToCustom( - constants.STANDARD_ZOOM_LEVEL_1_25000_MAP - ), - dispatcher: dispatcherWhat3words, - }) - } else { - dispatch('setZoom', { - zoom: constants.STANDARD_ZOOM_LEVEL_1_25000_MAP, - dispatcher: dispatcherWhat3words, - }) - } - dispatch('setPinnedLocation', { - coordinates: what3wordLocation, - dispatcher: dispatcherWhat3words, - }) - } else { - try { - results = await search({ - outputProjection: currentProjection, - queryString: query, - lang: rootState.i18n.lang, - layersToSearch: getters.visibleLayers, - limit: state.autoSelect ? 1 : null, - }) - if ( - (originUrlParam && results.length === 1) || - (originUrlParam && state.autoSelect && results.length >= 1) - ) { - dispatch('selectResultEntry', { - dispatcher: `${dispatcher}/setSearchQuery`, - entry: getResultForAutoselect(results), - }) - } - } catch (error) { - log.error(`Search failed`, error) - } - } - } else if (query.length === 0) { - dispatch('clearPinnedLocation', { dispatcher: `${dispatcher}/setSearchQuery` }) - } - commit('setSearchResults', { results, dispatcher: `${dispatcher}/setSearchQuery` }) - }, - /** - * @param commit - * @param dispatch - * @param {SearchResult} entry - */ - selectResultEntry: async ({ dispatch, getters, rootState, commit }, { entry, dispatcher }) => { - const dispatcherSelectResultEntry = `${dispatcher}/search.store/selectResultEntry` - switch (entry.resultType) { - case SearchResultTypes.LAYER: - if (getters.getActiveLayersById(entry.layerId, false).length === 0) { - dispatch('addLayer', { - layerConfig: { id: entry.layerId, visible: true }, - dispatcher: dispatcherSelectResultEntry, - }) - } else { - dispatch('updateLayers', { - layers: [{ id: entry.layerId, visible: true }], - dispatcher: dispatcherSelectResultEntry, - }) - } - // launching a new search to get (potential) layer features - try { - const resultIncludingLayerFeatures = await search({ - outputProjection: rootState.position.projection, - queryString: state.query, - lang: rootState.i18n.lang, - layersToSearch: getters.visibleLayers, - limit: state.autoSelect ? 1 : null, - }) - if (resultIncludingLayerFeatures.length > state.results.length) { - commit('setSearchResults', { - results: resultIncludingLayerFeatures, - ...dispatcher, - }) - } - } catch (error) { - log.error(`Search failed`, error) - } - break - case SearchResultTypes.LOCATION: - zoomToEntry(entry, dispatch, dispatcher, dispatcherSelectResultEntry) - dispatch('setPinnedLocation', { - coordinates: entry.coordinate, - dispatcher: dispatcherSelectResultEntry, - }) - break - case SearchResultTypes.FEATURE: - zoomToEntry(entry, dispatch, dispatcher, dispatcherSelectResultEntry) - - // Automatically select the feature - try { - if (entry.layer.getTopicForIdentifyAndTooltipRequests) { - getFeature(entry.layer, entry.featureId, rootState.position.projection, { - lang: rootState.i18n.lang, - screenWidth: rootState.ui.width, - screenHeight: rootState.ui.height, - mapExtent: extentUtils.flattenExtent(getters.extent), - coordinate: entry.coordinate, - }).then((feature) => { - dispatch('setSelectedFeatures', { - features: [feature], - dispatcher, - }) - dispatch('setFeatureInfoPosition', { - position: FeatureInfoPositions.TOOLTIP, - ...dispatcher, - }) - }) - } else { - // For imported KML and GPX files - let features = [] - if (entry.layer.type === LayerTypes.KML) { - features = parseKml(entry.layer, rootState.position.projection, []) - } - if (entry.layer.type === LayerTypes.GPX) { - features = parseGpx( - entry.layer.gpxData, - rootState.position.projection, - [] - ) - } - const layerFeatures = features - .map((feature) => createLayerFeature(feature, entry.layer)) - .filter((feature) => !!feature && feature.data.title === entry.title) - dispatch('setSelectedFeatures', { - features: layerFeatures, - dispatcher, - }) - dispatch('setFeatureInfoPosition', { - position: FeatureInfoPositions.TOOLTIP, - ...dispatcher, - }) - } - } catch (error) { - log.error('Error getting feature:', error) - } - - break - } - if (entry.resultType === SearchResultTypes.LOCATION) { - commit('setSearchQuery', { query: entry.sanitizedTitle.trim(), dispatcher }) - } - if (state.autoSelect) { - dispatch('setAutoSelect', { - value: false, - dispatcher: dispatcherSelectResultEntry, - }) - } - }, -} - -function createLayerFeature(olFeature, layer) { - if (!olFeature.getGeometry()) return null - return new LayerFeature({ - layer: layer, - id: olFeature.getId(), - title: - olFeature.get('label') ?? - // exception for MeteoSchweiz GeoJSONs, we use the station name instead of the ID - // some of their layers are - // - ch.meteoschweiz.messwerte-niederschlag-10min - // - ch.meteoschweiz.messwerte-lufttemperatur-10min - olFeature.get('station_name') ?? - // GPX track feature don't have an ID but have a name ! - olFeature.get('name') ?? - olFeature.getId(), - data: { - title: olFeature.get('name'), - description: olFeature.get('description'), - }, - coordinates: olFeature.getGeometry().getCoordinates(), - geometry: new GeoJSON().writeGeometryObject(olFeature.getGeometry()), - extent: extentUtils.normalizeExtent(olFeature.getGeometry().getExtent()), - }) -} - -const mutations = { - setAutoSelect: (state, { value }) => (state.autoSelect = value), - setSearchQuery: (state, { query }) => (state.query = query), - setSearchResults: (state, { results }) => (state.results = results ?? []), -} - -export default { - state, - getters, - actions, - mutations, -} - -function zoomToEntry(entry, dispatch, dispatcher, dispatcherSelectResultEntry) { - if (entry.extent?.length === 2) { - dispatch('zoomToExtent', { extent: entry.extent, dispatcher }) - } else if (entry.zoom) { - dispatch('setCenter', { - center: entry.coordinate, - dispatcher: dispatcherSelectResultEntry, - }) - dispatch('setZoom', { - zoom: entry.zoom, - dispatcher: dispatcherSelectResultEntry, - }) - } -} diff --git a/packages/viewer/src/store/modules/search.store.ts b/packages/viewer/src/store/modules/search.store.ts new file mode 100644 index 0000000000..931242f0a0 --- /dev/null +++ b/packages/viewer/src/store/modules/search.store.ts @@ -0,0 +1,367 @@ +import type { SingleCoordinate } from '@geoadmin/coordinates' +import type { GPXLayer, KMLLayer } from '@geoadmin/layers' + +import { + constants, + CoordinateSystem, + CustomCoordinateSystem, + LV03, + reprojectAndRound, +} from '@geoadmin/coordinates' +import { LayerType } from '@geoadmin/layers' +import { layerUtils } from '@geoadmin/layers/utils' +import log, { LogPreDefinedColor } from '@geoadmin/log' +import GeoJSON from 'ol/format/GeoJSON' +import { defineStore } from 'pinia' + +import type { ActionDispatcher } from '@/store/store' + +import getFeature from '@/api/features/features.api' +import LayerFeature from '@/api/features/LayerFeature.class' +import reframe from '@/api/lv03Reframe.api' +import search, { + type LayerFeatureSearchResult, + type LayerSearchResult, + type LocationSearchResult, + type SearchResult, + SearchResultTypes, +} from '@/api/search.api' +import { isWhat3WordsString, retrieveWhat3WordsLocation } from '@/api/what3words.api' +import useFeaturesStore from '@/store/modules/features.store' +import { useI18nStore } from '@/store/modules/i18n.store' +import useLayersStore from '@/store/modules/layers.store' +import useMapStore from '@/store/modules/map.store' +import usePositionStore from '@/store/modules/position.store' +import useUIStore, { FeatureInfoPositions } from '@/store/modules/ui.store' +import coordinateFromString from '@/utils/coordinates/coordinateExtractors' +import { flattenExtent, normalizeExtent } from '@/utils/extentUtils' +import { parseGpx } from '@/utils/gpxUtils' +import { parseKml } from '@/utils/kmlUtils.ts' + +function zoomToSearchResult( + entry: LocationSearchResult | LayerFeatureSearchResult, + dispatcher: ActionDispatcher +) { + const positionStore = usePositionStore() + if (entry.extent) { + positionStore.zoomToExtent({ extent: entry.extent }, dispatcher) + } else if (entry.zoom && entry.coordinate) { + positionStore.setCenter(entry.coordinate, dispatcher) + positionStore.setZoom(entry.zoom, dispatcher) + } +} + +export interface SearchState { + /** The search query. It will trigger a search to the backend if it contains 3 or more characters */ + query: string + /** Search results from the backend for the current query */ + results: SearchResult[] + /** If true, the first search result will be automatically selected */ + autoSelect: boolean +} + +export const useSearchStore = defineStore('search', { + state: (): SearchState => ({ + query: '', + results: [], + autoSelect: false, + }), + getters: {}, + actions: { + setAutoSelect(autoSelect: boolean, dispatcher: ActionDispatcher) { + this.autoSelect = autoSelect + }, + + async setSearchQuery( + payload: { + query: string + /** + * Used to select the first result if there is only one. Else it will not be, + * because this redo search is done every time the page loads + */ + originUrlParam?: boolean + }, + dispatcher: ActionDispatcher + ) { + const { query, originUrlParam = false } = payload + const i18nStore = useI18nStore() + const layerStore = useLayersStore() + const mapStore = useMapStore() + const positionStore = usePositionStore() + + const currentProjection: CoordinateSystem = positionStore.projection + + let results = [] + this.query = query + // only firing search if the query is longer than or equal to 2 chars + if (query.length >= 2) { + // checking first if this corresponds to a set of coordinates (or a what3words) + const extractedCoordinate = coordinateFromString(query) + let what3wordLocation: SingleCoordinate | undefined + if (!extractedCoordinate && isWhat3WordsString(query)) { + try { + what3wordLocation = await retrieveWhat3WordsLocation( + query, + currentProjection + ) + } catch (error) { + log.info({ + title: 'Search store / setSearchQuery', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + `Query "${query}" is not a valid What3Words, fallback to service search`, + error, + ], + }) + what3wordLocation = undefined + } + } + + if (extractedCoordinate) { + let coordinates: SingleCoordinate = extractedCoordinate.coordinate + if (extractedCoordinate.coordinateSystem !== currentProjection) { + // Special case for LV03 input, we can't use proj4 to transform them into + // LV95 or others, as the deformation between LV03 and the others is not constant. + // So we pass through a LV95 REFRAME (done by a backend service that knows all deformations between the two) + // and then go to the wanted coordinate system + if (extractedCoordinate.coordinateSystem === LV03) { + coordinates = await reframe({ + inputProjection: LV03, + inputCoordinates: coordinates, + outputProjection: currentProjection, + }) + } else { + coordinates = reprojectAndRound( + extractedCoordinate.coordinateSystem, + currentProjection, + coordinates + ) + } + } + positionStore.setCenter(coordinates, dispatcher) + if (currentProjection instanceof CustomCoordinateSystem) { + positionStore.setZoom( + currentProjection.transformStandardZoomLevelToCustom( + constants.STANDARD_ZOOM_LEVEL_1_25000_MAP + ), + dispatcher + ) + } else { + positionStore.setZoom(constants.STANDARD_ZOOM_LEVEL_1_25000_MAP, dispatcher) + } + mapStore.setPinnedLocation(coordinates, dispatcher) + } else if (what3wordLocation) { + positionStore.setCenter(what3wordLocation, dispatcher) + if (currentProjection instanceof CustomCoordinateSystem) { + positionStore.setZoom( + currentProjection.transformStandardZoomLevelToCustom( + constants.STANDARD_ZOOM_LEVEL_1_25000_MAP + ), + dispatcher + ) + } else { + positionStore.setZoom(constants.STANDARD_ZOOM_LEVEL_1_25000_MAP, dispatcher) + } + mapStore.setPinnedLocation(what3wordLocation, dispatcher) + } else { + try { + results = await search({ + outputProjection: currentProjection, + queryString: query, + lang: i18nStore.lang, + layersToSearch: layerStore.visibleLayers, + limit: this.autoSelect ? 1 : null, + }) + if ( + (originUrlParam && results.length === 1) || + (originUrlParam && this.autoSelect && results.length >= 1) + ) { + await this.selectResultEntry( + getResultForAutoselect(results), + dispatcher + ) + } + } catch (error) { + log.error({ + title: 'Search store / setSearchQuery', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [`Error while searching for "${query}"`, error], + }) + } + } + } else if (query.length === 0) { + mapStore.clearPinnedLocation(dispatcher) + } + this.results = results + }, + + async selectResultEntry(entry: SearchResult, dispatcher: ActionDispatcher) { + const i18nStore = useI18nStore() + const layerStore = useLayersStore() + const mapStore = useMapStore() + const positionStore = usePositionStore() + if (entry.resultType === SearchResultTypes.LAYER) { + const layerEntry = entry as LayerSearchResult + if (layerStore.getActiveLayersById(layerEntry.layerId, false).length === 0) { + layerStore.addLayer( + { layerId: layerEntry.id, layerConfig: { isVisible: true } }, + dispatcher + ) + } else { + layerStore.updateLayer( + { layerId: layerEntry.layerId, values: { isVisible: true } }, + dispatcher + ) + } + // launching a new search to get (potential) layer features + try { + const resultIncludingLayerFeatures = await search({ + outputProjection: positionStore.projection, + queryString: this.query, + lang: i18nStore.lang, + layersToSearch: layerStore.visibleLayers, + limit: this.autoSelect ? 1 : null, + }) + if (resultIncludingLayerFeatures.length > this.results.length) { + this.results = resultIncludingLayerFeatures + } + } catch (error) { + log.error({ + title: 'Search store / selectResultEntry', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: [ + 'Error while searching for layer features', + entry, + error, + dispatcher, + ], + }) + } + } else if (entry.resultType === SearchResultTypes.LOCATION) { + const locationEntry = entry as LocationSearchResult + zoomToSearchResult(locationEntry, dispatcher) + if (locationEntry.coordinate) { + mapStore.setPinnedLocation(locationEntry.coordinate, dispatcher) + } + await this.setSearchQuery( + { query: locationEntry.sanitizedTitle.trim() }, + dispatcher + ) + } else if (entry.resultType === SearchResultTypes.FEATURE) { + const featureEntry = entry as LayerFeatureSearchResult + zoomToSearchResult(featureEntry, dispatcher) + + // Automatically select the feature + try { + if (layerUtils.getTopicForIdentifyAndTooltipRequests(featureEntry.layer)) { + const featuresStore = useFeaturesStore() + const uiStore = useUIStore() + const feature = await getFeature( + featureEntry.layer, + featureEntry.featureId, + positionStore.projection, + { + lang: i18nStore.lang, + screenWidth: uiStore.width, + screenHeight: uiStore.height, + mapExtent: flattenExtent(positionStore.extent), + coordinate: featureEntry.coordinate, + } + ) + featuresStore.setSelectedFeatures( + { + features: [feature], + }, + dispatcher + ) + uiStore.setFeatureInfoPosition(FeatureInfoPositions.TOOLTIP, dispatcher) + } else { + // For imported KML and GPX files + let features = [] + if (featureEntry.layer.type === LayerType.KML) { + const kmlLayer: KMLLayer = featureEntry.layer as KMLLayer + features = parseKml(kmlLayer, positionStore.projection, []) + } else if (featureEntry.layer.type === LayerType.GPX) { + const gpxLayer = featureEntry.layer as GPXLayer + features = parseGpx(gpxLayer.gpxData, positionStore.projection) + } + const layerFeatures = features + .map((feature) => createLayerFeature(feature, entry.layer)) + .filter((feature) => !!feature && feature.data.title === entry.title) + dispatch('setSelectedFeatures', { + features: layerFeatures, + dispatcher, + }) + dispatch('setFeatureInfoPosition', { + position: FeatureInfoPositions.TOOLTIP, + ...dispatcher, + }) + } + } catch (error) { + log.error('Error getting feature:', error) + } + } + if (this.autoSelect) { + this.setAutoSelect(false, dispatcher) + } + }, + }, +}) + +/** + * Returns the appropriate result for autoselection from a list of search results. + * + * If there is only one result, it returns that result. Otherwise, it tries to find a result with + * the resultType of LOCATION. If such a result is found, it returns that result. If no result with + * resultType LOCATION is found, it returns the first result in the list. + * + * @param results - The list of search results. + * @returns The selected search result for autoselection. + */ +function getResultForAutoselect(results: SearchResult[]): SearchResult { + if (results.length === 1) { + return results[0] + } + // Try to find a result with resultType LOCATION + const locationResult = results.find( + (result) => result.resultType === SearchResultTypes.LOCATION + ) + + // If a location result is found, return it; otherwise, return the first result + return locationResult ?? results[0] +} + +function createLayerFeature(olFeature: Feature, layer) { + if (!olFeature.getGeometry()) { + return null + } + return new LayerFeature({ + layer: layer, + id: olFeature.getId(), + title: + olFeature.get('label') ?? + // exception for MeteoSchweiz GeoJSONs, we use the station name instead of the ID + // some of their layers are + // - ch.meteoschweiz.messwerte-niederschlag-10min + // - ch.meteoschweiz.messwerte-lufttemperatur-10min + olFeature.get('station_name') ?? + // GPX track feature don't have an ID but have a name ! + olFeature.get('name') ?? + olFeature.getId(), + data: { + title: olFeature.get('name'), + description: olFeature.get('description'), + }, + coordinates: olFeature.getGeometry().getCoordinates(), + geometry: new GeoJSON().writeGeometryObject(olFeature.getGeometry()), + extent: normalizeExtent(olFeature.getGeometry().getExtent()), + }) +} + +export default useSearchStore diff --git a/packages/viewer/src/store/modules/share.store.js b/packages/viewer/src/store/modules/share.store.js index 9c1bc77437..91d50b3743 100644 --- a/packages/viewer/src/store/modules/share.store.js +++ b/packages/viewer/src/store/modules/share.store.js @@ -1,6 +1,6 @@ import log from '@swissgeo/log' -import { createShortLink } from '@/api/shortlink.api' +import { createShortLink } from '@/api/shortlink.api.js' export default { state: { diff --git a/packages/viewer/src/store/modules/topics.store.js b/packages/viewer/src/store/modules/topics.store.js deleted file mode 100644 index 1a4db842cf..0000000000 --- a/packages/viewer/src/store/modules/topics.store.js +++ /dev/null @@ -1,117 +0,0 @@ -import { layerUtils } from '@swissgeo/layers/utils' -import log from '@swissgeo/log' - -const state = { - /** - * List of all available topics - * - * @type {Topic[]} - */ - config: [], - /** - * Current topic ID (either default 'ech' at app startup, or another from the config later - * chosen by the user) - * - * @type {String} - */ - current: 'ech', - /** - * Current topic's layers tree (that will help the user select layers belonging to this topic) - * - * @type {GeoAdminLayer[]} - */ - tree: [], - /** - * The ids of the catalog nodes that should be open. - * - * @type {String[]} - */ - openedTreeThemesIds: [], -} - -const getters = { - isDefaultTopic: (state) => { - return state.current === 'ech' - }, - /** Returns the current topic's id, or `ech` if no topic is selected */ - currentTopicId: (state) => { - return state.current - }, - currentTopic: (state) => { - return state.config.find((topic) => topic.id === state.current) - }, -} - -const actions = { - setTopics: ({ commit }, { topics, dispatcher }) => { - commit('setTopics', { topics, dispatcher }) - }, - setTopicTree: ({ commit }, { layers, dispatcher }) => { - commit('setTopicTree', { - layers: layers.map((layer) => layerUtils.cloneLayer(layer)), - dispatcher, - }) - }, - changeTopic: ({ commit, state }, { topicId, dispatcher }) => { - if ( - state.config.some((topic) => topic.id === topicId) || - // during appLoadingManagement.routerPlugin the topics are not yet set - // therefore we cannot validate the topic ID - dispatcher === 'appLoadingManagement.routerPlugin' - ) { - commit('changeTopic', { topicId, dispatcher }) - } else { - log.error(`Invalid topic ID ${topicId}`) - } - }, - setTopicTreeOpenedThemesIds: ({ commit }, { themes, dispatcher }) => { - if (typeof themes === 'string') { - commit('setTopicTreeOpenedThemesIds', { - themes: themes.split(','), - dispatcher, - }) - } else if (Array.isArray(themes)) { - commit('setTopicTreeOpenedThemesIds', { themes: themes.slice(), dispatcher }) - } - }, - addTopicTreeOpenedThemeId: ({ commit }, { themeId, dispatcher }) => { - commit('addTopicTreeOpenedThemeId', { - themeId, - dispatcher, - }) - }, - removeTopicTreeOpenedThemeId: ({ commit }, { themeId, dispatcher }) => { - commit('removeTopicTreeOpenedThemeId', { - themeId, - dispatcher, - }) - }, -} - -const mutations = { - setTopics: (state, { topics }) => (state.config = topics), - setTopicTree: (state, { layers }) => (state.tree = layers), - setTopicTreeOpenedThemesIds: (state, { themes }) => (state.openedTreeThemesIds = themes), - changeTopic: (state, { topicId }) => (state.current = topicId), - addTopicTreeOpenedThemeId: (state, { themeId }) => { - if (!state.openedTreeThemesIds.includes(themeId)) { - const newOpenThemesIds = [...state.openedTreeThemesIds] - newOpenThemesIds.push(themeId) - state.openedTreeThemesIds = newOpenThemesIds - } - }, - removeTopicTreeOpenedThemeId: (state, { themeId }) => { - if (state.openedTreeThemesIds.includes(themeId)) { - const newOpenThemesIds = [...state.openedTreeThemesIds] - newOpenThemesIds.splice(newOpenThemesIds.indexOf(themeId), 1) - state.openedTreeThemesIds = newOpenThemesIds - } - }, -} - -export default { - state, - getters, - actions, - mutations, -} diff --git a/packages/viewer/src/store/modules/topics.store.ts b/packages/viewer/src/store/modules/topics.store.ts new file mode 100644 index 0000000000..e5ed4da0bb --- /dev/null +++ b/packages/viewer/src/store/modules/topics.store.ts @@ -0,0 +1,87 @@ +import type { GeoAdminLayer } from '@geoadmin/layers' + +import { layerUtils } from '@geoadmin/layers/utils' +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { defineStore } from 'pinia' + +import type { Topic } from '@/api/topics.api' +import type { ActionDispatcher } from '@/store/store' + +export interface TopicsState { + /** List of all available topics */ + config: Topic[] + /** + * Current topic ID (either default 'ech' at app startup, or another from the config later + * chosen by the user) + */ + current: string + /** Current topic's layers tree (that will help the user select layers belonging to this topic) */ + tree: GeoAdminLayer[] + /** The ids of the catalog nodes that should be open. */ + openedTreeThemesIds: string[] +} + +const useTopicsStore = defineStore('topics', { + state: (): TopicsState => ({ + config: [], + current: 'ech', + tree: [], + openedTreeThemesIds: [], + }), + getters: { + isDefaultTopic(): boolean { + return this.current === 'ech' + }, + currentTopic(): Topic | undefined { + return this.config.find((topic) => topic.id === this.current) + }, + }, + actions: { + setTopics(topics: Topic[], dispatcher: ActionDispatcher) { + this.config = [...topics] + }, + + setTopicTree(layers: GeoAdminLayer[], dispatcher: ActionDispatcher) { + this.tree = layers.map((layer) => layerUtils.cloneLayer(layer) as GeoAdminLayer) + }, + + changeTopic(topicId: string, dispatcher: ActionDispatcher) { + if ( + this.config.some((topic) => topic.id === topicId) || + // during appLoadingManagement.routerPlugin the topics are not yet set, + // we can therefore not validate the topic ID + dispatcher.name === 'appLoadingManagement.routerPlugin' + ) { + this.current = topicId + } else { + log.error({ + title: 'Topics store', + titleStyle: { + backgroundColor: LogPreDefinedColor.Red, + }, + messages: ['Invalid topic ID', topicId, dispatcher], + }) + } + }, + + setTopicTreeOpenedThemesIds(themes: string | string[], dispatcher: ActionDispatcher) { + if (typeof themes === 'string') { + this.openedTreeThemesIds = themes.split(',') + } else if (Array.isArray(themes)) { + this.openedTreeThemesIds = [...themes] + } + }, + + addTopicTreeOpenedThemeId(themeId: string, dispatcher: ActionDispatcher) { + this.openedTreeThemesIds.push(themeId) + }, + + removeTopicTreeOpenedThemeId(themeId: string, dispatcher: ActionDispatcher) { + if (this.openedTreeThemesIds.includes(themeId)) { + this.openedTreeThemesIds.splice(this.openedTreeThemesIds.indexOf(themeId), 1) + } + }, + }, +}) + +export default useTopicsStore diff --git a/packages/viewer/src/store/modules/ui.store.js b/packages/viewer/src/store/modules/ui.store.js deleted file mode 100644 index a2f9e1669c..0000000000 --- a/packages/viewer/src/store/modules/ui.store.js +++ /dev/null @@ -1,579 +0,0 @@ -import log from '@swissgeo/log' -import { ErrorMessage, WarningMessage } from '@swissgeo/log/Message' -import { isNumber } from '@swissgeo/numbers' - -import { BREAKPOINT_TABLET, MAX_WIDTH_SHOW_FLOATING_TOOLTIP } from '@/config/responsive.config' -import { - GIVE_FEEDBACK_HOSTNAMES, - NO_WARNING_BANNER_HOSTNAMES, - REPORT_PROBLEM_HOSTNAMES, - WARNING_RIBBON_HOSTNAMES, -} from '@/config/staging.config' - -const MAP_LOADING_BAR_REQUESTER = 'app-map-loading' - -/** - * Describes the different mode the UI can have. Either desktop / tablet (menu is always shown, info - * box is a side tray) or phone (menu has to be opened with a button, info box is a swipeable - * element) - * - * @type enum - */ -export const UIModes = { - DESKTOP: 'DESKTOP', // formerly called "MENU_ALWAYS_OPEN", also used for tablets - PHONE: 'PHONE', // formerly called "MENU_OPENED_THROUGH_BUTTON" -} -export const FeatureInfoPositions = { - DEFAULT: 'default', // This is not the default value, but this is the default behavior, - // which depends on the UI size. Bottompanel on phones, tooltip on desktop - BOTTOMPANEL: 'bottomPanel', - TOOLTIP: 'tooltip', - NONE: 'none', -} -/** - * Module that stores all information related to the UI, for instance if a portion of the UI (like - * the header) should be visible right now or not. Most actions from this module will be - * used/synchronized by store plugins as it involved listening to some mutation to trigger this - * change. - */ -export default { - state: { - /** - * Height of the viewport (in px) - * - * @type Number - */ - height: window.innerHeight, - /** - * Width of the viewport (in px) - * - * @type Number - */ - width: window.innerWidth, - - /** - * Flag telling if the main menu (where the layer options, layer tree and other stuff is) - * should be open. It does not tell if the menu is effectively displayed on screen, as this - * also depends on e.g. if the drawing mode is open or not. Use the getter "isMenuShown" to - * know that. - */ - showMenu: window.innerWidth >= BREAKPOINT_TABLET, - /** - * Flag telling if the app should be shown in fullscreen mode, meaning that : - * - * - The header bar should be hidden - * - The footer should be hidden - * - Tool buttons (background wheel, zoom, geolocation) should be hidden - * - * @type Boolean - */ - fullscreenMode: false, - /** - * Flag telling if the app must be displayed as an embedded iFrame app (broken down / - * simplified UI) - * - * @type Boolean - */ - embed: false, - /** - * Flag telling if the ctrl key is required to scroll. This is useful when the map is - * embedded in an iframe and the parent page needs to scroll. - * - * @type Boolean - */ - noSimpleZoomEmbed: false, - /** - * Mapping of loading bar requesters. The loading bar on top of the screen is shown as soon - * as this mapping (object) is not empty. A requester can request several times the loading - * bar, but then it needs to clear it as many times it has set it. - * - * @type {[String]: Number} - */ - loadingBarRequesters: { [MAP_LOADING_BAR_REQUESTER]: 1 }, - /** - * Current UI mode of the application, dictates how the menu interaction is made (for touch - * the menu has to be opened through a button, for desktop it is always shown) and how the - * information about a selected feature are shown. - * - * @type String - */ - mode: UIModes.PHONE, // Configured in screen-size-management.plugin.js (or manually in the settings) - /** - * Expected position of the features tooltip position when selecting features. - * - * The default position is set to NONE, as we want people who want to share a feature - * without the tooltip to have a very simple URL. - * - * @type String - */ - featureInfoPosition: FeatureInfoPositions.NONE, - /** - * Hostname on which the application is running (use to display warnings to the user on - * 'non-production' hosts) - * - * @type String - */ - hostname: window.location.hostname, - /** - * Flag telling if import catalogue shown - * - * @type Boolean - */ - importCatalogue: false, - /** - * Flag telling if import file (map tooltip overlay or infobox) is shown - * - * @type Boolean - */ - importFile: false, - /** - * Height of the header (in px) - * - * @type Number - */ - headerHeight: 100, - - /** - * Float telling where across the screen is the compare slider. The compare Slider should - * only be shown when the value is between 0 and 1 - * - * @type Number - */ - - compareRatio: null, - /** - * Flag telling if the compare slider is currently active or not - * - * @type Boolean - */ - - isCompareSliderActive: false, - /** - * Flag telling if the time slider is currently active or not - * - * @type Boolean - */ - - isTimeSliderActive: false, - - /** - * Flag telling if iframe marker description has disclaimer shown - * - * @type Boolean - */ - showDisclaimer: true, - - /** - * Set of errors to display. Each error must be an ErrorMessage object. - * - * @type Set(ErrorMessage) - */ - errors: new Set(), - /** - * Set of warnings to display. Each warning must be an object WarningMessage - * - * @type Set(WarningMessage) - */ - warnings: new Set(), - - /** - * Flag telling if the "Drop file here" overlay will be displayed on top of the map. - * - * @type Boolean - */ - showDragAndDropOverlay: false, - - /** - * Flag telling if we should hide the UI elements in the embed viewer. Zoom buttons, 3d - * button and the `view on geoadmin link`. This is only for the Geo Platform Schweiz current - * implementation and should not be disclosed to users as something they can use. - * - * @type Boolean - */ - hideEmbedUI: false, - - /** - * Flag used to override the dev site warning behavior. This can only be used to force the - * dev site to remove its dev-specific UI element, so this always start as false, and is - * only meant to be set to true by a press of a button in the debug menu - * - * @type Boolean - */ - forceNoDevSiteWarning: false, - }, - getters: { - showLoadingBar(state) { - return Object.keys(state.loadingBarRequesters).length > 0 - }, - screenDensity(state) { - if (state.height === 0) { - return 0 - } - return state.width / state.height - }, - /** - * Tells if the menu tray is shown - * - * On desktop mode, the menu tray is always shown, as long as the header is shown. Clicking - * on the menu open / close button will simply minimize / maximize the menu tray. On phone - * mode, the menu tray is only shown if the menu is shown. - * - * @returns {boolean} - */ - isMenuTrayShown(state, getters) { - return state.mode === UIModes.PHONE ? getters.isMenuShown : getters.isHeaderShown - }, - - /** - * Tells if the main menu is effectively displayed on the screen (i.e. if the menu is open - * AND visible). - * - * This is the case if the menu is in its open state and the header is shown. - * - * @returns {boolean} - */ - isMenuShown(state, getters) { - return getters.isHeaderShown && state.showMenu - }, - - /** - * Tells if the header bar is visible - * - * @returns {boolean} - */ - isHeaderShown(state, getters, rootState) { - return !state.fullscreenMode && !rootState.drawing?.drawingOverlay.show - }, - - isPhoneMode(state) { - return state.mode === UIModes.PHONE - }, - isDesktopMode(state) { - return state.mode === UIModes.DESKTOP - }, - hasNoSimpleZoomEmbedEnabled(state) { - return state.noSimpleZoomEmbed - }, - isEmbed(state) { - return state.embed - }, - isPhoneSize(state, getters) { - return getters.isPhoneMode - }, - isTabletSize(state, getters) { - return getters.isDesktopMode && state.width < BREAKPOINT_TABLET - }, - isTraditionalDesktopSize(state, getters) { - return getters.isDesktopMode && state.width >= BREAKPOINT_TABLET - }, - /** Flag to display a warning ribbon ('TEST') at the top/bottom right corner */ - hasWarningRibbon(state) { - return WARNING_RIBBON_HOSTNAMES.some((hostname) => state.hostname.includes(hostname)) - }, - /** - * Flag that tells if users should be warned that it is a development site. Also used to - * hide development specific features in production (like the app version) - */ - hasDevSiteWarning(state) { - return ( - !state.forceNoDevSiteWarning && - !NO_WARNING_BANNER_HOSTNAMES.some((hostname) => state.hostname.includes(hostname)) - ) - }, - isProductionSite(state) { - return state.hostname === 'map.geo.admin.ch' - }, - showFeatureInfoInTooltip(state, getters) { - return ( - state.featureInfoPosition === FeatureInfoPositions.TOOLTIP || - (state.featureInfoPosition === FeatureInfoPositions.DEFAULT && !getters.isPhoneMode) - ) - }, - showFeatureInfoInBottomPanel(state, getters) { - return ( - state.featureInfoPosition === FeatureInfoPositions.BOTTOMPANEL || - (state.featureInfoPosition === FeatureInfoPositions.DEFAULT && getters.isPhoneMode) - ) - }, - noFeatureInfo(state) { - return state.featureInfoPosition === FeatureInfoPositions.NONE - }, - /** - * Flag to display to display give feedback button/form. On localhost, it will always shown - * for testing purpose - */ - hasGiveFeedbackButton(state) { - return GIVE_FEEDBACK_HOSTNAMES.some((hostname) => state.hostname.includes(hostname)) - }, - /** - * Flag to display to display report problem button/form. On localhost, it will always shown - * for testing purpose - */ - hasReportProblemButton(state) { - return REPORT_PROBLEM_HOSTNAMES.some((hostname) => state.hostname.includes(hostname)) - }, - - hideEmbedUI(state) { - return state.hideEmbedUI - }, - }, - actions: { - setSize({ commit, state }, { width, height, dispatcher }) { - commit('setSize', { - height, - width, - dispatcher, - }) - // on resize with a very narrow width, the tooltip would overlap with the right side menu - // we enforce the features information to be set into an infobox when we want to show them - // in this situation - if ( - state.featureInfoPosition !== FeatureInfoPositions.NONE && - width < MAX_WIDTH_SHOW_FLOATING_TOOLTIP - ) { - commit('setFeatureInfoPosition', { - position: FeatureInfoPositions.BOTTOMPANEL, - dispatcher, - }) - } - }, - toggleMenu({ commit, state }, { dispatcher }) { - commit('setShowMenu', { show: !state.showMenu, dispatcher }) - }, - closeMenu({ commit }, { dispatcher }) { - commit('setShowMenu', { show: false, dispatcher }) - }, - toggleFullscreenMode({ commit, state }, { dispatcher }) { - commit('setFullscreenMode', { mode: !state.fullscreenMode, dispatcher }) - }, - setEmbed({ commit }, { embed, dispatcher }) { - commit('setEmbed', { embed: !!embed, dispatcher }) - }, - setNoSimpleZoomEmbed({ commit }, { noSimpleZoomEmbed, dispatcher }) { - if (typeof noSimpleZoomEmbed === 'boolean') { - commit('setNoSimpleZoomEmbed', { - noSimpleZoomEmbed, - dispatcher, - }) - } - }, - setLoadingBarRequester({ commit }, { requester, dispatcher }) { - commit('setShowLoadingBar', { requester, loading: true, dispatcher }) - }, - clearLoadingBarRequester({ commit }, { requester, dispatcher }) { - commit('setShowLoadingBar', { requester, loading: false, dispatcher }) - }, - clearLoadingBar4MapLoading({ commit }, { dispatcher }) { - commit('setShowLoadingBar', { - requester: MAP_LOADING_BAR_REQUESTER, - loading: false, - dispatcher, - }) - }, - setUiMode({ commit, state }, { mode, dispatcher }) { - if (mode in UIModes) { - commit('setUiMode', { mode, dispatcher }) - // As there is no possibility to trigger the fullscreen mode in desktop mode for now - if (state.fullscreenMode && mode === UIModes.DESKTOP) { - commit('setFullscreenMode', { mode: false, dispatcher }) - } - } - }, - toggleImportCatalogue({ commit, state }, { dispatcher }) { - commit('setImportCatalogue', { importCatalogue: !state.importCatalogue, dispatcher }) - }, - toggleImportFile({ commit, state }, { dispatcher }) { - commit('setImportFile', { importFile: !state.importFile, dispatcher }) - }, - setHeaderHeight({ commit }, { height, dispatcher }) { - commit('setHeaderHeight', { height: parseFloat(height), dispatcher }) - }, - setCompareRatio({ commit }, { compareRatio, dispatcher }) { - /* - This check is here to make sure the compare ratio doesn't get out of hand - The logic is, we want the compare ratio to be either in its visible range, - which is 0.001 to 0.999, and it's "storage range" (-0.001 to -0.999). If - we are not within these bounds, we revert to the default value (-0.5) - */ - if (compareRatio > 0.0 && compareRatio < 1.0) { - commit('setCompareRatio', { compareRatio, dispatcher }) - } else { - commit('setCompareRatio', { compareRatio: null, dispatcher }) - } - }, - setCompareSliderActive({ commit }, args) { - commit('setCompareSliderActive', args) - }, - setFeatureInfoPosition({ commit, state }, { position, dispatcher }) { - let featurePosition = FeatureInfoPositions[position?.toUpperCase()] - if (!featurePosition) { - log.error( - `invalid feature Info Position given as parameter. ${position} is not a valid key` - ) - return - } - // when the viewport width is too small, the layout of the floating infobox will be - // partially under the menu, making it hard to use. In those conditions, the option to - // set it as a floating tooltip is disabled. - if ( - featurePosition !== FeatureInfoPositions.NONE && - state.width < MAX_WIDTH_SHOW_FLOATING_TOOLTIP - ) { - featurePosition = FeatureInfoPositions.BOTTOMPANEL - } - if (state.featureInfoPosition === featurePosition) { - // no need to commit anything if we're trying to switch to the current value - return - } - commit('setFeatureInfoPosition', { - position: featurePosition, - dispatcher: dispatcher, - }) - }, - setTimeSliderActive({ commit }, args) { - commit('setTimeSliderActive', args) - }, - setShowDisclaimer({ commit }, { showDisclaimer, dispatcher }) { - commit('setShowDisclaimer', { showDisclaimer, dispatcher }) - }, - addErrors({ commit, state }, { errors, dispatcher }) { - if (errors instanceof Array && errors.every((error) => error instanceof ErrorMessage)) { - errors = errors.filter( - (error) => - // we only add the errors that are not existing within the store - ![...state.errors].some((otherError) => error.isEquals(otherError)) - ) - if (errors.length > 0) { - commit('addErrors', { errors, dispatcher }) - } - } else { - throw new Error( - `Error ${errors} dispatched by ${dispatcher} is not of type ErrorMessage, or not an Array of ErrorMessages` - ) - } - }, - - removeError({ commit, state }, { error, dispatcher }) { - if (!(error instanceof ErrorMessage)) { - throw new Error( - `Error ${error} dispatched by ${dispatcher} is not of type ErrorMessage` - ) - } - if (state.errors.has(error)) { - commit('removeError', { error, dispatcher }) - } - }, - addWarnings({ commit, state }, { warnings, dispatcher }) { - if ( - warnings instanceof Array && - warnings.every((warning) => warning instanceof WarningMessage) - ) { - warnings = warnings.filter( - (warning) => - // we only add the warnings that are not existing within the store - ![...state.warnings].some((otherWarning) => warning.isEqual(otherWarning)) - ) - if (warnings.length > 0) { - commit('addWarnings', { warnings, dispatcher }) - } - } else { - throw new Error( - `Warning ${warnings} dispatched by ${dispatcher} is not of type WarningMessage, or not an Array of WarningMessages` - ) - } - }, - removeWarning({ commit, state }, { warning, dispatcher }) { - if (!(warning instanceof WarningMessage)) { - throw new Error( - `Warning ${warning} dispatched by ${dispatcher} is not of type WarningMessage` - ) - } - if (state.warnings.has(warning)) { - commit('removeWarning', { warning, dispatcher }) - } - }, - setShowDragAndDropOverlay({ commit }, { showDragAndDropOverlay, dispatcher }) { - commit('setShowDragAndDropOverlay', { showDragAndDropOverlay, dispatcher }) - }, - setHideEmbedUI({ commit }, { hideEmbedUI, dispatcher }) { - commit('sethideEmbedUI', { hideEmbedUI: !!hideEmbedUI, dispatcher }) - }, - setForceNoDevSiteWarning({ commit }, { dispatcher }) { - commit('setForceNoDevSiteWarning', { dispatcher }) - }, - }, - mutations: { - setSize(state, { height, width }) { - state.height = height - state.width = width - }, - setShowMenu(state, { show }) { - state.showMenu = show - }, - setFullscreenMode(state, { mode }) { - state.fullscreenMode = mode - }, - setEmbed(state, { embed }) { - state.embed = embed - }, - setNoSimpleZoomEmbed(state, { noSimpleZoomEmbed }) { - state.noSimpleZoomEmbed = noSimpleZoomEmbed - }, - setShowLoadingBar(state, { requester, loading }) { - if (loading) { - if (!isNumber(state.loadingBarRequesters[requester])) { - state.loadingBarRequesters[requester] = 0 - } - state.loadingBarRequesters[requester] += 1 - } else { - if (state.loadingBarRequesters[requester] > 0) { - state.loadingBarRequesters[requester] -= 1 - } - if (state.loadingBarRequesters[requester] <= 0) { - delete state.loadingBarRequesters[requester] - } - } - log.debug( - `Loading bar has been set; requester=${requester}, loading=${loading}, loadingBarRequesters=`, - state.loadingBarRequesters - ) - }, - setUiMode(state, { mode }) { - state.mode = mode - }, - setImportCatalogue(state, { importCatalogue }) { - state.importCatalogue = importCatalogue - }, - setImportFile(state, { importFile }) { - state.importFile = importFile - }, - setHeaderHeight(state, { height }) { - state.headerHeight = height - }, - setCompareRatio(state, { compareRatio }) { - state.compareRatio = compareRatio - }, - setCompareSliderActive(state, { compareSliderActive }) { - state.isCompareSliderActive = compareSliderActive - }, - setTimeSliderActive(state, { timeSliderActive }) { - state.isTimeSliderActive = timeSliderActive - }, - setFeatureInfoPosition(state, { position }) { - state.featureInfoPosition = position - }, - setShowDisclaimer: (state, { showDisclaimer }) => (state.showDisclaimer = showDisclaimer), - addErrors: (state, { errors }) => { - errors.forEach((error) => state.errors.add(error)) - }, - removeError: (state, { error }) => state.errors.delete(error), - addWarnings: (state, { warnings }) => { - warnings.forEach((warning) => state.warnings.add(warning)) - }, - removeWarning: (state, { warning }) => state.warnings.delete(warning), - setShowDragAndDropOverlay: (state, { showDragAndDropOverlay }) => - (state.showDragAndDropOverlay = showDragAndDropOverlay), - sethideEmbedUI: (state, { hideEmbedUI }) => (state.hideEmbedUI = hideEmbedUI), - setForceNoDevSiteWarning: (state) => (state.forceNoDevSiteWarning = true), - }, -} diff --git a/packages/viewer/src/store/modules/ui.store.ts b/packages/viewer/src/store/modules/ui.store.ts new file mode 100644 index 0000000000..af9e336633 --- /dev/null +++ b/packages/viewer/src/store/modules/ui.store.ts @@ -0,0 +1,528 @@ +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { ErrorMessage, WarningMessage } from '@geoadmin/log/Message' +import { isNumber } from '@geoadmin/numbers' +import { defineStore } from 'pinia' + +import { BREAKPOINT_TABLET, MAX_WIDTH_SHOW_FLOATING_TOOLTIP } from '@/config/responsive.config' +import { + GIVE_FEEDBACK_HOSTNAMES, + NO_WARNING_BANNER_HOSTNAMES, + REPORT_PROBLEM_HOSTNAMES, + WARNING_RIBBON_HOSTNAMES, +} from '@/config/staging.config' + +const MAP_LOADING_BAR_REQUESTER = 'app-map-loading' + +/** + * Describes the different mode the UI can have. Either desktop / tablet (menu is always shown, info + * box is a side tray) or phone (menu has to be opened with a button, info box is a swipeable + * element) + */ +export enum UIModes { + DESKTOP, + PHONE, +} + +export enum FeatureInfoPositions { + DEFAULT = 'default', // This is not the default value, but this is the default behavior, + // which depends on the UI size. Bottompanel on phones, tooltip on desktop + BOTTOMPANEL = 'bottomPanel', + TOOLTIP = 'tooltip', + NONE = 'none', +} + +/** + * Module that stores all information related to the UI, for instance if a portion of the UI (like + * the header) should be visible right now or not. Most actions from this module will be + * used/synchronized by store plugins as it involved listening to some mutation to trigger this + * change. + */ +export interface UIState { + /** Height of the viewport (in px) */ + height: number + /** Width of the viewport (in px) */ + width: number + /** + * Flag telling if the main menu (where the layer options, layer tree and other stuff is) should + * be open. It does not tell if the menu is effectively displayed on screen, as this also + * depends on e.g. if the drawing mode is open or not. Use the getter "isMenuShown" to know + * that. + */ + showMenu: boolean + /** + * Flag telling if the app should be shown in fullscreen mode, meaning that : + * + * - The header bar should be hidden + * - The footer should be hidden + * - Tool buttons (background wheel, zoom, geolocation) should be hidden + */ + fullscreenMode: boolean + /** + * Flag telling if the app must be displayed as an embedded iFrame app (broken down / simplified + * UI) + */ + embed: boolean + /** + * Flag telling if the ctrl key is required to scroll. This is useful when the map is embedded + * in an iframe and the parent page needs to scroll. + */ + noSimpleZoomEmbed: boolean + /** + * Mapping of loading bar requesters. The loading bar on top of the screen is shown as soon as + * this mapping (object) is not empty. A requester can request several times the loading bar, + * but then it needs to clear it as many times it has set it. + */ + loadingBarRequesters: { [key: string]: number } + /** + * Current UI mode of the application, dictates how the menu interaction is made (for touch the + * menu has to be opened through a button, for desktop it is always shown) and how the + * information about a selected feature are shown. + */ + mode: UIModes + /** + * Expected position of the features tooltip position when selecting features. + * + * The default position is set to NONE, as we want people who want to share a feature without + * the tooltip to have a very simple URL. + */ + featureInfoPosition: FeatureInfoPositions + /** + * Hostname on which the application is running (use to display warnings to the user on + * 'non-production' hosts) + */ + hostname: string + /** Flag telling if import catalogue shown */ + importCatalogue: boolean + /** Flag telling if import file (map tooltip overlay or infobox) is shown */ + importFile: boolean + /** Height of the header (in px) */ + headerHeight: number + /** + * Float telling where across the screen is the compare slider. The compare Slider should only + * be shown when the value is between 0 and 1 + */ + compareRatio: number | undefined + /** Flag telling if the compare slider is currently active or not */ + isCompareSliderActive: boolean + /** Flag telling if the time slider is currently active or not */ + isTimeSliderActive: boolean + /** Flag telling if iframe marker description has disclaimer shown */ + showDisclaimer: boolean + /** Set of errors to display. Each error must be an ErrorMessage object. */ + errors: Set + /** Set of warnings to display. Each warning must be an object WarningMessage */ + warnings: Set + /** Flag telling if the "Drop file here" overlay will be displayed on top of the map. */ + showDragAndDropOverlay: boolean + /** + * Flag telling if we should hide the UI elements in the embed viewer. Zoom buttons, 3d button + * and the `view on geoadmin link`. This is only for the Geo Platform Schweiz current + * implementation and should not be disclosed to users as something they can use. + */ + hideEmbedUI: boolean + /** + * Flag used to override the dev site warning behavior. This can only be used to force the dev + * site to remove its dev-specific UI element, so this always start as false, and is only meant + * to be set to true by a press of a button in the debug menu + */ + forceNoDevSiteWarning: boolean +} + +const useUIStore = defineStore('ui', { + state: (): UIState => ({ + height: window.innerHeight, + width: window.innerWidth, + showMenu: window.innerWidth >= BREAKPOINT_TABLET, + fullscreenMode: false, + embed: false, + noSimpleZoomEmbed: false, + loadingBarRequesters: { [MAP_LOADING_BAR_REQUESTER]: 1 }, + mode: UIModes.PHONE, // Configured in screen-size-management.plugin.js (or manually in the settings) + featureInfoPosition: FeatureInfoPositions.NONE, + hostname: window.location.hostname, + importCatalogue: false, + importFile: false, + headerHeight: 100, + compareRatio: undefined, + isCompareSliderActive: false, + isTimeSliderActive: false, + showDisclaimer: true, + errors: new Set(), + warnings: new Set(), + showDragAndDropOverlay: false, + hideEmbedUI: false, + forceNoDevSiteWarning: false, + }), + getters: { + showLoadingBar(): boolean { + return Object.keys(this.loadingBarRequesters).length > 0 + }, + + screenDensity(): number { + if (this.height === 0) { + return 0 + } + return this.width / this.height + }, + + /** + * Tells if the menu tray is shown + * + * On desktop mode, the menu tray is always shown, as long as the header is shown. Clicking + * on the menu open / close button will simply minimize / maximize the menu tray. On phone + * mode, the menu tray is only shown if the menu is shown. + */ + isMenuTrayShown(): boolean { + return this.mode === UIModes.PHONE ? this.isMenuShown : this.isHeaderShown + }, + + /** + * Tells if the main menu is effectively displayed on the screen (i.e. if the menu is open + * AND visible). + * + * This is the case if the menu is in its open state and the header is shown. + */ + isMenuShown(): boolean { + return this.isHeaderShown && this.showMenu + }, + + /** Tells if the header bar is visible */ + isHeaderShown(): boolean { + // TODO: add useDrawingStore here + const isDrawingOverlayHidden = true + return !this.fullscreenMode && isDrawingOverlayShown + }, + + isPhoneMode(): boolean { + return this.mode === UIModes.PHONE + }, + + isDesktopMode(): boolean { + return this.mode === UIModes.DESKTOP + }, + + // TODO: remove redundant getter + hasNoSimpleZoomEmbedEnabled(): boolean { + return this.noSimpleZoomEmbed + }, + + // TODO: remove redundant getter + isEmbed(): boolean { + return this.embed + }, + + // TODO: remove redundant getter + isPhoneSize(): boolean { + return this.isPhoneMode() + }, + + isTabletSize(): boolean { + return this.isDesktopMode() && this.width < BREAKPOINT_TABLET + }, + + isTraditionalDesktopSize(): boolean { + return this.isDesktopMode() && this.width >= BREAKPOINT_TABLET + }, + + /** Flag to display a warning ribbon ('TEST') at the top/bottom right corner */ + hasWarningRibbon(): boolean { + return WARNING_RIBBON_HOSTNAMES.some((hostname) => this.hostname.includes(hostname)) + }, + + /** + * Flag that tells if users should be warned that it is a development site. Also used to + * hide development-specific features in production (like the app version) + */ + hasDevSiteWarning(): boolean { + return ( + !this.forceNoDevSiteWarning && + !NO_WARNING_BANNER_HOSTNAMES.some((hostname) => this.hostname.includes(hostname)) + ) + }, + + isProductionSite(): boolean { + return this.hostname === 'map.geo.admin.ch' + }, + + showFeatureInfoInTooltip(): boolean { + return ( + this.featureInfoPosition === FeatureInfoPositions.TOOLTIP || + (this.featureInfoPosition === FeatureInfoPositions.DEFAULT && !this.isPhoneMode()) + ) + }, + + showFeatureInfoInBottomPanel(): boolean { + return ( + this.featureInfoPosition === FeatureInfoPositions.BOTTOMPANEL || + (this.featureInfoPosition === FeatureInfoPositions.DEFAULT && this.isPhoneMode()) + ) + }, + + noFeatureInfo(): boolean { + return this.featureInfoPosition === FeatureInfoPositions.NONE + }, + + /** + * Flag to display to display give feedback button/form. On localhost, it will always shown + * for testing purpose + */ + hasGiveFeedbackButton(): boolean { + return GIVE_FEEDBACK_HOSTNAMES.some((hostname) => this.hostname.includes(hostname)) + }, + + /** + * Flag to display to display report problem button/form. On localhost, it will always shown + * for testing purpose + */ + hasReportProblemButton(): boolean { + return REPORT_PROBLEM_HOSTNAMES.some((hostname) => this.hostname.includes(hostname)) + }, + }, + actions: { + setSize(width: number, height: number, dispatcher: ActionDispatcher) { + this.height = height + this.width = width + + // on resize with a very narrow width, the tooltip would overlap with the right side menu + // we enforce the features information to be set into an infobox when we want to show them + // in this situation + if ( + this.featureInfoPosition !== FeatureInfoPositions.NONE && + this.width < MAX_WIDTH_SHOW_FLOATING_TOOLTIP + ) { + this.featureInfoPosition = FeatureInfoPositions.BOTTOMPANEL + } + }, + + toggleMenu(dispatcher: ActionDispatcher) { + this.showMenu = !this.showMenu + }, + + closeMenu(dispatcher: ActionDispatcher) { + this.showMenu = false + }, + + toggleFullscreenMode(dispatcher: ActionDispatcher) { + this.fullscreenMode = !this.fullscreenMode + }, + + setEmbed(embed: boolean, dispatcher: ActionDispatcher) { + this.embed = !!embed + }, + + setNoSimpleZoomEmbed(noSimpleZoomEmbed: boolean, dispatcher: ActionDispatcher) { + this.noSimpleZoomEmbed = !!noSimpleZoomEmbed + }, + + setShowLoadingBar(loading: boolean, requester: string, dispatcher: ActionDispatcher) { + if (loading) { + if (!isNumber(this.loadingBarRequesters[requester])) { + this.loadingBarRequesters[requester] = 0 + } + this.loadingBarRequesters[requester] += 1 + } else { + if (this.loadingBarRequesters[requester] > 0) { + this.loadingBarRequesters[requester] -= 1 + } + if (this.loadingBarRequesters[requester] <= 0) { + delete this.loadingBarRequesters[requester] + } + } + log.debug({ + title: 'UI store / setShowLoadingBar', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: [ + `Loading bar has been set; requester=${requester}, loading=${loading}, loadingBarRequesters=`, + this.loadingBarRequesters, + ], + }) + }, + + setLoadingBarRequester(requester: string, dispatcher: ActionDispatcher) { + this.setShowLoadingBar(true, requester, dispatcher) + }, + + clearLoadingBarRequester(requester: string, dispatcher: ActionDispatcher) { + this.setShowLoadingBar(false, requester, dispatcher) + }, + + clearLoadingBar4MapLoading(dispatcher: ActionDispatcher) { + this.setShowLoadingBar(false, MAP_LOADING_BAR_REQUESTER, dispatcher) + }, + + setUiMode(mode: UIModes, dispatcher: ActionDispatcher) { + if (mode in UIModes) { + this.mode = mode + this.fullscreenMode = false + } + }, + + toggleImportCatalogue(dispatcher: ActionDispatcher) { + this.importCatalogue = !this.importCatalogue + }, + + toggleImportFile(dispatcher: ActionDispatcher) { + this.importFile = !this.importFile + }, + + setHeaderHeight(height: number, dispatcher: ActionDispatcher) { + this.headerHeight = height + }, + + setCompareRatio(compareRatio: number, dispatcher: ActionDispatcher) { + /* + This check is here to make sure the compare ratio doesn't get out of hand + The logic is, we want the compare ratio to be either in its visible range, + which is 0.001 to 0.999, and it's "storage range" (-0.001 to -0.999). If + we are not within these bounds, we revert to the default value (-0.5) + */ + if (compareRatio > 0.0 && compareRatio < 1.0) { + this.compareRatio = compareRatio + } else { + this.compareRatio = undefined + } + }, + + setCompareSliderActive(isActive: boolean, dispatcher: ActionDispatcher) { + this.isCompareSliderActive = !!isActive + }, + + setFeatureInfoPosition(position: FeatureInfoPositions, dispatcher: ActionDispatcher) { + const featurePosition: FeatureInfoPositions = + FeatureInfoPositions[position?.toUpperCase()] + if (!featurePosition) { + log.error({ + title: 'UI store / setFeatureInfoPosition', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: [`Invalid feature info position: ${position}.`], + }) + return + } + // When the viewport width is too small, the layout of the floating infobox will be + // partially under the menu, making it hard to use. In those conditions, the option to + // set it as a floating tooltip is disabled. + if ( + featurePosition !== FeatureInfoPositions.NONE && + this.width < MAX_WIDTH_SHOW_FLOATING_TOOLTIP + ) { + this.featureInfoPosition = FeatureInfoPositions.BOTTOMPANEL + } else { + this.featureInfoPosition = featurePosition + } + }, + + setTimeSliderActive(isActive: boolean, dispatcher: ActionDispatcher) { + this.isTimeSliderActive = !!isActive + }, + + setShowDisclaimer(showDisclaimer: boolean, dispatcher: ActionDispatcher) { + this.showDisclaimer = !!showDisclaimer + }, + + addErrors(errors: ErrorMessage[], dispatcher: ActionDispatcher) { + if (Array.isArray(errors) && errors.every((error) => error instanceof ErrorMessage)) { + errors + .filter( + (error) => + // we only add the errors that are not already present in the store + ![...this.errors].some((otherError) => error.isEquals(otherError)) + ) + .forEach((error) => { + this.errors.add(error) + }) + } else { + log.error({ + title: 'UI store / addErrors', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: ['Wrong type of errors passed to addErrors', errors, dispatcher], + }) + } + }, + + removeError(error: ErrorMessage, dispatcher: ActionDispatcher) { + if (!(error instanceof ErrorMessage)) { + log.error({ + title: 'UI store / removeError', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: ['Wrong type of error passed to removeError', error, dispatcher], + }) + return + } + if (this.errors.has(error)) { + this.errors.delete(error) + } + }, + + addWarnings(warnings: WarningMessage, dispatcher: ActionDispatcher) { + if ( + Array.isArray(warnings) && + warnings.every((warning) => warning instanceof WarningMessage) + ) { + warnings + .filter( + (warning) => + // we only add the warnings that are already present in the store + ![...this.warnings].some((otherWarning) => + warning.isEquals(otherWarning) + ) + ) + .forEach((warning) => { + this.warnings.add(warning) + }) + } else { + log.error({ + title: 'UI store / addWarnings', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: [ + 'Wrong type of warnings passed to addWarnings', + warnings, + dispatcher, + ], + }) + } + }, + removeWarning(warning: WarningMessage, dispatcher: ActionDispatcher) { + if (!(warning instanceof WarningMessage)) { + log.error({ + title: 'UI store / removeWarning', + titleStyle: { + color: LogPreDefinedColor.Red, + }, + messages: [ + 'Wrong type of warning passed to removeWarning', + warning, + dispatcher, + ], + }) + return + } + if (this.warnings.has(warning)) { + this.warnings.delete(warning) + } + }, + + setShowDragAndDropOverlay(showDragAndDropOverlay: boolean, dispatcher: ActionDispatcher) { + this.showDragAndDropOverlay = !!showDragAndDropOverlay + }, + + setHideEmbedUI(hideEmbedUI: boolean, dispatcher: ActionDispatcher) { + this.hideEmbedUI = !!hideEmbedUI + }, + + setForceNoDevSiteWarning(dispatcher: ActionDispatcher) { + this.forceNoDevSiteWarning = true + }, + }, +}) + +export default useUIStore diff --git a/packages/viewer/src/store/store.ts b/packages/viewer/src/store/store.ts new file mode 100644 index 0000000000..6079831b78 --- /dev/null +++ b/packages/viewer/src/store/store.ts @@ -0,0 +1,8 @@ +/** + * To better keep track of who's the "trigger" of an action, each action comes attached with the + * name of the component (or other part of the app) that triggered the action. This will be used to + * log things. + */ +export interface ActionDispatcher { + name: string +} diff --git a/packages/viewer/src/utils/gpxUtils.js b/packages/viewer/src/utils/gpxUtils.ts similarity index 57% rename from packages/viewer/src/utils/gpxUtils.js rename to packages/viewer/src/utils/gpxUtils.ts index 2bfe1ac89b..7d96e19f85 100644 --- a/packages/viewer/src/utils/gpxUtils.js +++ b/packages/viewer/src/utils/gpxUtils.ts @@ -1,5 +1,8 @@ -import { WGS84 } from '@swissgeo/coordinates' -import { CoordinateSystem } from '@swissgeo/coordinates' +import type { Feature } from 'ol' +import type { Geometry } from 'ol/geom' + +import type { FlatExtent } from '@swissgeo/coordinates' +import { CoordinateSystem, WGS84 } from '@swissgeo/coordinates' import { gpx as gpxToGeoJSON } from '@tmcw/togeojson' import { bbox } from '@turf/turf' import { isEmpty as isExtentEmpty } from 'ol/extent' @@ -12,40 +15,40 @@ import { gpxStyles } from '@/utils/styleUtils' * * Will return null if the extent is not parsable. * - * @param {String} content GPX content as a string - * @returns {[number, number, number, number] | null} + * @param content GPX content as a string */ -export function getGpxExtent(content) { +export function getGpxExtent(content: string): FlatExtent | undefined { const parseGpx = new DOMParser().parseFromString(content, 'text/xml') const extent = bbox(gpxToGeoJSON(parseGpx)) if (isExtentEmpty(extent)) { - return null + return } - return extent + return extent as FlatExtent } /** * Parses a GPX's data into OL Features, including deserialization of features * - * @param {String} gpxData KML content to parse - * @param {CoordinateSystem} projection Projection to use for the OL Feature - * @returns {ol/Feature[]|null} List of OL Features, or null of the gpxData or projection is - * invalid/empty + * @param gpxData KML content to parse + * @param projection Projection to use for the OL Feature + * @returns List of OL Features, or null of the gpxData or projection is invalid/empty */ -export function parseGpx(gpxData, projection) { +export function parseGpx(gpxData: string, projection: CoordinateSystem): Feature[] | undefined { if (!gpxData?.length || !(projection instanceof CoordinateSystem)) { - return null + return undefined } // currently points which contain a timestamp are displayed with an offset due to a bug // therefore they are removed here as they are not needed for displaying (see PB-785) gpxData = gpxData.replace(/