diff --git a/examples/snowbox/YetAnotherEmptyComponent.vue b/examples/snowbox/YetAnotherEmptyComponent.vue index affcb15908..ca48bebf73 100644 --- a/examples/snowbox/YetAnotherEmptyComponent.vue +++ b/examples/snowbox/YetAnotherEmptyComponent.vue @@ -1,7 +1,17 @@ diff --git a/examples/snowbox/debug-assistant.js b/examples/snowbox/debug-assistant.js new file mode 100644 index 0000000000..49a8b521ff --- /dev/null +++ b/examples/snowbox/debug-assistant.js @@ -0,0 +1,25 @@ +import { getStore } from '@polar/polar' + +function initializeDebugAssistant() { + const map = document.getElementById('snowbox') + const coreStore = getStore(map, 'core') + const activePluginIds = coreStore.activePluginIds + + window.map = map + window.olMap = coreStore.map + window.coreStore = coreStore + window.activePluginIds = activePluginIds + for (const pluginId of activePluginIds) { + window[`${pluginId}Store`] = coreStore.getPluginStore(pluginId) + } +} + +// We want to load as late as possible. +// Especially, the timeout stuff from snowbox code should be done when doing this. +setTimeout(() => { + initializeDebugAssistant() + + // 7 seconds may be long sometimes, inform the developer about it. + // eslint-disable-next-line no-console + console.info('POLAR debug assistant was successfully initialized') +}, 7000) diff --git a/examples/snowbox/index.html b/examples/snowbox/index.html index 79a0317df1..69c45bfbe9 100644 --- a/examples/snowbox/index.html +++ b/examples/snowbox/index.html @@ -8,6 +8,7 @@ + diff --git a/src/core/components/PolarMap.ce.vue b/src/core/components/PolarMap.ce.vue index 035057dc6a..ae01654bb7 100644 --- a/src/core/components/PolarMap.ce.vue +++ b/src/core/components/PolarMap.ce.vue @@ -31,13 +31,15 @@ import { updateDragAndZoomInteractions } from '../utils/map/updateDragAndZoomInt import PolarMapOverlay from './PolarMapOverlay.ce.vue' const mainStore = useMainStore() -const { hasWindowSize, hasSmallDisplay, center, zoom } = storeToRefs(mainStore) +const { hasWindowSize, hasSmallDisplay, center, extent, zoom } = + storeToRefs(mainStore) const polarMapContainer = useTemplateRef('polar-map-container') const overlay = useTemplateRef('polar-map-overlay') function onMove() { center.value = mainStore.map.getView().getCenter() || center.value + extent.value = mainStore.map.getView().calculateExtent() zoom.value = mainStore.map.getView().getZoom() || zoom.value } diff --git a/src/core/stores/index.ts b/src/core/stores/index.ts index c1e0be0bfd..55a6ac883e 100644 --- a/src/core/stores/index.ts +++ b/src/core/stores/index.ts @@ -4,9 +4,12 @@ */ /* eslint-enable tsdoc/syntax */ +import type { Feature } from 'ol' + import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia' import { computed } from 'vue' +import { updateSelection } from '../utils/map/setupMarkers' import { useMainStore } from './main' import { useMarkerStore } from './marker' import { useMoveHandleStore } from './moveHandle' @@ -22,13 +25,22 @@ import { usePluginStore } from './plugin' export const useCoreStore = defineStore('core', () => { const mainStore = useMainStore() const mainStoreRefs = storeToRefs(mainStore) + const moveHandleStore = useMoveHandleStore() const pluginStore = usePluginStore() const markerStore = useMarkerStore() + const markerStoreRefs = storeToRefs(markerStore) return { + /** + * Read or modify center coordinate of the map. + * + * @internal + */ + center: mainStoreRefs.center, + /** * Color scheme the client should be using. * @@ -68,6 +80,14 @@ export const useCoreStore = defineStore('core', () => { */ deviceIsHorizontal: computed(() => mainStore.deviceIsHorizontal), + /** + * Extent of the map. + * + * @alpha + * @readonly + */ + extent: computed(() => mainStore.extent), + /** * Whether the map has a maximum height of {@link SMALL_DISPLAY_HEIGHT} and * a maximum width of {@link SMALL_DISPLAY_WIDTH}. @@ -115,6 +135,22 @@ export const useCoreStore = defineStore('core', () => { */ zoom: mainStoreRefs.zoom, + /** + * Returns the layer with the given ID. + * + * @param layerId - ID of the layer + * @alpha + */ + getLayer: mainStore.getLayer, + + /** + * List of all active plugin's IDs. + * + * @readonly + * @alpha + */ + activePluginIds: computed(() => pluginStore.activePluginIds), + /** * Before instantiating the map, all required plugins have to be added. Depending on how you use POLAR, this may * already have been done. Ready-made clients (that is, packages prefixed `@polar/client-`) come with plugins prepared. @@ -168,7 +204,7 @@ export const useCoreStore = defineStore('core', () => { /** * Allows reading or setting the OIDC token used for service accesses. */ - oidcToken: mainStore.oidcToken, + oidcToken: mainStoreRefs.oidcToken, /** * Allows accessing the POLAR DOM element (``). @@ -204,6 +240,26 @@ export const useCoreStore = defineStore('core', () => { */ moveHandleTop: computed(() => moveHandleStore.top), + /** + * Feature that is hovered by the user with a marker. + * NOTE: Set _polarLayerId! + * + * @alpha + */ + hoveredFeature: markerStoreRefs.hovered, + + /** + * Feature that was selected by the user with a marker. + * + * @alpha + */ + selectedFeature: computed({ + get: () => markerStore.selected, + set: (feature) => { + updateSelection(mainStore.map, feature as Feature) + }, + }), + /** * Coordinates that were selected by the user with a marker. * diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts index 02afcbce05..eac4ea0a59 100644 --- a/src/core/stores/main.ts +++ b/src/core/stores/main.ts @@ -1,5 +1,6 @@ import type { Feature, Map } from 'ol' import type { Coordinate } from 'ol/coordinate' +import type { Extent } from 'ol/extent' import type { Point } from 'ol/geom' import { toMerged } from 'es-toolkit' @@ -27,6 +28,7 @@ export const useMainStore = defineStore('main', () => { defaults ) ) + const extent = ref([0, 0, 0, 0]) const language = ref('') const lightElement = ref(null) const map = shallowRef({} as Map) @@ -78,6 +80,10 @@ export const useMainStore = defineStore('main', () => { center.value = (feature.getGeometry() as Point).getCoordinates() } + function getLayer(layerId: string) { + return map.value.getAllLayers().find((layer) => layer.get('id') === layerId) + } + function setup() { addEventListener('resize', updateHasSmallDisplay) updateHasSmallDisplay() @@ -101,6 +107,7 @@ export const useMainStore = defineStore('main', () => { serviceRegister, shadowRoot, center, + extent, zoom, // Getters layout, @@ -110,6 +117,7 @@ export const useMainStore = defineStore('main', () => { deviceIsHorizontal, // Actions centerOnFeature, + getLayer, updateHasSmallDisplay, setup, teardown, diff --git a/src/core/stores/plugin.ts b/src/core/stores/plugin.ts index b6b0d14398..9373fe9f46 100644 --- a/src/core/stores/plugin.ts +++ b/src/core/stores/plugin.ts @@ -1,7 +1,7 @@ import { toMerged } from 'es-toolkit' import i18next from 'i18next' import { acceptHMRUpdate, defineStore } from 'pinia' -import { markRaw, reactive } from 'vue' +import { computed, markRaw, reactive } from 'vue' import type { PluginContainer, @@ -18,6 +18,8 @@ export const usePluginStore = defineStore('plugin', () => { const plugins = reactive([]) const mainStore = useMainStore() + const activePluginIds = computed(() => plugins.map((plugin) => plugin.id)) + function addPlugin(plugin: PluginContainer) { const { id, locales, options, storeModule } = plugin @@ -81,6 +83,7 @@ export const usePluginStore = defineStore('plugin', () => { return { plugins, + activePluginIds, addPlugin, removePlugin, getPluginStore, diff --git a/src/core/types/locales.ts b/src/core/types/locales.ts index 43ac237249..07ab53668e 100644 --- a/src/core/types/locales.ts +++ b/src/core/types/locales.ts @@ -2,7 +2,8 @@ import type { ResourceKey } from 'i18next' import type { BundledPluginId, BundledPluginLocaleResources } from '@/core' import type { resourcesEn as core } from '@/core/locales' -import type { CoreId } from '@/core/vuePlugins/i18next' +import type { CoreId, SharedId } from '@/core/vuePlugins/i18next' +import type { resourcesEn as shared } from '@/locales' /** @internal */ export interface Locale { @@ -12,9 +13,14 @@ export interface Locale { /** @internal */ export type LocaleResources = { - [T in typeof CoreId | BundledPluginId]: T extends BundledPluginId + [T in + | typeof CoreId + | typeof SharedId + | BundledPluginId]: T extends BundledPluginId ? BundledPluginLocaleResources - : typeof core + : T extends typeof SharedId + ? typeof shared + : typeof core } type ToLocaleOverride = T extends string diff --git a/src/core/types/plugin.ts b/src/core/types/plugin.ts index 1a26360814..c5cfa112f1 100644 --- a/src/core/types/plugin.ts +++ b/src/core/types/plugin.ts @@ -13,6 +13,9 @@ import type { useFullscreenStore as FullscreenStore } from '@/plugins/fullscreen import type { PluginId as GeoLocationPluginId } from '@/plugins/geoLocation' import type { resourcesEn as GeoLocationResources } from '@/plugins/geoLocation/locales' import type { useGeoLocationStore as GeoLocationStore } from '@/plugins/geoLocation/store' +import type { PluginId as GfiPluginId } from '@/plugins/gfi' +import type { resourcesEn as GfiResources } from '@/plugins/gfi/locales' +import type { useGfiStore as GfiStore } from '@/plugins/gfi/store' import type { PluginId as IconMenuPluginId } from '@/plugins/iconMenu' import type { resourcesEn as IconMenuResources } from '@/plugins/iconMenu/locales' import type { useIconMenuStore as IconMenuStore } from '@/plugins/iconMenu/store' @@ -36,9 +39,30 @@ import type { useToastStore as ToastStore } from '@/plugins/toast/store' import type { NineLayoutTag } from '../utils/NineLayoutTag' import type { Locale } from './locales' +/** + * Generic options for all plugins + */ export interface PluginOptions { + /** + * Should the component be visible at all. + * + * @defaultValue `false` + */ displayComponent?: boolean + + /** + * The region where the plugin should be rendered. + * Required for nine-regions layout, ignored otherwise. + */ layoutTag?: keyof typeof NineLayoutTag + + /** + * Defines if the plugin is rendered independent or as part of the icon menu. + * This is automatically set by the icon menu; you should not need to touch this. + * + * @defaultValue `'independent'` + */ + renderType?: 'independent' | 'iconMenu' } export interface BoundaryOptions { @@ -91,6 +115,7 @@ export type BundledPluginId = | typeof FooterPluginId | typeof FullscreenPluginId | typeof GeoLocationPluginId + | typeof GfiPluginId | typeof IconMenuPluginId | typeof LayerChooserPluginId | typeof LoadingIndicatorId @@ -114,6 +139,7 @@ export type BundledPluginStores = | GetPluginStore | GetPluginStore | GetPluginStore + | GetPluginStore | GetPluginStore | GetPluginStore | GetPluginStore @@ -146,6 +172,7 @@ export type BundledPluginLocaleResources = typeof GeoLocationPluginId, typeof GeoLocationResources > + | GetPluginResources | GetPluginResources | GetPluginResources< T, diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts index 9c64d54098..4831439a3e 100644 --- a/src/core/utils/map/setupMarkers.ts +++ b/src/core/utils/map/setupMarkers.ts @@ -89,7 +89,14 @@ function resolveClusterClick(map: Map, feature: Feature) { }) } -function updateSelection( +/** + * Update the selected marker in the map. + * + * @param map - Map + * @param feature - Feature to select + * @param centerOnFeature - Should the map center on the feature? + */ +export function updateSelection( map: Map, feature: Feature | null, centerOnFeature = false @@ -207,10 +214,9 @@ export function setupMarkers(map: Map) { watch( () => store.hovered, - (feature) => { - if (feature !== null && feature !== toRaw(store.selected)) { - store.hovered?.setStyle(undefined) - store.hovered = null + (feature, oldFeature) => { + if (oldFeature !== null && oldFeature !== toRaw(store.selected)) { + oldFeature.setStyle(undefined) } if (feature !== null && feature !== toRaw(store.selected)) { store.hovered = markRaw(feature) @@ -220,7 +226,7 @@ export function setupMarkers(map: Map) { .hoverStyle, isMultiFeature ) - store.hovered.setStyle(style) + feature.setStyle(style) } } ) diff --git a/src/core/vuePlugins/i18next.ts b/src/core/vuePlugins/i18next.ts index 17fee380a5..0f66fdfd97 100644 --- a/src/core/vuePlugins/i18next.ts +++ b/src/core/vuePlugins/i18next.ts @@ -4,9 +4,12 @@ import i18next from 'i18next' import LanguageDetector from 'i18next-browser-languagedetector' import I18NextVue from 'i18next-vue' +import sharedLocales from '@/locales' + import locales from '../locales' export const CoreId = 'core' +export const SharedId = 'shared' export const I18Next: Plugin = { async install(app) { @@ -28,6 +31,11 @@ export const I18Next: Plugin = { supportedLngs: locales.map(({ type }) => type), }) + // This is no plugin itself, but bundled for usage of all plugins + sharedLocales.forEach((lng) => { + i18next.addResourceBundle(lng.type, SharedId, lng.resources, true) + }) + // eslint-disable-next-line no-console console.info(`Successfully initialized i18next.`) } catch (error: unknown) { diff --git a/src/lib/getRefStore.ts b/src/lib/getRefStore.ts new file mode 100644 index 0000000000..db4b396b1d --- /dev/null +++ b/src/lib/getRefStore.ts @@ -0,0 +1,14 @@ +import type { StoreReference } from '@/core' + +import { useCoreStore } from '@/core/stores' + +/** + * Get the store for a `StoreReference`. + * + * @param ref - Store reference + * @returns Referenced store + */ +export function getRefStore(ref: StoreReference) { + const coreStore = useCoreStore() + return ref.plugin ? coreStore.getPluginStore(ref.plugin) : coreStore +} diff --git a/src/lib/getVectorSource.ts b/src/lib/getVectorSource.ts new file mode 100644 index 0000000000..487a28863c --- /dev/null +++ b/src/lib/getVectorSource.ts @@ -0,0 +1,27 @@ +import type Layer from 'ol/layer/Layer' + +import ClusterSource from 'ol/source/Cluster' +import VectorSource from 'ol/source/Vector' + +/** + * Retrieves the vector source from a layer using (possibly nested) `.getSource()` calls. + * + * @param layer - Layer to get the vector source from + * @returns Vector source + */ +export function getVectorSource(layer: Layer): VectorSource { + let source = layer.getSource() + if (!source) { + throw new Error('Could not find a vector source for this layer') + } + while (source instanceof ClusterSource) { + source = source.getSource() + if (!source) { + throw new Error('Could not find a vector source for this layer') + } + } + if (!(source instanceof VectorSource)) { + throw new Error('Could not find a vector source for this layer') + } + return source +} diff --git a/src/locales.ts b/src/locales.ts new file mode 100644 index 0000000000..071900ed54 --- /dev/null +++ b/src/locales.ts @@ -0,0 +1,59 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys for POLAR shared components. + * These locales are *NOT* exported, but documented only. + * + * @module locales/shared + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core/types' + +/** + * German locales for POLAR shared components. + * For overwriting these values, pass a partial object of this in `locales`. + */ +export const resourcesDe = { + pagination: { + currentPage: 'Aktuelle Seite, Seite {{page}}', + page: 'Seite {{page}}', + next: 'Nächste Seite', + previous: 'Vorherige Seite', + wrapper: 'Seitenauswahl', + }, +} as const + +/** + * English locales for POLAR shared components. + * For overwriting these values, pass a partial object of this in `locales`. + */ +export const resourcesEn = { + pagination: { + currentPage: 'Aktuelle Seite, Seite {{page}}', + page: 'Seite {{page}}', + next: 'Nächste Seite', + previous: 'Vorherige Seite', + wrapper: 'Seitenauswahl', + }, +} as const + +/** + * POLAR shared components locales. + * + * @privateRemarks + * The first entry will be used as fallback. + * + * @internal + */ +const locales: Locale[] = [ + { + type: 'de', + resources: resourcesDe, + }, + { + type: 'en', + resources: resourcesEn, + }, +] + +export default locales diff --git a/src/plugins/fullscreen/types.ts b/src/plugins/fullscreen/types.ts index aa1e5c3864..1e12f43c35 100644 --- a/src/plugins/fullscreen/types.ts +++ b/src/plugins/fullscreen/types.ts @@ -9,15 +9,6 @@ export const PluginId = 'fullscreen' * Plugin options for fullscreen plugin. */ export interface FullscreenPluginOptions extends PluginOptions { - /** - * Defines if the fullscreen button is rendered independent or as part of the icon menu. - * - * This is only applicable if the layout is `'nineRegions'`. - * - * @defaultValue `'independent'` - */ - renderType?: 'independent' | 'iconMenu' - /** * Defines the target container to show in fullscreen mode. * This defaults to the web component (i.e., the map with its plugin controls). diff --git a/src/plugins/geoLocation/types.ts b/src/plugins/geoLocation/types.ts index 3e99ddd847..6e96297d32 100644 --- a/src/plugins/geoLocation/types.ts +++ b/src/plugins/geoLocation/types.ts @@ -32,16 +32,6 @@ export interface GeoLocationPluginOptions extends LayerBoundPluginOptions { */ keepCentered?: boolean - /** - * Defines if the geoLocation button is rendered independent or as part of the - * icon menu. - * - * This is only applicable if the layout is `'nineRegions'`. - * - * @defaultValue `'independent'` - */ - renderType?: 'independent' | 'iconMenu' - /** * If set to `true`, a tooltip will be shown when hovering the geoposition * marker on the map, indicating that it shows the user's position. diff --git a/src/plugins/gfi/components/GfiFeature.ce.vue b/src/plugins/gfi/components/GfiFeature.ce.vue new file mode 100644 index 0000000000..07cfd067c8 --- /dev/null +++ b/src/plugins/gfi/components/GfiFeature.ce.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/plugins/gfi/components/GfiFeatureList.ce.vue b/src/plugins/gfi/components/GfiFeatureList.ce.vue new file mode 100644 index 0000000000..ba1291e255 --- /dev/null +++ b/src/plugins/gfi/components/GfiFeatureList.ce.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/plugins/gfi/components/GfiUI.ce.vue b/src/plugins/gfi/components/GfiUI.ce.vue new file mode 100644 index 0000000000..837d46db2e --- /dev/null +++ b/src/plugins/gfi/components/GfiUI.ce.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/plugins/gfi/composables/useFeatureDisplayLayer.ts b/src/plugins/gfi/composables/useFeatureDisplayLayer.ts new file mode 100644 index 0000000000..fb7e91a272 --- /dev/null +++ b/src/plugins/gfi/composables/useFeatureDisplayLayer.ts @@ -0,0 +1,86 @@ +import type { Feature as GeoJsonFeature } from 'geojson' +import type { Style } from 'ol/style' + +import { Feature, Map } from 'ol' +import { GeoJSON } from 'ol/format' +import VectorLayer from 'ol/layer/Vector' +import { Vector } from 'ol/source' +import { onScopeDispose, watch, type Ref } from 'vue' + +function getFeatureDisplayLayer() { + const featureDisplayLayer = new VectorLayer({ + source: new Vector({ + features: [], + }), + }) + + featureDisplayLayer.set('polarInternalId', 'pluginGfiFeatureDisplay') + featureDisplayLayer.setZIndex(90) + // NOTE: This may be changed in the future to not use the default styling of @masterportal/masterportalapi + featureDisplayLayer.set('styleId', 'defaultHighlightFeaturesPoint') + + return featureDisplayLayer +} + +function isVectorSource(source): source is Vector { + return source instanceof Vector +} + +/** + * reset feature layer's features. + */ +function clear(featureDisplayLayer: VectorLayer): void { + const source = featureDisplayLayer.getSource() + if (isVectorSource(source)) { + source.clear() + } +} + +/** + * add feature from jsonable GeoJson object. + */ +function addFeature( + feature: GeoJsonFeature, + featureDisplayLayer: VectorLayer +): void { + const source = featureDisplayLayer.getSource() + if (isVectorSource(source)) { + // Since ol@10, readFeature may also return a Feature[]? + source.addFeature(new GeoJSON().readFeature(feature) as Feature) + } +} + +export function useFeatureDisplayLayer(options: { + map: Map + style: Ref