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',