diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index ac13fea773..f9ed044760 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -232,6 +232,56 @@ document.getElementById('secondMapClean').addEventListener('click', () => { document.getElementById('secondMapContainer').innerText = '' }) +addPlugin( + map, + pluginFooter({ + leftEntries: [{ id: 'mockPointer', component: MockPointerPosition }], + rightEntries: [ + pluginScale({}), + pluginAttributions({ + icons: { + close: 'kern-icon--keyboard-arrow-up', + }, + listenToChanges: [ + { + key: 'activeBackgroundId', + plugin: 'layerChooser', + }, + { + key: 'activeMaskIds', + plugin: 'layerChooser', + }, + { + key: 'zoom', + }, + ], + layerAttributions: [ + { + id: basemapId, + title: 'snowbox.attributions.basemap', + }, + { + id: basemapGreyId, + title: 'snowbox.attributions.basemapGrey', + }, + { + id: reports, + title: 'snowbox.attributions.reports', + }, + { + id: ausgleichsflaechen, + title: 'snowbox.attributions.ausgleichsflaechen', + }, + { + id: denkmal, + title: `Karte Kulturdenkmale (Denkmalliste): © Landesamt für Denkmalpflege `, + }, + ], + }), + ], + }) +) + addPlugin( map, pluginToast({ @@ -265,27 +315,22 @@ addPlugin( loaderStyle: 'BasicLoader', }) ) + addPlugin( map, - pluginAddressSearch({ - searchMethods: [ + pluginReverseGeocoder({ + url: 'https://geodienste.hamburg.de/HH_WPS', + coordinateSources: [ { - queryParameters: { - searchStreets: true, - searchHouseNumbers: true, - }, - type: 'mpapi', - url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + plugin: 'pins', + key: 'coordinate', }, ], - minLength: 3, - waitMs: 300, - focusAfterSearch: true, - groupProperties: { - defaultGroup: { - limitResults: 5, - }, + addressTarget: { + plugin: 'addressSearch', + key: 'selectResult', }, + zoomTo: 7, }) ) addPlugin( @@ -302,23 +347,6 @@ addPlugin( toZoomLevel: 7, }) ) -addPlugin( - map, - pluginReverseGeocoder({ - url: 'https://geodienste.hamburg.de/HH_WPS', - coordinateSources: [ - { - plugin: 'pins', - key: 'coordinate', - }, - ], - addressTarget: { - plugin: 'addressSearch', - key: 'selectResult', - }, - zoomTo: 7, - }) -) addPlugin( map, pluginIconMenu({ @@ -373,53 +401,28 @@ addPlugin( ], }) ) + addPlugin( map, - pluginFooter({ - leftEntries: [{ id: 'mockPointer', component: MockPointerPosition }], - rightEntries: [ - pluginScale({}), - pluginAttributions({ - icons: { - close: 'kern-icon--keyboard-arrow-up', + pluginAddressSearch({ + searchMethods: [ + { + queryParameters: { + searchStreets: true, + searchHouseNumbers: true, }, - listenToChanges: [ - { - key: 'activeBackgroundId', - plugin: 'layerChooser', - }, - { - key: 'activeMaskIds', - plugin: 'layerChooser', - }, - { - key: 'zoom', - }, - ], - layerAttributions: [ - { - id: basemapId, - title: 'snowbox.attributions.basemap', - }, - { - id: basemapGreyId, - title: 'snowbox.attributions.basemapGrey', - }, - { - id: reports, - title: 'snowbox.attributions.reports', - }, - { - id: ausgleichsflaechen, - title: 'snowbox.attributions.ausgleichsflaechen', - }, - { - id: denkmal, - title: `Karte Kulturdenkmale (Denkmalliste): © Landesamt für Denkmalpflege `, - }, - ], - }), + type: 'mpapi', + url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + }, ], + minLength: 3, + waitMs: 300, + focusAfterSearch: true, + groupProperties: { + defaultGroup: { + limitResults: 5, + }, + }, }) ) diff --git a/src/composables/usePluginStoreWatcher.ts b/src/composables/usePluginStoreWatcher.ts new file mode 100644 index 0000000000..53b2cae998 --- /dev/null +++ b/src/composables/usePluginStoreWatcher.ts @@ -0,0 +1,164 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/core/composables/usePluginStoreWatcher + */ +/* eslint-enable tsdoc/syntax */ + +import { computed, watch, type ComputedRef, type WatchHandle } from 'vue' + +import type { StoreReference } from '@/core/types' + +import { useCoreStore } from '@/core/stores' + +/** + * Configuration for a single store reference watcher. + * @internal + */ +interface WatcherConfig { + callback: (value: unknown) => void | Promise + handle: WatchHandle | null + source: StoreReference +} + +/** + * Generic composable for watching multiple plugin store references. + * + * It watches the list of installed plugins to detect when target plugins + * are added or removed, and manages the corresponding watchers accordingly. + * + * @param sources - Array of plugin store references to watch, or a computed reference to them, or a function returning them + * @param callback - Function called when any watched plugin store value changes + * + * @example + * ```typescript + * const { setupPlugin, teardownPlugin } = usePluginStoreWatcher( + * () => configuration.value.coordinateSources || [], + * (coordinate) => { + * if (coordinate) { + * await reverseGeocode(coordinate) + * } + * } + * ) + * ``` + * + * @internal + */ +export function usePluginStoreWatcher( + sources: + | StoreReference[] + | ComputedRef + | (() => StoreReference[]), + callback: (value: unknown) => void | Promise +) { + const coreStore = useCoreStore() + const sourcesArray = computed(() => { + if (typeof sources === 'function') { + return sources() + } + if ('value' in sources) { + return sources.value + } + return sources + }) + + const watchers: WatcherConfig[] = [] + let pluginListWatcher: WatchHandle | null = null + let sourcesWatcher: WatchHandle | null = null + + function setupWatcherForSource(watcherConfig: WatcherConfig) { + if (watcherConfig.handle !== null) { + return + } + + if (!watcherConfig.source.plugin) { + return + } + + const store = coreStore.getPluginStore(watcherConfig.source.plugin) + + if (!store) { + console.warn( + `usePluginStoreWatcher: "${watcherConfig.source.plugin}" not found. Cannot watch "${watcherConfig.source.key}".` + ) + return + } + + watcherConfig.handle = watch( + () => store[watcherConfig.source.key], + watcherConfig.callback + ) + } + + function removeWatcherForSource(watcherConfig: WatcherConfig) { + if (watcherConfig.handle) { + watcherConfig.handle() + watcherConfig.handle = null + } + } + + function updateWatchersBasedOnInstalledPlugins() { + const currentSources = sourcesArray.value + + watchers.forEach((watcherConfig, index) => { + if (!currentSources.some((s) => s === watcherConfig.source)) { + removeWatcherForSource(watcherConfig) + watchers.splice(index, 1) + } + }) + + currentSources.forEach((source) => { + let watcherConfig = watchers.find((w) => w.source === source) + + if (!watcherConfig) { + watcherConfig = { source, callback, handle: null } + watchers.push(watcherConfig) + } + + const pluginIsInstalled = + source.plugin && coreStore.getPluginStore(source.plugin) + + if (pluginIsInstalled && !watcherConfig.handle) { + setupWatcherForSource(watcherConfig) + } else if (!pluginIsInstalled && watcherConfig.handle) { + removeWatcherForSource(watcherConfig) + } + }) + } + + function setupPlugin() { + updateWatchersBasedOnInstalledPlugins() + + sourcesWatcher = watch(sourcesArray, () => { + updateWatchersBasedOnInstalledPlugins() + }) + + pluginListWatcher = watch( + () => coreStore.usedPlugins, + () => { + updateWatchersBasedOnInstalledPlugins() + } + ) + } + + function teardownPlugin() { + watchers.forEach((watcher) => { + removeWatcherForSource(watcher) + }) + watchers.length = 0 + + if (sourcesWatcher) { + sourcesWatcher() + sourcesWatcher = null + } + + if (pluginListWatcher) { + pluginListWatcher() + pluginListWatcher = null + } + } + + return { + setupPlugin, + teardownPlugin, + } +} diff --git a/src/core/stores/index.ts b/src/core/stores/index.ts index 1f173e286a..f018ba79c6 100644 --- a/src/core/stores/index.ts +++ b/src/core/stores/index.ts @@ -169,6 +169,13 @@ export const useCoreStore = defineStore('core', () => { */ getPluginStore: pluginStore.getPluginStore, + /** + * Returns a list of IDs of all currently installed plugins. + * + * @readonly + */ + usedPlugins: computed(() => pluginStore.plugins.map((p) => p.id)), + /** * Allows reading or setting the OIDC token used for service accesses. */ diff --git a/src/plugins/attributions/store.ts b/src/plugins/attributions/store.ts index 98e82ae7f0..355f23f6ee 100644 --- a/src/plugins/attributions/store.ts +++ b/src/plugins/attributions/store.ts @@ -5,10 +5,9 @@ /* eslint-enable tsdoc/syntax */ import { acceptHMRUpdate, defineStore } from 'pinia' -import { computed, ref, watch, type WatchHandle } from 'vue' - -import type { StoreReference } from '@/core' +import { computed, ref } from 'vue' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import { getVisibleAttributions } from '@/plugins/attributions/utils/getVisibleAttributions.ts' @@ -32,14 +31,9 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { const layers = ref([]) const windowIsOpen = ref(false) - const watchHandles = ref([]) - const configuration = computed( () => coreStore.configuration.attributions || {} ) - const listenToChanges = computed( - () => configuration.value.listenToChanges || [] - ) const mapInfo = computed(() => buildMapInfo( getVisibleAttributions(layers.value, attributions.value), @@ -59,19 +53,13 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { ) const windowWidth = computed(() => configuration.value.windowWidth || 500) + const listenersWatchers = usePluginStoreWatcher( + () => configuration.value.listenToChanges || [], + updateLayers + ) + function setupPlugin() { - // TODO: addPlugin order is still relevant if the wather is added like this - for (const listenReference of listenToChanges.value) { - const store = listenReference.plugin - ? coreStore.getPluginStore(listenReference.plugin) - : coreStore - if (!store) { - continue - } - watchHandles.value.push( - watch(() => store[listenReference.key], updateLayers) - ) - } + listenersWatchers.setupPlugin() const allLayers = coreStore.map.getLayers() allLayers.on('add', updateLayers) @@ -91,6 +79,7 @@ export const useAttributionsStore = defineStore('plugins/attributions', () => { } function teardownPlugin() { + listenersWatchers.teardownPlugin() const allLayers = coreStore.map.getLayers() allLayers.un('add', updateLayers) allLayers.un('add', updateAttributions) diff --git a/src/plugins/pins/store.ts b/src/plugins/pins/store.ts index 981c207710..82afed238a 100644 --- a/src/plugins/pins/store.ts +++ b/src/plugins/pins/store.ts @@ -16,10 +16,11 @@ import VectorLayer from 'ol/layer/Vector' import { toLonLat } from 'ol/proj' import { Vector } from 'ol/source' import { defineStore } from 'pinia' -import { computed, ref, watch, type WatchHandle } from 'vue' +import { computed, ref } from 'vue' import type { PolarGeoJsonFeature } from '@/core' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import type { PinMovable, PinsPluginOptions } from './types' @@ -77,7 +78,20 @@ export const usePinsStore = defineStore('plugins/pins', () => { configuration.value.minZoomLevel, layers: [pinLayer], }) - let coordinateSourceWatcher: WatchHandle | null = null + + const sourceWatchers = usePluginStoreWatcher( + () => configuration.value.coordinateSources || [], + (value: unknown) => { + const feature = value as PolarGeoJsonFeature | null + // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features + // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. + if (feature && feature.type !== 'reverse_geocoded') { + addPin(feature.geometry.coordinates, false, { + type: feature.geometry.type, + }) + } + } + ) function setupPlugin() { coreStore.map.addLayer(pinLayer) @@ -85,7 +99,7 @@ export const usePinsStore = defineStore('plugins/pins', () => { coreStore.map.on('singleclick', async ({ coordinate }) => { await click(coordinate) }) - setupCoordinateSource() + sourceWatchers.setupPlugin() setupInitial() setupInteractions() } @@ -99,37 +113,7 @@ export const usePinsStore = defineStore('plugins/pins', () => { map.removeLayer(pinLayer) map.removeInteraction(move) map.removeInteraction(translate) - if (coordinateSourceWatcher) { - coordinateSourceWatcher() - } - } - - function setupCoordinateSource() { - const { coordinateSources } = configuration.value - if (!coordinateSources) { - return - } - coordinateSources.forEach((source) => { - const store = source.plugin - ? coreStore.getPluginStore(source.plugin) - : coreStore - if (!store) { - return - } - // redo pin if source (e.g. from addressSearch) changes - coordinateSourceWatcher = watch( - () => store[source.key], - (feature: PolarGeoJsonFeature | null) => { - // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features - // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. - if (feature && feature.type !== 'reverse_geocoded') { - addPin(feature.geometry.coordinates, false, { - type: feature.geometry.type, - }) - } - } - ) - }) + sourceWatchers.teardownPlugin() } function setupInitial() { diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index 31ac5cbdc4..a5b2bbcd5a 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -9,8 +9,9 @@ import type { Mock } from 'vitest' import { easeOut } from 'ol/easing' import { Point } from 'ol/geom' import { acceptHMRUpdate, defineStore } from 'pinia' -import { computed, ref, watch, type Reactive, type WatchHandle } from 'vue' +import { computed, type Reactive } from 'vue' +import { usePluginStoreWatcher } from '@/composables/usePluginStoreWatcher' import { useCoreStore } from '@/core/stores' import { indicateLoading } from '@/lib/indicateLoading' @@ -37,34 +38,22 @@ export const useReverseGeocoderStore = defineStore( () => coreStore.configuration[PluginId] as ReverseGeocoderPluginOptions ) - const watchHandles = ref([]) - - function setupPlugin() { - for (const source of configuration.value.coordinateSources || []) { - const store = source.plugin - ? coreStore.getPluginStore(source.plugin) - : coreStore - if (!store) { - continue + const sourceWatchers = usePluginStoreWatcher( + () => configuration.value.coordinateSources || [], + async (value: unknown) => { + const coordinate = value as [number, number] | null + if (coordinate) { + await reverseGeocode(coordinate) } - watchHandles.value.push( - watch( - () => store[source.key], - async (coordinate) => { - if (coordinate) { - await reverseGeocode(coordinate) - } - }, - { immediate: true } - ) - ) } + ) + + function setupPlugin() { + sourceWatchers.setupPlugin() } function teardownPlugin() { - watchHandles.value.forEach((handle) => { - handle() - }) + sourceWatchers.teardownPlugin() } function passFeatureToTarget( @@ -160,11 +149,16 @@ if (import.meta.vitest) { coreStore: [ async ({}, use) => { const fit = vi.fn() + const pluginStores = { + pins: reactive({ + coordinate: null, + }), + } const coreStore = reactive({ configuration: { [PluginId]: { url: 'https://wps.example', - coordinateSources: [{ key: 'coordinateSource' }], + coordinateSources: [{ plugin: 'pins', key: 'coordinate' }], addressTarget: { key: 'addressTarget' }, zoomTo: 99, }, @@ -172,12 +166,12 @@ if (import.meta.vitest) { map: { getView: () => ({ fit }), }, - coordinateSource: null, addressTarget: vi.fn(), + getPluginStore: (plugin) => pluginStores[plugin] || null, }) // @ts-expect-error | Mocking useCoreStore vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore) - await use(coreStore) + await use({ ...coreStore, pluginStores }) }, { auto: true }, ], @@ -198,7 +192,12 @@ if (import.meta.vitest) { reverseGeocodeUtil, coreStore, }) => { - coreStore.coordinateSource = [1, 2] + const pluginStore = coreStore.pluginStores as { + pins: { + coordinate: [number, number] | null + } + } + pluginStore.pins.coordinate = [1, 2] await new Promise((resolve) => setTimeout(resolve)) expect(reverseGeocodeUtil).toHaveBeenCalledWith( 'https://wps.example',