- ): void
-
- dispatch: Dispatch
- commit: Commit
- subscribe(
- // TODO: check if type any is valid
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- fn: (mutation: P, state: S) => any,
- options?: SubscribeOptions
- ): () => void
-
- subscribeAction
(
- fn: SubscribeActionOptions
,
- options?: SubscribeOptions
- ): () => void
-
- watch(
- // TODO: check if type any is valid
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- getter: (state: S, getters: any) => T,
- cb: (value: T, oldValue: T) => void,
- options?: WatchOptions
- ): () => void
-
- unregisterModule(path: string | string[]): void
-
- hasModule(path: string | string[]): boolean
- hotUpdate(options: {
- actions?: PolarActionTree
- mutations?: MutationTree
- getters?: PolarGetterTree
- modules?: PolarModuleTree
- }): void
-}
-
-/**
- * Copied from https://stackoverflow.com/a/54178819.#
- *
- * Makes the properties defined by type `K` optional in type `T`.
- *
- * Example: PartialBy\
- */
-export type PartialBy = Omit & Partial>
diff --git a/patches/@masterportal+masterportalapi+2.54.0.patch b/patches/@masterportal+masterportalapi+2.54.0.patch
new file mode 100644
index 000000000..b75355554
--- /dev/null
+++ b/patches/@masterportal+masterportalapi+2.54.0.patch
@@ -0,0 +1,42 @@
+diff --git a/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/fixedOverlaySynchronizer.js b/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/fixedOverlaySynchronizer.js
+index 6613447..cc83711 100644
+--- a/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/fixedOverlaySynchronizer.js
++++ b/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/fixedOverlaySynchronizer.js
+@@ -1,4 +1,4 @@
+-import OverlaySynchronizer from "olcs/lib/olcs/OverlaySynchronizer.js";
++import OverlaySynchronizer from "olcs/OverlaySynchronizer";
+
+ /**
+ * Represents a FixedOverlaySynchronizer.
+diff --git a/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/wmsRasterSynchronizer.js b/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/wmsRasterSynchronizer.js
+index aeebfaf..de492e6 100644
+--- a/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/wmsRasterSynchronizer.js
++++ b/node_modules/@masterportal/masterportalapi/src/maps/olcs/3dUtils/wmsRasterSynchronizer.js
+@@ -2,12 +2,12 @@
+ * @module olcs.WMSRasterSynchronizer
+ */
+ import olLayerGroup from "ol/layer/Group.js";
+-import {getUid} from "olcs/lib/olcs/util.js";
++import {getUid} from "olcs/util";
+ import TileWMS from "ol/source/TileWMS.js";
+ import ImageWMS from "ol/source/ImageWMS.js";
+ import WMTS from "ol/source/WMTS.js";
+-import AbstractSynchronizer from "olcs/lib/olcs/AbstractSynchronizer.js";
+-import {extentToRectangle, tileLayerToImageryLayer, updateCesiumLayerProperties} from "olcs/lib/olcs/core.js";
++import AbstractSynchronizer from "olcs/AbstractSynchronizer";
++import {extentToRectangle, tileLayerToImageryLayer, updateCesiumLayerProperties} from "olcs/core";
+ import {Tile, Image as ImageLayer} from "ol/layer.js";
+ import {stableSort} from "ol/array.js";
+ import {getBottomLeft, getBottomRight, getTopRight, getTopLeft} from "ol/extent.js";
+diff --git a/node_modules/@masterportal/masterportalapi/src/maps/olcs/olcsMap.js b/node_modules/@masterportal/masterportalapi/src/maps/olcs/olcsMap.js
+index 2192925..685cc9a 100644
+--- a/node_modules/@masterportal/masterportalapi/src/maps/olcs/olcsMap.js
++++ b/node_modules/@masterportal/masterportalapi/src/maps/olcs/olcsMap.js
+@@ -1,6 +1,6 @@
+ import OLCesium from "olcs";
+ import {transform, get} from "ol/proj.js";
+-import VectorSynchronizer from "olcs/lib/olcs/VectorSynchronizer.js";
++import VectorSynchronizer from "olcs/VectorSynchronizer";
+
+ import FixedOverlaySynchronizer from "./3dUtils/fixedOverlaySynchronizer.js";
+ import WMSRasterSynchronizer from "./3dUtils/wmsRasterSynchronizer.js";
diff --git a/scripts/build-masterportalapi-patch.sh b/scripts/build-masterportalapi-patch.sh
new file mode 100755
index 000000000..4f4964f75
--- /dev/null
+++ b/scripts/build-masterportalapi-patch.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+find node_modules/@masterportal/masterportalapi/ -type f -name '*.js' -print0 | \
+ xargs -0 sed -i 's|olcs/lib/olcs/\([a-zA-Z]\+\)\.js|olcs/\1|g'
diff --git a/scripts/clean.js b/scripts/clean.js
deleted file mode 100644
index 1b68eab33..000000000
--- a/scripts/clean.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/* eslint-disable no-console */
-const { exec } = require('child_process') // eslint-disable-line
-const os = require('os') // eslint-disable-line
-
-const isWindows = os.platform() === 'win32'
-
-async function clean() {
- if (isWindows) {
- await exec('rmdir /s /q node_modules')
- console.log('node_modules were purged.')
- return
- }
- await exec('rm -rf node_modules')
- console.log('node_modules were purged.')
-}
-clean()
diff --git a/scripts/clean.ts b/scripts/clean.ts
new file mode 100644
index 000000000..b9f80b8fa
--- /dev/null
+++ b/scripts/clean.ts
@@ -0,0 +1,7 @@
+import fs from 'node:fs/promises'
+
+await Promise.all(
+ ['.cache', 'dist', 'docs-html', 'node_modules'].map(async (dir) => {
+ await fs.rm(dir, { recursive: true, force: true })
+ })
+)
diff --git a/scripts/create-github-release.ts b/scripts/create-github-release.ts
new file mode 100644
index 000000000..9fdf1eac9
--- /dev/null
+++ b/scripts/create-github-release.ts
@@ -0,0 +1,24 @@
+import { readFileSync } from 'node:fs'
+import { getOctokit, context } from '@actions/github'
+if (!process.env.GITHUB_TOKEN) {
+ process.stderr.write('fatal: No GitHub token provided')
+ process.exit(1)
+}
+
+const github = getOctokit(process.env.GITHUB_TOKEN)
+const { owner, repo } = context.repo
+
+const packageJson = JSON.parse(readFileSync('package.json').toString())
+const packageName = packageJson.name
+const packageVersion = packageJson.version
+
+let body = ''
+body += `[NPM package](https://www.npmjs.com/package/${packageName}/v/${packageVersion})`
+
+await github.rest.repos.createRelease({
+ owner,
+ repo,
+ tag_name: `v${packageVersion}`,
+ name: `Version ${packageVersion}`,
+ body,
+})
diff --git a/scripts/createRelease.js b/scripts/createRelease.js
deleted file mode 100644
index dbd4b990f..000000000
--- a/scripts/createRelease.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/* eslint-disable @typescript-eslint/no-var-requires */
-
-const fs = require('fs')
-const { getOctokit, context } = require('@actions/github')
-
-const tags = process.argv.slice(2)
-const github = getOctokit(process.env.GITHUB_TOKEN)
-const { owner, repo } = context.repo
-
-const camelize = (strings, all = false) =>
- (all ? '' : strings[0]) +
- strings
- .slice(all ? 0 : 1)
- .map((string) => string.charAt(0).toUpperCase() + string.slice(1))
- .join('')
-
-function getBody(tag) {
- const [packageName, packageVersion] = tag.split('@').slice(1)
- const name = packageName.split('/')[1]
- const nameParts = name.split('-')
- let filePath
-
- if (nameParts[0] === 'core' || nameParts[0] === 'components') {
- filePath = `./packages/${nameParts[0]}`
- } else if (name === 'lib-custom-types') {
- filePath = `./packages/types/custom`
- } else if (nameParts[0] === 'plugin' || nameParts[0] === 'client') {
- filePath = `./packages/${nameParts[0]}s/${camelize(
- nameParts.slice(1),
- nameParts[0] === 'plugin'
- )}`
- } else if (nameParts[0] === 'lib') {
- filePath = `./packages/${nameParts[0]}/${camelize(nameParts.slice(1))}`
- } else {
- const message = `Unknown package name in tag ${tag}.`
- console.error(message)
- process.exit = 1
- throw new Error(message)
- }
- const data = fs.readFileSync(`${filePath}/CHANGELOG.md`, { encoding: 'utf8' })
- return `## CHANGELOG
-${data
- .split('##')[1]
- .split('\n')
- .slice(1)
- .join(
- '\n'
- )}[NPM package](https://www.npmjs.com/package/@${packageName}/v/${packageVersion})`
-}
-
-for (const tag of tags) {
- github.request(`POST /repos/${owner}/${repo}/releases`, {
- owner,
- repo,
- tag_name: tag,
- name: tag,
- body: getBody(tag),
- headers: {
- 'X-GitHub-Api-Version': '2022-11-28',
- },
- })
-}
diff --git a/scripts/typedoc-install-vue-tsc.ts b/scripts/typedoc-install-vue-tsc.ts
new file mode 100644
index 000000000..87f453af9
--- /dev/null
+++ b/scripts/typedoc-install-vue-tsc.ts
@@ -0,0 +1,42 @@
+// This script is heavily experimental.
+// It requires that dev mode was started at least once with vite-plugin-checker enabled.
+
+import { cpSync, existsSync, mkdirSync } from 'node:fs'
+import { resolve, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const basePath = resolve(__dirname, '..')
+
+const vueTsPath = resolve(
+ basePath,
+ 'node_modules',
+ 'vite-plugin-checker',
+ 'dist',
+ 'checkers',
+ 'vueTsc',
+ 'typescript-vue-tsc'
+)
+
+const targetTsParentPath = resolve(
+ basePath,
+ 'node_modules',
+ 'typedoc',
+ 'node_modules'
+)
+
+const targetTsPath = resolve(targetTsParentPath, 'typescript')
+
+if (!existsSync(targetTsParentPath)) {
+ mkdirSync(targetTsParentPath, {
+ recursive: true,
+ })
+}
+
+if (existsSync(targetTsPath)) {
+ // VueTS was already installed.
+ process.exit(0)
+}
+
+cpSync(vueTsPath, targetTsPath, {
+ recursive: true,
+})
diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts
new file mode 100644
index 000000000..b00895864
--- /dev/null
+++ b/src/@types/i18next.d.ts
@@ -0,0 +1,9 @@
+import 'i18next'
+import type { LocaleResources } from '@/core'
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ enableSelector: true
+ resources: LocaleResources
+ }
+}
diff --git a/src/@types/pinia.d.ts b/src/@types/pinia.d.ts
new file mode 100644
index 000000000..7a5824963
--- /dev/null
+++ b/src/@types/pinia.d.ts
@@ -0,0 +1,16 @@
+import 'pinia'
+import type { Pinia } from 'pinia'
+
+declare module 'pinia' {
+ export interface PiniaCustomProperties {
+ /* eslint-disable @typescript-eslint/naming-convention */
+
+ /**
+ * @privateRemarks
+ * Using the saveInstance plugin, the pinia instance is auto-saved here.
+ */
+ _instance: Pinia
+
+ /* eslint-enable @typescript-eslint/naming-convention */
+ }
+}
diff --git a/src/@types/shims-masterportalapi.d.ts b/src/@types/shims-masterportalapi.d.ts
new file mode 100644
index 000000000..26400c1e8
--- /dev/null
+++ b/src/@types/shims-masterportalapi.d.ts
@@ -0,0 +1 @@
+declare module '@masterportal/masterportalapi/*'
diff --git a/src/@types/virtual-kern-extra-icons.d.ts b/src/@types/virtual-kern-extra-icons.d.ts
new file mode 100644
index 000000000..9514c6714
--- /dev/null
+++ b/src/@types/virtual-kern-extra-icons.d.ts
@@ -0,0 +1,4 @@
+declare module 'virtual:kern-extra-icons' {
+ declare const sheet: CSSStyleSheet
+ export = sheet
+}
diff --git a/src/@types/vite-env.d.ts b/src/@types/vite-env.d.ts
new file mode 100644
index 000000000..470ffd199
--- /dev/null
+++ b/src/@types/vite-env.d.ts
@@ -0,0 +1,5 @@
+///
+
+interface ViteTypeOptions {
+ strictImportMetaEnv: unknown
+}
diff --git a/src/architecture.spec.ts b/src/architecture.spec.ts
new file mode 100644
index 000000000..b2cface75
--- /dev/null
+++ b/src/architecture.spec.ts
@@ -0,0 +1,61 @@
+import { resolve } from 'node:path'
+import { beforeAll, describe, expect, test } from 'vitest'
+import { FileConditionBuilder, filesOfProject } from 'tsarch'
+
+describe('Architectural checks', () => {
+ let files: FileConditionBuilder
+
+ beforeAll(() => {
+ files = filesOfProject(resolve(__dirname, 'tsconfig.json'))
+ })
+
+ test('POLAR should be cycle-free', async () => {
+ const violations = await files
+ .matchingPattern('.*')
+ .should()
+ .beFreeOfCycles()
+ .check()
+ expect(violations).toEqual([])
+ })
+
+ test('Core should not depend on plugins (except for plugin types)', async () => {
+ const violations = await files
+ .matchingPattern('^core/(?!types/.*\\.ts$).*$')
+ .shouldNot()
+ .dependOnFiles()
+ .matchingPattern('^plugins/.*$')
+ .check()
+ expect(violations).toEqual([])
+ })
+
+ test('Plugin file structure', async () => {
+ const violations = await files
+ .matchingPattern('^plugins/.*$')
+ .should()
+ .matchPattern(
+ '^plugins/[^/]+/((index|locales|store|types)\\.ts|utils/.*\\.ts|components/.*\\.spec\\.ts)$'
+ )
+ .check()
+ expect(violations).toEqual([])
+ })
+
+ test('Plugins should only depend on public core API', async () => {
+ const violations = await files
+ .matchingPattern('^plugins/.*$')
+ .shouldNot()
+ .dependOnFiles()
+ .matchingPattern('^core/(?!(index|stores/export)\\.ts$).*$')
+ .check()
+ expect(violations).toEqual([])
+ })
+
+ test('Lib utils should only depend on public core API', async () => {
+ const violations = await files
+ .matchingPattern('^lib/.*$')
+ .shouldNot()
+ .dependOnFiles()
+ .matchingPattern('^core/(?!(index|stores/export)\\.ts$).*$')
+ .check()
+ expect(violations).toEqual([])
+ })
+})
diff --git a/src/components/PolarCard.ce.vue b/src/components/PolarCard.ce.vue
new file mode 100644
index 000000000..80cbbaba8
--- /dev/null
+++ b/src/components/PolarCard.ce.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/PolarIconButton.ce.vue b/src/components/PolarIconButton.ce.vue
new file mode 100644
index 000000000..fac5f24b8
--- /dev/null
+++ b/src/components/PolarIconButton.ce.vue
@@ -0,0 +1,97 @@
+
+
+
+ {{ hint }}
+
+ {{ hint }}
+
+
+
+
+
+
+
diff --git a/src/components/PolarInput.ce.vue b/src/components/PolarInput.ce.vue
new file mode 100644
index 000000000..e19c8d3fd
--- /dev/null
+++ b/src/components/PolarInput.ce.vue
@@ -0,0 +1,54 @@
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
diff --git a/src/components/PolarInputGroup.ce.vue b/src/components/PolarInputGroup.ce.vue
new file mode 100644
index 000000000..e94040d11
--- /dev/null
+++ b/src/components/PolarInputGroup.ce.vue
@@ -0,0 +1,41 @@
+
+
+
+ {{ legend }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/.polar-dev.css b/src/core/.polar-dev.css
new file mode 100644
index 000000000..1ea174fa7
--- /dev/null
+++ b/src/core/.polar-dev.css
@@ -0,0 +1 @@
+/* This file is left empty on purpose. */
\ No newline at end of file
diff --git a/src/core/components/MoveHandle.ce.vue b/src/core/components/MoveHandle.ce.vue
new file mode 100644
index 000000000..9b51935d2
--- /dev/null
+++ b/src/core/components/MoveHandle.ce.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+ {{ closeLabel }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/components/PolarContainer.ce.vue b/src/core/components/PolarContainer.ce.vue
new file mode 100644
index 000000000..3bcecdc70
--- /dev/null
+++ b/src/core/components/PolarContainer.ce.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/components/PolarMap.ce.vue b/src/core/components/PolarMap.ce.vue
new file mode 100644
index 000000000..98ead4c3c
--- /dev/null
+++ b/src/core/components/PolarMap.ce.vue
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/core/components/PolarMapOverlay.ce.vue b/src/core/components/PolarMapOverlay.ce.vue
new file mode 100644
index 000000000..81f00b5a3
--- /dev/null
+++ b/src/core/components/PolarMapOverlay.ce.vue
@@ -0,0 +1,91 @@
+
+
+
+ {{ message }}
+
+
+
+
+
+
+
diff --git a/src/core/components/PolarUI.ce.vue b/src/core/components/PolarUI.ce.vue
new file mode 100644
index 000000000..e6c44bca4
--- /dev/null
+++ b/src/core/components/PolarUI.ce.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/core/components/layouts/NineLayout.ce.vue b/src/core/components/layouts/NineLayout.ce.vue
new file mode 100644
index 000000000..294932196
--- /dev/null
+++ b/src/core/components/layouts/NineLayout.ce.vue
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/core/components/layouts/StandardLayout.ce.vue b/src/core/components/layouts/StandardLayout.ce.vue
new file mode 100644
index 000000000..1132454a1
--- /dev/null
+++ b/src/core/components/layouts/StandardLayout.ce.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/src/core/composables/useT.ts b/src/core/composables/useT.ts
new file mode 100644
index 000000000..695deedc3
--- /dev/null
+++ b/src/core/composables/useT.ts
@@ -0,0 +1,19 @@
+import i18next from 'i18next'
+import { ref, onMounted, onUnmounted, type Ref } from 'vue'
+
+export function useT(translator: () => string): Ref {
+ const message = ref(translator())
+
+ const onLanguageChanged = () => {
+ message.value = translator()
+ }
+
+ onMounted(() => {
+ i18next.on('languageChanged', onLanguageChanged)
+ })
+ onUnmounted(() => {
+ i18next.off('languageChanged', onLanguageChanged)
+ })
+
+ return message
+}
diff --git a/src/core/index.ts b/src/core/index.ts
new file mode 100644
index 000000000..1c10d54b0
--- /dev/null
+++ b/src/core/index.ts
@@ -0,0 +1,48 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the main export for the NPM package \@polar/polar.
+ *
+ * Lost? You probably want to start at {@link createMap}.
+ *
+ * @packageDocumentation
+ * @module \@polar/polar
+ */
+/* eslint-enable tsdoc/syntax */
+
+import '@kern-ux/native/dist/fonts/fira-sans.css'
+import { defineCustomElement } from 'vue'
+import PolarContainer from './components/PolarContainer.ce.vue'
+import { I18Next } from './vuePlugins/i18next'
+import { Pinia } from './vuePlugins/pinia'
+
+import './monkeyHeaderLoader'
+
+/**
+ * Custom element of the POLAR map.
+ *
+ * You will probably need this to have TypeScript support on `polar-map` elements
+ * if you want to do so.
+ */
+export const PolarMap = defineCustomElement(PolarContainer, {
+ configureApp(app) {
+ app.use(Pinia)
+ app.use(I18Next)
+ },
+})
+
+/**
+ * Registers the custom element for POLAR (i.e., `polar-map`).
+ *
+ * This has to be called before using POLAR in any way.
+ */
+export function register() {
+ customElements.define('polar-map', PolarMap)
+}
+
+export { fetchServiceRegister } from './utils/fetchServiceRegister'
+export * from './utils/export/createMap'
+export * from './utils/export/plugin'
+export * from './utils/export/store'
+
+export type * from './types'
+export { type PolarContainer }
diff --git a/src/core/locales.ts b/src/core/locales.ts
new file mode 100644
index 000000000..4855188f7
--- /dev/null
+++ b/src/core/locales.ts
@@ -0,0 +1,70 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in POLAR core.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/core
+ */
+/* eslint-enable tsdoc/syntax */
+
+import type { Locale } from './types'
+
+/**
+ * German locales for POLAR core.
+ * For overwriting these values, pass a partial object of this in `locales`.
+ */
+export const resourcesDe = {
+ canvas: {
+ label: 'Kartenanwendung',
+ },
+ error: {
+ serviceUnavailable:
+ 'Der Kartendienst "{{serviceName}}" (ID: {{serviceId}}) ist derzeit nicht verfügbar. Dies kann die Funktionalität der Karte einschränken.',
+ },
+ overlay: {
+ noControlOnZoom: 'Verwenden Sie Strg+Scrollen zum Zoomen der Karte',
+ noCommandOnZoom: 'Verwenden Sie Command ⌘ + Scrollen zum Zoomen der Karte',
+ oneFingerPan:
+ 'Verwenden Sie mindestens zwei Finger zum Verschieben der Karte',
+ },
+} as const
+
+/**
+ * English locales for POLAR core.
+ * For overwriting these values, pass a partial object of this in `locales`.
+ */
+export const resourcesEn = {
+ canvas: {
+ label: 'Map application',
+ },
+ error: {
+ serviceUnavailable:
+ 'Service "{{serviceName}}" (ID: {{serviceId}}) is unavailable. This may limit the map\'s functionality.',
+ },
+ overlay: {
+ noControlOnZoom: 'Use Ctrl+Mousewheel to zoom into the map',
+ noCommandOnZoom: 'Use Command ⌘ + Mousewheel to zoom into the map',
+ oneFingerPan: 'Use at least two fingers to pan the map',
+ },
+} as const
+
+/**
+ * Core 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/core/monkeyHeaderLoader.ts b/src/core/monkeyHeaderLoader.ts
new file mode 100644
index 000000000..e3b0ef8e0
--- /dev/null
+++ b/src/core/monkeyHeaderLoader.ts
@@ -0,0 +1,61 @@
+import { type ImageTile, Map } from 'ol'
+import TileLayer from 'ol/layer/Tile'
+import { type TileWMS } from 'ol/source'
+
+// NOTE: This monkey patch allows url parameters of tiled WMS layers to become headers if used like `{key=value}`
+
+const headerRegex = /{(?[^=]+)=(?[^}]+)}/gm
+
+/**
+ * A header is defined by `{key=value}` as part of the configured url of a service.
+ * Note, that the parenthesis are necessary.
+ */
+function customLoader(tile: ImageTile, url: string) {
+ const headers: HeadersInit = {}
+ const src = url.replaceAll(headerRegex, (_, key: string, value: string) => {
+ headers[key] = value
+ return ''
+ })
+
+ fetch(src, { method: 'GET', headers })
+ .then((response) =>
+ response.ok
+ ? response.blob()
+ : response.text().then((msg) => {
+ throw new Error(msg)
+ })
+ )
+ .then((blob) => {
+ ;(tile.getImage() as HTMLImageElement).src = URL.createObjectURL(blob)
+ })
+ .catch((e: unknown) => {
+ console.error(e)
+ })
+}
+
+// Original addLayer method
+// eslint-disable-next-line @typescript-eslint/unbound-method
+const originalAddLayer = Map.prototype.addLayer
+// Monkey patch
+Map.prototype.addLayer = function (...parameters) {
+ // Add layer to map
+ originalAddLayer.call(this, ...parameters)
+ Map.prototype.getLayers
+ .call(this)
+ .getArray()
+ .forEach((layer) => {
+ if (!(layer instanceof TileLayer)) {
+ return
+ }
+ const source = layer.getSource() as TileWMS
+ // @ts-expect-error | urls is accessible and not protected here.
+ const headerRequired = source.urls?.some((url: string) =>
+ headerRegex.test(url)
+ )
+ if (headerRequired && typeof source.setTileLoadFunction === 'function') {
+ // @ts-expect-error | only ImageTiles reach this point, generic class Tile is not used.
+ source.setTileLoadFunction(customLoader)
+ layer.setSource(source)
+ }
+ })
+}
diff --git a/src/core/piniaPlugins/actionLogger.ts b/src/core/piniaPlugins/actionLogger.ts
new file mode 100644
index 000000000..d49c4240d
--- /dev/null
+++ b/src/core/piniaPlugins/actionLogger.ts
@@ -0,0 +1,17 @@
+import type { PiniaPluginContext } from 'pinia'
+
+export function actionLogger({ store }: PiniaPluginContext) {
+ if (import.meta.env.DEV) {
+ /* eslint-disable no-console */
+ console.log('DEV MODE DETECTED - PINIA LOGGING ENABLED')
+ store.$onAction(
+ ({ name, store, args }) => {
+ console.log(
+ `Action: '${name}'; Store: '${store.$id}'; Arguments:`,
+ args
+ )
+ }
+ /* eslint-enable no-console */
+ )
+ }
+}
diff --git a/src/core/piniaPlugins/saveInstance.ts b/src/core/piniaPlugins/saveInstance.ts
new file mode 100644
index 000000000..8806eda9c
--- /dev/null
+++ b/src/core/piniaPlugins/saveInstance.ts
@@ -0,0 +1,5 @@
+import type { PiniaPluginContext } from 'pinia'
+
+export function saveInstance({ pinia, store }: PiniaPluginContext) {
+ store._instance = pinia
+}
diff --git a/src/core/stores/export.ts b/src/core/stores/export.ts
new file mode 100644
index 000000000..da0f68f77
--- /dev/null
+++ b/src/core/stores/export.ts
@@ -0,0 +1,227 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia'
+import { computed } from 'vue'
+import { useMainStore } from './main'
+import { usePluginStore } from './plugin'
+import { useMarkerStore } from './marker'
+import { useMoveHandleStore } from './moveHandle'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Core store of POLAR.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useCoreStore = defineStore('core', () => {
+ const mainStore = useMainStore()
+ const mainStoreRefs = storeToRefs(mainStore)
+ const moveHandleStore = useMoveHandleStore()
+
+ const pluginStore = usePluginStore()
+
+ const markerStore = useMarkerStore()
+
+ return {
+ /**
+ * Color scheme the client should be using.
+ *
+ * @internal
+ */
+ colorScheme: mainStoreRefs.colorScheme,
+
+ /**
+ * The current height of the map.
+ *
+ * @alpha
+ * @readonly
+ */
+ clientHeight: computed(() => mainStore.clientHeight),
+
+ /**
+ * Returns the current runtime configuration.
+ *
+ * @readonly
+ */
+ configuration: computed(() => mainStore.configuration),
+
+ /**
+ * Whether a mobile device is held horizontally.
+ * True if {@link hasSmallHeight} and {@link hasWindowSize} are true.
+ *
+ * @alpha
+ * @readonly
+ */
+ deviceIsHorizontal: computed(() => mainStore.deviceIsHorizontal),
+
+ /**
+ * Whether the map has a maximum height of {@link SMALL_DISPLAY_HEIGHT} and
+ * a maximum width of {@link SMALL_DISPLAY_WIDTH}.
+ *
+ * @alpha
+ * @readonly
+ */
+ hasSmallDisplay: computed(() => mainStore.hasSmallDisplay),
+
+ /**
+ * Whether the height of the map is smaller than 480px.
+ *
+ * @alpha
+ * @readonly
+ */
+ hasSmallHeight: computed(() => mainStore.hasSmallHeight),
+
+ /**
+ * Whether the width of the map is smaller than 768px.
+ *
+ * @alpha
+ * @readonly
+ */
+ hasSmallWidth: computed(() => mainStore.hasSmallWidth),
+
+ /**
+ * Whether the size of the map equals the size of the browser window.
+ *
+ * @alpha
+ * @readonly
+ */
+ hasWindowSize: computed(() => mainStore.hasWindowSize),
+
+ /**
+ * Configured language.
+ *
+ * @internal
+ */
+ language: mainStoreRefs.language,
+
+ /**
+ * 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.
+ *
+ * You may add further plugins.
+ *
+ * Please note that the order of certain plugins is relevant when other plugins are referenced,
+ * e.g. `@polar/plugin-gfi`'s `coordinateSources` requires the configured sources to have previously been set up.
+ *
+ * In case you're integrating new plugins, call `addPlugin` with a plugin instance.
+ *
+ * @example
+ * ```
+ * addPlugin(Plugin(pluginOptions: PluginOptions))
+ * ```
+ *
+ * @remarks
+ * In case you're writing a new plugin, it must fulfill the following API:
+ * ```
+ * const Plugin = (options: PluginOptions): PluginContainer => ({
+ * id,
+ * component,
+ * locales,
+ * options,
+ * storeModule,
+ * })
+ * ```
+ *
+ * @param plugin - Plugin to be added.
+ */
+ addPlugin: pluginStore.addPlugin,
+
+ /**
+ * Removes a plugin by its ID.
+ *
+ * @param pluginId - ID of the plugin to be removed.
+ */
+ removePlugin: pluginStore.removePlugin,
+
+ /**
+ * Returns a plugin's store by its ID.
+ *
+ * For bundled plugins, the return value is typed.
+ *
+ * If no plugin with the specified ID is loaded, `null` is returned instead.
+ *
+ * @param pluginId - ID of the plugin whose store is requested.
+ */
+ getPluginStore: pluginStore.getPluginStore,
+
+ /**
+ * Allows reading or setting the OIDC token used for service accesses.
+ */
+ oidcToken: mainStore.oidcToken,
+
+ /**
+ * Allows accessing the POLAR DOM element (``).
+ *
+ * @readonly
+ * @alpha
+ */
+ lightElement: computed(() => mainStore.lightElement),
+
+ /**
+ * The currently used layout.
+ * Either a string indicating `standard` or `nineRegions` or a custom Vue component.
+ *
+ * @readonly
+ * @alpha
+ */
+ layout: computed(() => mainStore.layout),
+
+ /**
+ * Allows accessing the OpenLayers Map element.
+ *
+ * @readonly
+ * @alpha
+ */
+ map: computed(() => mainStore.map),
+
+ /**
+ * The current top position value in px of the MoveHandle.
+ * Is null if the MoveHandle is currently not visible.
+ *
+ * @readonly
+ * @alpha
+ */
+ moveHandleTop: computed(() => moveHandleStore.top),
+
+ /**
+ * Coordinates that were selected by the user with a marker.
+ *
+ * @readonly
+ * @alpha
+ */
+ selectedCoordinates: computed(() => markerStore.selectedCoordinates),
+
+ /**
+ * Allows accessing the Shadow DOM root of POLAR.
+ *
+ * @readonly
+ * @alpha
+ */
+ shadowRoot: computed(() => mainStore.shadowRoot),
+
+ /**
+ * Allows setting content to the MoveHandle to be displayed on small devices
+ * if the application has the same size as the window.
+ *
+ * @alpha
+ */
+ setMoveHandle: moveHandleStore.setMoveHandle,
+
+ /**
+ * Allows setting an additional action button to be displayed as part of the
+ * MoveHandle.
+ *
+ * @alpha
+ */
+ setMoveHandleActionButton: moveHandleStore.setMoveHandleActionButton,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useCoreStore, import.meta.hot))
+}
diff --git a/src/core/stores/main.ts b/src/core/stores/main.ts
new file mode 100644
index 000000000..5cf332d22
--- /dev/null
+++ b/src/core/stores/main.ts
@@ -0,0 +1,117 @@
+import { toMerged } from 'es-toolkit'
+import type { Feature, Map } from 'ol'
+import type { Coordinate } from 'ol/coordinate'
+import type { Point } from 'ol/geom'
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { computed, ref, shallowRef, watch } from 'vue'
+import type {
+ ColorScheme,
+ MapConfigurationIncludingDefaults,
+ MasterportalApiServiceRegister,
+} from '../types'
+import { SMALL_DISPLAY_HEIGHT, SMALL_DISPLAY_WIDTH } from '../utils/constants'
+import { addInterceptor } from '../utils/addInterceptor'
+import defaults from '../utils/defaults'
+
+export const useMainStore = defineStore('main', () => {
+ const colorScheme = ref('system')
+ const configuration = ref(
+ toMerged(
+ {
+ layers: [],
+ startCenter: [0, 0],
+ },
+ defaults
+ )
+ )
+ const language = ref('')
+ const lightElement = ref(null)
+ const map = shallowRef({} as Map)
+ const serviceRegister = ref([])
+ const shadowRoot = ref(null)
+ const zoom = ref(0)
+
+ const layout = computed(() => configuration.value.layout ?? 'standard')
+
+ // TODO(dopenguin): Both will possibly be updated with different breakpoints -> Breakpoints are e.g. not valid on newer devices
+ const clientHeight = ref(0)
+ const clientWidth = ref(0)
+ const hasSmallHeight = computed(
+ () => clientHeight.value <= SMALL_DISPLAY_HEIGHT
+ )
+ const hasSmallWidth = computed(() => clientWidth.value <= SMALL_DISPLAY_WIDTH)
+ const hasWindowSize = computed(
+ () =>
+ window.innerHeight === clientHeight.value &&
+ window.innerWidth === clientWidth.value
+ )
+ const deviceIsHorizontal = computed(
+ () => hasSmallHeight.value && hasWindowSize.value
+ )
+
+ const hasSmallDisplay = ref(false)
+ function updateHasSmallDisplay() {
+ hasSmallDisplay.value =
+ window.innerHeight <= SMALL_DISPLAY_HEIGHT ||
+ window.innerWidth <= SMALL_DISPLAY_WIDTH
+ }
+
+ const oidcToken = ref('')
+ watch(
+ () => configuration.value.secureServiceUrlRegex,
+ (urlRegex) => {
+ if (urlRegex) {
+ addInterceptor(
+ urlRegex,
+ () => new Headers([['Authorization', `Bearer ${oidcToken.value}`]])
+ )
+ }
+ }
+ )
+
+ const center = ref([0, 0])
+ function centerOnFeature(feature: Feature) {
+ center.value = (feature.getGeometry() as Point).getCoordinates()
+ }
+
+ function setup() {
+ addEventListener('resize', updateHasSmallDisplay)
+ updateHasSmallDisplay()
+ }
+
+ function teardown() {
+ removeEventListener('resize', updateHasSmallDisplay)
+ }
+
+ return {
+ // State
+ colorScheme,
+ configuration,
+ clientHeight,
+ clientWidth,
+ hasSmallDisplay,
+ language,
+ lightElement,
+ map,
+ oidcToken,
+ serviceRegister,
+ shadowRoot,
+ center,
+ zoom,
+ // Getters
+ layout,
+ hasSmallHeight,
+ hasSmallWidth,
+ hasWindowSize,
+ deviceIsHorizontal,
+ // Actions
+ centerOnFeature,
+ updateHasSmallDisplay,
+ setup,
+ teardown,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useMainStore, import.meta.hot))
+}
diff --git a/src/core/stores/marker.ts b/src/core/stores/marker.ts
new file mode 100644
index 000000000..86e051997
--- /dev/null
+++ b/src/core/stores/marker.ts
@@ -0,0 +1,42 @@
+import { Feature } from 'ol'
+import { Point } from 'ol/geom'
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import type { CallOnMapSelect } from '../types'
+import { useMainStore } from './main'
+
+export const useMarkerStore = defineStore('marker', () => {
+ const mainStore = useMainStore()
+ const configuration = computed(() => mainStore.configuration.markers)
+
+ const callOnMapSelect = computed(() =>
+ typeof configuration.value?.callOnMapSelect === 'function'
+ ? (configuration.value.callOnMapSelect as CallOnMapSelect)
+ : null
+ )
+ const clusterClickZoom = computed(
+ () => (configuration.value?.clusterClickZoom as boolean) || false
+ )
+
+ const hovered = ref(null)
+ const selected = ref(null)
+ const selectedCoordinates = computed(() =>
+ selected.value === null
+ ? null
+ : (selected.value.getGeometry() as Point).getCoordinates()
+ )
+
+ return {
+ configuration,
+ callOnMapSelect,
+ clusterClickZoom,
+
+ hovered,
+ selected,
+ selectedCoordinates,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useMarkerStore, import.meta.hot))
+}
diff --git a/src/core/stores/moveHandle.ts b/src/core/stores/moveHandle.ts
new file mode 100644
index 000000000..4a8d1e6c0
--- /dev/null
+++ b/src/core/stores/moveHandle.ts
@@ -0,0 +1,66 @@
+import { defineStore } from 'pinia'
+import { type Component, markRaw, ref } from 'vue'
+import type { MoveHandleProperties } from '../types'
+
+export const useMoveHandleStore = defineStore('moveHandle', () => {
+ const actionButton = ref(null)
+ const closeFunction = ref<(userInteraction: boolean) => void>(() => {})
+ const closeIcon = ref('kern-icon--close')
+ const closeLabel = ref('')
+ const component = ref(null)
+ const isActive = ref(false)
+ const plugin = ref('')
+ const top = ref(null)
+
+ function setMoveHandle(moveHandle: MoveHandleProperties | null) {
+ if (moveHandle === null) {
+ closeFunction.value(false)
+ $reset()
+ return
+ }
+ // Makes sure the previous plugin is properly closed if the "normal" way of closing isn't used.
+ if (plugin.value !== moveHandle.plugin) {
+ closeFunction.value(false)
+ }
+ isActive.value = true
+ closeFunction.value = moveHandle.closeFunction
+ closeLabel.value = moveHandle.closeLabel
+ component.value = markRaw(moveHandle.component)
+ plugin.value = moveHandle.plugin
+ if (moveHandle.closeIcon) {
+ closeIcon.value = moveHandle.closeIcon
+ }
+ }
+
+ function setMoveHandleActionButton(component: Component | null) {
+ actionButton.value = component === null ? null : markRaw(component)
+ }
+
+ function $reset() {
+ actionButton.value = null
+ closeFunction.value = () => {}
+ closeIcon.value = 'kern-icon--close'
+ closeLabel.value = ''
+ component.value = null
+ isActive.value = false
+ plugin.value = ''
+ }
+
+ return {
+ actionButton,
+ closeFunction,
+ closeIcon,
+ closeLabel,
+ component,
+ isActive,
+ plugin,
+ top,
+ $reset,
+
+ /** @alpha */
+ setMoveHandle,
+
+ /** @alpha */
+ setMoveHandleActionButton,
+ }
+})
diff --git a/src/core/stores/plugin.ts b/src/core/stores/plugin.ts
new file mode 100644
index 000000000..8b259553d
--- /dev/null
+++ b/src/core/stores/plugin.ts
@@ -0,0 +1,90 @@
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { markRaw, reactive } from 'vue'
+import { toMerged } from 'es-toolkit'
+import i18next from 'i18next'
+import type {
+ PluginContainer,
+ PluginId,
+ BundledPluginId,
+ BundledPluginStores,
+ PolarPluginStore,
+ PluginOptions,
+} from '../types'
+import { useMainStore } from './main'
+
+export const usePluginStore = defineStore('plugin', () => {
+ const plugins = reactive([])
+ const mainStore = useMainStore()
+
+ function addPlugin(plugin: PluginContainer) {
+ const { id, locales, options, storeModule } = plugin
+
+ /* configuration merge – "options" are from client-code, "configuration"
+ * is from mapConfiguration object and thus overrides */
+ const pluginConfiguration = toMerged(
+ options || {},
+ (mainStore.configuration[id] || {}) as PluginOptions
+ )
+ mainStore.configuration[id] = pluginConfiguration
+
+ const store = storeModule?.(mainStore._instance)
+ if (store && typeof store.setupPlugin === 'function') {
+ store.setupPlugin()
+ }
+
+ if (locales) {
+ locales.forEach((lng) => {
+ i18next.addResourceBundle(lng.type, id, lng.resources, true)
+ })
+ }
+
+ plugins.push({
+ ...plugin,
+ // This is added for consistency. However, the options should be accessed via configuration.
+ options: pluginConfiguration,
+ ...(plugin.component ? { component: markRaw(plugin.component) } : {}),
+ })
+ }
+
+ function removePlugin(pluginId: string) {
+ const pluginIndex = plugins.findIndex(({ id }) => id === pluginId)
+ const plugin = plugins[pluginIndex]
+ if (!plugin) {
+ console.error(`Plugin "${pluginId}" not found.`)
+ return
+ }
+
+ const store = plugin.storeModule?.(mainStore._instance)
+ if (store) {
+ if (typeof store.teardownPlugin === 'function') {
+ store.teardownPlugin()
+ }
+ store.$reset()
+ }
+
+ plugins.splice(pluginIndex, 1)
+ }
+
+ function getPluginStore(
+ id: T
+ ): ReturnType<
+ T extends BundledPluginId
+ ? BundledPluginStores
+ : PolarPluginStore
+ > | null {
+ const plugin = plugins.find((plugin) => plugin.id === id)
+ // @ts-expect-error | We trust that our internal IDs work.
+ return plugin?.storeModule?.(mainStore._instance) || null
+ }
+
+ return {
+ plugins,
+ addPlugin,
+ removePlugin,
+ getPluginStore,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(usePluginStore, import.meta.hot))
+}
diff --git a/src/core/types.ts b/src/core/types.ts
new file mode 100644
index 000000000..7b0ec8eb8
--- /dev/null
+++ b/src/core/types.ts
@@ -0,0 +1,8 @@
+export * from './types/layer'
+export * from './types/locales'
+export * from './types/main'
+export * from './types/marker'
+export * from './types/moveHandle'
+export * from './types/plugin'
+export * from './types/theme'
+export * from './types/utils'
diff --git a/src/core/types/layer.ts b/src/core/types/layer.ts
new file mode 100644
index 000000000..98cf46914
--- /dev/null
+++ b/src/core/types/layer.ts
@@ -0,0 +1,124 @@
+export interface LayerConfigurationOptionLayers {
+ /**
+ * Legend image to be used for sub-layer. If false, no image is displayed.
+ * If true, it is assumed an image exists in the layer's GetCapabilities, and
+ * that will be used. If Record, it maps the layer name to a linked image url.
+ */
+ legend?: boolean | Record
+
+ /**
+ * Comma-separated re-ordering of service layer's 'layer' specification.
+ * Layer's not specified in service definition, but in order, are initially
+ * invisible. Layers not specified in order, but in service definition, are
+ * always visible. Layers specified in both are initially visible. Layers
+ * specified in neither are always invisible.
+ */
+ order?: string
+
+ /**
+ * Title to be displayed for sub-layer. If false, layer name itself will
+ * be used as given in service description 'layers' field. If true, it is
+ * assumed a name exists in the layer's GetCapabilities, and that will be
+ * used. If Record, it maps the layer name to an arbitrary display name given
+ * by the configuration.
+ */
+ title?: boolean | Record
+}
+
+// TODO: It should be allowed to set LayerType to a random string to allow for the grouping of different mask layers
+export type LayerType = 'background' | 'mask'
+
+export interface LayerConfigurationOptions {
+ /**
+ * Named matching OGC-specification of a WMS layer's layers.
+ *
+ * If configured, all configured _layers of the layer_ can be turned off and
+ * on by the user.
+ *
+ * ⚠️ Only implemented for WMS. Only implemented for top layers; that is, only
+ * first level of nesting is considered.
+ *
+ * @example
+ * ```
+ * options: {
+ * layers: {
+ * order: '6,24,25,4,3,2,1,0',
+ * title: {
+ * '6': 'Monument area',
+ * '24': 'Majority of structures',
+ * '25': 'Material group',
+ * '4': 'Architectural monument',
+ * '3': 'Natural monument',
+ * '2': 'Water bodies',
+ * '1': 'Architectural monument (area)',
+ * '0': 'Natural monument (area)',
+ * },
+ * legend: true,
+ * },
+ * },
+ * ```
+ */
+ layers: LayerConfigurationOptionLayers
+}
+
+export interface LayerConfiguration {
+ /**
+ * Unique id to identify the layer.
+ */
+ id: string
+
+ /**
+ * Human-readable identifier and value to be displayed in the UI.
+ */
+ name: string
+
+ /**
+ * Whether the layer is a background layer or a feature layer with specific information.
+ */
+ type: LayerType
+
+ /**
+ * layers may have their own gfiMode.
+ */
+ gfiMode?: 'bboxDot' | 'intersects'
+
+ /**
+ * Whether the mask-layer should be hidden from the LayerChooser selection menu.
+ */
+ hideInMenu?: boolean
+
+ /**
+ * The maximum zoom level the layer will be rendered in.
+ *
+ * @defaultValue Number.MAX_SAFE_INTEGER
+ */
+ maxZoom?: number
+
+ /**
+ * The minimum zoom level the layer will be rendered in.
+ *
+ * @defaultValue 0
+ */
+ minZoom?: number
+
+ /**
+ * Enables a configuration feature for the layer in its selection in the UI of
+ * the LayerChooser.
+ */
+ options?: LayerConfigurationOptions
+
+ /**
+ * ID of the used style. If the layer is also configured in {@link MapConfiguration.markers | `mapConfiguration.markers`},
+ * that configuration takes precedence over the configured `styleId`. Only applicable for vector-type layers.
+ * For more information and an example see {@link MapConfiguration.featureStyles | `mapConfiguration.featureStyles`}.
+ * Defaults and fallbacks to OpenLayers default styling.
+ */
+ styleId?: string
+
+ /**
+ * Initial visibility of the layers.
+ *
+ * @defaultValue false
+ */
+ visibility?: boolean
+}
diff --git a/src/core/types/locales.ts b/src/core/types/locales.ts
new file mode 100644
index 000000000..e72db6642
--- /dev/null
+++ b/src/core/types/locales.ts
@@ -0,0 +1,36 @@
+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'
+
+/** @internal */
+export interface Locale {
+ resources: Record
+ type: string
+}
+
+/** @internal */
+export type LocaleResources = {
+ [T in typeof CoreId | BundledPluginId]: T extends BundledPluginId
+ ? BundledPluginLocaleResources
+ : typeof core
+}
+
+type ToLocaleOverride = T extends string
+ ? string
+ : { [P in keyof T]?: ToLocaleOverride }
+
+/**
+ * Overrides for the built-in translations.
+ */
+export interface LocaleOverride {
+ /**
+ * Locale resources to override in the given language.
+ */
+ resources: ToLocaleOverride
+
+ /**
+ * Language key as described in the i18next documentation.
+ */
+ type: string
+}
diff --git a/src/core/types/main.ts b/src/core/types/main.ts
new file mode 100644
index 000000000..828ba7d19
--- /dev/null
+++ b/src/core/types/main.ts
@@ -0,0 +1,328 @@
+import type { VueElement } from 'vue'
+import type defaults from '../utils/defaults'
+import type { MarkerConfiguration } from './marker'
+import type { LayerConfiguration } from './layer'
+import type { PolarTheme } from './theme'
+import type { LocaleOverride } from './locales'
+import type { PluginId } from './plugin'
+import type { FooterPluginOptions } from '@/plugins/footer'
+import type { FullscreenPluginOptions } from '@/plugins/fullscreen'
+import type { GeoLocationPluginOptions } from '@/plugins/geoLocation'
+import type { IconMenuPluginOptions } from '@/plugins/iconMenu'
+import type { LoadingIndicatorOptions } from '@/plugins/loadingIndicator'
+import type { PinsPluginOptions } from '@/plugins/pins'
+import type { ReverseGeocoderPluginOptions } from '@/plugins/reverseGeocoder'
+import type { ToastPluginOptions } from '@/plugins/toast'
+
+export interface ServiceAvailabilityCheck {
+ ping: Promise
+ serviceId: string
+ serviceName: string
+}
+
+export interface StoreReference {
+ key: string
+ plugin?: PluginId
+}
+
+export type InitialLanguage = 'de' | 'en'
+
+export interface PolarMapOptions {
+ /**
+ * Size of 1 pixel on the screen converted to map units (e.g. meters) depending on the used projection
+ * ({@link MasterportalApiConfiguration.epsg} | `epsg`).
+ */
+ resolution: number
+
+ /**
+ * Scale in meters.
+ */
+ scale: number
+
+ /**
+ * Zoom level.
+ */
+ zoomLevel: number
+}
+
+/**
+ * Service register for use with `@masterportal/masterportalapi`.
+ *
+ * Whitelisted and confirmed parameters include:
+ * - WMS: `id`, `name`, `url`, `typ`, `format`, `version`, `transparent`, `layers`, `styles`, `singleTile`
+ * - WFS: `id`, `name`, `url`, `typ`, `outputFormat`, `version`, `featureType`
+ * - WMTS: `id`, `name`, `urls`, `typ`, `capabilitiesUrl`, `optionsFromCapabilities`, `tileMatrixSet`, `layers`,
+ * `legendURL`, `format`, `coordinateSystem`, `origin`, `transparent`, `tileSize`, `minScale`, `maxScale`,
+ * `requestEncoding`, `resLength`
+ * - OAF: `id`, `name`, `url`, `typ`, `collection`, `crs`, `bboxCrs`
+ * - GeoJSON: `id`, `name`, `url`, `typ`, `version`, `minScale`, `maxScale`, `legendURL`
+ *
+ * To load this from an URL, call {@link fetchServiceRegister}.
+ * You may also pass the URL directly to the `serviceRegister` parameter of {@link createMap}.
+ *
+ * An example for a predefined service register is [the service register of the city of Hamburg](https://geodienste.hamburg.de/services-internet.json).
+ * Full documentation regarding the configuration can be read [here](https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev/doc/services.json.md).
+ * However, not all listed services have been implemented in the `@masterportal/masterportalapi` yet,
+ * and no documentation regarding implemented properties exists there yet.
+ */
+export type MasterportalApiServiceRegister = Record[]
+
+/**
+ * The `<...masterportalapi.fields>` means that any \@masterportal/masterportalapi field may also be used here _directly_
+ * in the {@link MapConfiguration | `mapConfiguration`}. The fields described here are fields that are interesting for
+ * the usage of POLAR.
+ * Fields that are not set as required have default values.
+ */
+export interface MasterportalApiConfiguration {
+ /**
+ * Initial center coordinate.
+ * Coordinate needs to be defined in the chosen leading coordinate system configured by
+ * {@link MasterportalApiConfiguration.epsg | `mapConfiguration.epsg`}.
+ *
+ * @example
+ * ```
+ * startCenter: [553655.72, 6004479.25]
+ * ```
+ */
+ startCenter: [number, number]
+
+ /**
+ * Leading coordinate system. The coordinate system has to be defined in
+ * {@link MasterportalApiConfiguration.namedProjections | `mapConfiguration.namedProjections`} as well.
+ * Changing this value should also lead to changes in
+ * {@link MasterportalApiConfiguration.startCenter | `mapConfiguration.startCenter`},
+ * {@link MasterportalApiConfiguration.extent | `mapConfiguration.extent`},
+ * {@link MasterportalApiConfiguration.options | `mapConfiguration.options`} and
+ * {@link MasterportalApiConfiguration.startResolution | `mapConfiguration.startResolution`} as they are described in
+ * or are related to the leading coordinate system.
+ *
+ * @defaultValue `'EPSG:25832'`
+ *
+ * @example
+ * ```
+ * epsg: 'EPSG:4326'
+ * ```
+ */
+ epsg?: `EPSG:${string}`
+
+ /**
+ * Map movement will be restricted to the rectangle described by the given coordinates. Unrestricted by default.
+ * Coordinates need to be defined in the chosen leading coordinate system configured by
+ * {@link MasterportalApiConfiguration.epsg | `mapConfiguration.epsg`}.
+ *
+ * @example
+ * ```
+ * extent: [426205.6233, 5913461.9593, 650128.6567, 6101486.8776]
+ * ```
+ */
+ extent?: [number, number, number, number]
+
+ /**
+ * Array of usable coordinated systems mapped to a projection as a proj4 string. Defines `'EPSG:25832'`, `'EPSG:3857'`,
+ * `'EPSG:4326'`, `'EPSG:31467'` and `'EPSG:4647'` by default. If you set a value, please mind that all pre-configured
+ * projections are overridden, and requiring e.g. `'EPSG:4326'` will only work if it is also defined in your override.
+ *
+ * @example
+ * ```
+ * namedProjections: [
+ * [
+ * 'EPSG:25832',
+ * '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
+ * ],
+ * ]
+ * ```
+ */
+ namedProjections?: Array<[string, string]>
+
+ /**
+ * Defines all available zoom levels mapped to the respective resolution and related scale.
+ * The resolution is dependent on the chosen leading coordinate system configured by
+ * {@link MasterportalApiConfiguration.epsg | `mapConfiguration.epsg`}.
+ * Defines 10 zoomLevels for `'EPSG:25832'` by default.
+ *
+ * @example
+ * ```
+ * options: [
+ * { resolution: 66.14579761460263, scale: 250000, zoomLevel: 0 },
+ * { resolution: 26.458319045841044, scale: 100000, zoomLevel: 1 },
+ * { resolution: 15.874991427504629, scale: 60000, zoomLevel: 2 },
+ * { resolution: 10.583327618336419, scale: 40000, zoomLevel: 3 },
+ * { resolution: 5.2916638091682096, scale: 20000, zoomLevel: 4 },
+ * { resolution: 2.6458319045841048, scale: 10000, zoomLevel: 5 },
+ * { resolution: 1.3229159522920524, scale: 5000, zoomLevel: 6 },
+ * { resolution: 0.6614579761460262, scale: 2500, zoomLevel: 7 },
+ * { resolution: 0.2645831904584105, scale: 1000, zoomLevel: 8 },
+ * { resolution: 0.1322915952292052, scale: 500, zoomLevel: 9 },
+ * ]
+ * ```
+ */
+ options?: PolarMapOptions[]
+
+ /**
+ * Initial resolution; must be described in {@link MasterportalApiConfiguration.options | `mapConfiguration.options`}.
+ * Defaults to `15.874991427504629` which is a zoom level defined in the default configuration of
+ * {@link MasterportalApiConfiguration.options | `mapConfiguration.options`}.
+ *
+ * @defaultValue `15.874991427504629`
+ * @example
+ * ```
+ * startResolution: 264.583190458
+ * ```
+ */
+ startResolution?: number
+}
+
+export type ColorScheme = 'dark' | 'light' | 'system'
+
+/** The mapConfiguration allows controlling many client instance details. */
+export interface MapConfiguration extends MasterportalApiConfiguration {
+ /**
+ * Configuration of layers that are supposed to be used in the respective client. All layers defined here have to have
+ * an entry in the {@link createMap | `serviceRegister` parameter of `createMap`}. If `@polar/plugin-layer-chooser` is
+ * installed and configured, all these layers will be displayed in that menu.
+ *
+ * @example
+ * ```
+ * layers: [
+ * {
+ * id: 'basemap',
+ * name: 'Basemap Grayscale',
+ * },
+ * {
+ * id: 'my-wfs',
+ * name: 'My WFS service',
+ * },
+ * ]
+ * ```
+ */
+ layers: LayerConfiguration[]
+
+ /** If set to `true`, all services' availability will be checked with head requests. */
+ checkServiceAvailability?: boolean
+
+ /**
+ * Color scheme the client should be using.
+ * If set to `system`, the color scheme is chosen according to the user's system preferences.
+ *
+ * @defaultValue `'system'`
+ */
+ colorScheme?: ColorScheme
+
+ /**
+ * Optional path to define styles for vector features. The parameter may be a url or a path on the local file system.
+ * See `mapConfiguration.featureStyles` for more information.
+ */
+ featureStyles?: string
+
+ /**
+ * The initial language the client should be using.
+ *
+ * @defaultValue `'de'` (German)
+ */
+ language?: InitialLanguage
+
+ /**
+ * Choose between the standard sidebar layout with fixed positioning, the oldschool nine region layout with full
+ * configurability regarding positioning or add a custom layout as Vue component.
+ */
+ layout?: 'standard' | 'nineRegions' | VueElement
+
+ /**
+ * All locales in POLAR and its plugins can be overridden to fit your needs.
+ * Take a look at the respective documentation for all values that can be overridden.
+ *
+ * A language option is an object consisting of a type (its language key) and the i18next resource definition.
+ * You may e.g. decide that the texts offered in the LayerChooser do not fit the style of your client, or that they
+ * could be more precise in your situation since you're only using *very specific* overlays.
+ *
+ * @example
+ * ```
+ * locales: [
+ * {
+ * type: 'de',
+ * resources: {
+ * layerChooser: {
+ * maskTitle: 'Bahnstrecken',
+ * },
+ * },
+ * },
+ * {
+ * type: 'en',
+ * resources: {
+ * layerChooser: {
+ * maskTitle: 'Railway lines',
+ * },
+ * },
+ * },
+ * ],
+ * ```
+ *
+ * @remarks
+ * When reading the locale tables, please mind that the dot notation (`a.b.c | value`) has to be written as separate
+ * keys in nested objects as seen in the example above (`{a: {b: {c: "value"}}}`).
+ */
+ locales?: LocaleOverride[]
+
+ /**
+ * If set, all configured visible vector layers' features can be hovered and selected by mouseover and click respectively.
+ * They are available as features in the store. Layers with `clusterDistance` will be clustered to a multi-marker
+ * that supports the same features. Please mind that only point marker vector layers are supported.
+ * For all other layers, take a look at the configuration of
+ * {@link MapConfiguration.featureStyles | `mapConfiguration.featureStyles`}.
+ * Note, that this configuration parameter takes precedence over the configuration of
+ * {@link MapConfiguration.featureStyles | `mapConfiguration.featureStyles`}.
+ */
+ markers?: MarkerConfiguration
+
+ /**
+ * If a secured layer is supposed to be visible on start, the token also has to be provided via this configuration parameter.
+ * Updates to the token have to be done by updating the store parameter `oidcToken`.
+ */
+ oidcToken?: string
+
+ /**
+ * Regular expression defining URLs that belong to secured services. All requests sent to URLs that fit the regular
+ * expression will send the JSON Web Token (JWT) found in the store parameter `oidcToken` as a Bearer token in the
+ * Authorization header of the request. Requests already including an Authorization header will keep the already present one.
+ */
+ secureServiceUrlRegex?: RegExp
+
+ /**
+ * Custom theme for POLAR.
+ *
+ * The default is to use KERN's standard theme.
+ */
+ theme?: PolarTheme
+ // Plugins are not sorted alphabetical, but listed last.
+ // Remember to sort them alphabetical inside their space.
+ // TODO: Generate this section via types/plugin.ts
+ /* eslint-disable perfectionist/sort-interfaces */
+
+ /** Configuration for footer plugin. */
+ footer?: FooterPluginOptions
+
+ /** Configuration for fullscreen plugin. */
+ fullscreen?: FullscreenPluginOptions
+
+ /** Configuration for geoLocation plugin. */
+ geoLocation?: GeoLocationPluginOptions
+
+ /** Configuration for iconMenu plugin. */
+ iconMenu?: IconMenuPluginOptions
+
+ /** Configuration for loadingIndicator plugin. */
+ loadingIndicator?: LoadingIndicatorOptions
+
+ /** Configuration for pins plugin. */
+ pins?: PinsPluginOptions
+
+ /** Configuration for reverseGeocoder plugin. */
+ reverseGeocoder?: ReverseGeocoderPluginOptions
+
+ /** Configuration for toast plugin. */
+ toast?: ToastPluginOptions
+ /* eslint-enable perfectionist/sort-interfaces */
+}
+
+export type MapConfigurationIncludingDefaults = MapConfiguration &
+ typeof defaults
diff --git a/src/core/types/marker.ts b/src/core/types/marker.ts
new file mode 100644
index 000000000..fd6747e23
--- /dev/null
+++ b/src/core/types/marker.ts
@@ -0,0 +1,153 @@
+import type { Feature } from 'ol'
+
+export type MarkersIsSelectableFunction = (feature: Feature) => boolean
+
+export interface CallOnMapSelect {
+ action: string
+ payload: unknown
+ pluginName?: string
+}
+
+/**
+ * A full documentation of the parameters is available at the Masterportal's https://www.masterportal.org/mkdocs/doc/Latest/User/Global-Config/style.json/.
+ * For more details, visual examples, and expert features, see there.
+ */
+export interface PolygonFillHatch {
+ backgroundColor?: [number, number, number, number]
+ lineWidth?: number
+ pattern?:
+ | 'diagonal'
+ | 'diagonal-right'
+ | 'zig-line'
+ | 'zig-line-horizontal'
+ | 'circle'
+ | 'rectangle'
+ | 'triangle'
+ | 'diamond'
+ | object
+ patternColor?: [number, number, number, number]
+ size?: number
+}
+
+export interface MarkerStyle {
+ /**
+ * `width` and `height` of the ``-cluster-marker.
+ *
+ * @defaultValue `[40, 36]`
+ */
+ clusterSize: [number, number]
+
+ /**
+ * Fill color (or hatch pattern) for map marker.
+ */
+ fill: string | PolygonFillHatch
+
+ /**
+ * `width` and `height` of the ``-marker.
+ *
+ * @defaultValue `[26, 36]`
+ */
+ size: [number, number]
+
+ /**
+ * Color of marker stroke (outer line).
+ *
+ * @defaultValue `'#FFFFFF'`
+ */
+ stroke: string
+
+ /**
+ * Width of marker stroke (outer line).
+ *
+ * @defaultValue `'2'`
+ */
+ strokeWidth: string | number
+}
+
+export interface MarkerLayer {
+ defaultStyle: MarkerStyle
+ hoverStyle: MarkerStyle
+ id: string
+ isSelectable: MarkersIsSelectableFunction
+ selectionStyle: MarkerStyle
+ unselectableStyle: MarkerStyle
+}
+
+export interface MarkerLayerConfiguration {
+ /** Unique identifier of a layer configured in {@link MapConfiguration.layers | `mapConfiguration.layers`}. */
+ id: string
+
+ /**
+ * Used as the default marker style.
+ * The default fill color for these markers is `'#005CA9'`.
+ */
+ defaultStyle?: Partial
+
+ /**
+ * Used as map marker style for hovered features.
+ * The default fill color for these markers is `'#7B1045'`.
+ */
+ hoverStyle?: Partial
+
+ /**
+ * If undefined, all features are selectable.
+ * If defined, this can be used to sort out features to be unselectable,
+ * and such features will be styled differently and won't react on click.
+ *
+ * @example
+ * ```
+ * isSelectable: (feature: Feature) => feature.get('indicator')
+ * ```
+ */
+ isSelectable?: MarkersIsSelectableFunction
+
+ /**
+ * Used as map marker style for selected features.
+ * The default fill color for these markers is `'#679100'`.
+ */
+ selectionStyle?: Partial
+
+ /**
+ * Used as a map marker style for unselectable features.
+ * Features are unselectable if a given {@link MarkerLayerConfiguration.isSelectable | `isSelectable`} method returns
+ * falsy for a feature.
+ * The default fill color for these markers is `'#333333'`.
+ */
+ unselectableStyle?: Partial
+}
+
+export interface MarkerConfiguration {
+ /**
+ * List of layers including optional style information and under which
+ * condition a feature is selectable.
+ */
+ layers: MarkerLayerConfiguration[]
+
+ /**
+ * If set, the given `action` will be called with the given `payload`. If the
+ * `pluginName` is set, the action will be called in the respective plugin,
+ * otherwise the core store is used.
+ *
+ * @example
+ * ```
+ * callOnMapSelect: {
+ * action: 'openMenuById',
+ * payload: 'gfi',
+ * pluginName: 'iconMenu'
+ * }
+ * ```
+ *
+ * @remarks
+ * The example open the gfi window in the iconMenu, if the IconMenu exists
+ * with the gfi plugin registered under the id `gfi`.
+ */
+ callOnMapSelect?: CallOnMapSelect
+
+ /**
+ * If `true`, clicking a cluster feature will zoom into the clustered features'
+ * bounding box (with padding) so that the cluster is "resolved". This happens
+ * until the maximum zoom level is reached, at which no further zooming can
+ * take place. Defaults to `false`.
+ */
+ clusterClickZoom?: boolean
+}
diff --git a/src/core/types/moveHandle.ts b/src/core/types/moveHandle.ts
new file mode 100644
index 000000000..0eb20bffe
--- /dev/null
+++ b/src/core/types/moveHandle.ts
@@ -0,0 +1,11 @@
+import type { Component } from 'vue'
+
+export interface MoveHandleProperties {
+ closeFunction: (...args: unknown[]) => unknown
+ closeLabel: string
+ component: Component
+
+ /** Id of the plugin that added the moveHandle. */
+ plugin: string
+ closeIcon?: string
+}
diff --git a/src/core/types/plugin.ts b/src/core/types/plugin.ts
new file mode 100644
index 000000000..b600f1eb4
--- /dev/null
+++ b/src/core/types/plugin.ts
@@ -0,0 +1,219 @@
+import type { SetupStoreDefinition } from 'pinia'
+import type { Component } from 'vue'
+import type { NineLayoutTag } from '../utils/NineLayoutTag'
+import type { Locale } from './locales'
+
+import type { PluginId as FooterPluginId } from '@/plugins/footer'
+import type { useFooterStore as FooterStore } from '@/plugins/footer/store'
+import type { resourcesEn as FooterResources } from '@/plugins/footer/locales'
+
+import type { PluginId as FullscreenPluginId } from '@/plugins/fullscreen'
+import type { useFullscreenStore as FullscreenStore } from '@/plugins/fullscreen/store'
+import type { resourcesEn as FullscreenResources } from '@/plugins/fullscreen/locales'
+
+import type { PluginId as GeoLocationPluginId } from '@/plugins/geoLocation'
+import type { useGeoLocationStore as GeoLocationStore } from '@/plugins/geoLocation/store'
+import type { resourcesEn as GeoLocationResources } from '@/plugins/geoLocation/locales'
+
+import type { PluginId as IconMenuPluginId } from '@/plugins/iconMenu'
+import type { useIconMenuStore as IconMenuStore } from '@/plugins/iconMenu/store'
+import type { resourcesEn as IconMenuResources } from '@/plugins/iconMenu/locales'
+
+import type { PluginId as LayerChooserPluginId } from '@/plugins/layerChooser'
+import type { useLayerChooserStore as LayerChooserStore } from '@/plugins/layerChooser/store'
+import type { resourcesEn as LayerChooserResources } from '@/plugins/layerChooser/locales'
+
+import type { PluginId as LoadingIndicatorId } from '@/plugins/loadingIndicator'
+import type { useLoadingIndicatorStore as LoadingIndicatorStore } from '@/plugins/loadingIndicator/store'
+
+import type { PluginId as PinsPluginId } from '@/plugins/pins'
+import type { usePinsStore as PinsStore } from '@/plugins/pins/store'
+import type { resourcesEn as PinsResources } from '@/plugins/pins/locales'
+
+import type { PluginId as ReverseGeocoderPluginId } from '@/plugins/reverseGeocoder'
+import type { useReverseGeocoderStore as ReverseGeocoderStore } from '@/plugins/reverseGeocoder/store'
+
+import type { PluginId as ToastPluginId } from '@/plugins/toast'
+import type { useToastStore as ToastStore } from '@/plugins/toast/store'
+import type { resourcesEn as ToastResources } from '@/plugins/toast/locales'
+
+export interface PluginOptions {
+ displayComponent?: boolean
+ layoutTag?: keyof typeof NineLayoutTag
+}
+
+export interface BoundaryOptions {
+ /**
+ * ID of the vector layer to restrict requests to.
+ * The layer must contain vectors. This is useful for restricted maps to avoid
+ * selecting unfit coordinates.
+ */
+ layerId: string
+
+ /**
+ * If the boundary layer check does not work due to loading or configuration
+ * errors, style `'strict'` will disable the affected feature, and style
+ * `'permissive'` will act as if no {@link layerId} was set.
+ *
+ * @defaultValue 'permissive'
+ */
+ onError?: 'strict' | 'permissive'
+}
+
+export interface LayerBoundPluginOptions extends PluginOptions {
+ /**
+ * Set to check whether something should be restricted to an area defined by a layer.
+ *
+ * If
+ *
+ * @example
+ * ```
+ * {
+ * layerId: 'hamburgBorder',
+ * }
+ * ```
+ */
+ boundary?: BoundaryOptions
+}
+
+export type PolarPluginStore<
+ T extends {
+ setupPlugin?: () => void
+ teardownPlugin?: () => void
+ } = {
+ setupPlugin?: () => void
+ teardownPlugin?: () => void
+ },
+> = SetupStoreDefinition
+
+/** @internal */
+export type BundledPluginId =
+ | typeof FooterPluginId
+ | typeof FullscreenPluginId
+ | typeof GeoLocationPluginId
+ | typeof IconMenuPluginId
+ | typeof LayerChooserPluginId
+ | typeof LoadingIndicatorId
+ | typeof PinsPluginId
+ | typeof ReverseGeocoderPluginId
+ | typeof ToastPluginId
+
+type GetPluginStore<
+ T extends BundledPluginId,
+ I extends BundledPluginId,
+ // TODO: This fixes the type error, but relaxes type-checking for the plugin store too much.
+ // However, it is not clear if Pinia's type system allows for stronger checks at the moment.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ S extends PolarPluginStore,
+> = T extends I ? S : never
+
+/** @internal */
+export type BundledPluginStores =
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore
+ | GetPluginStore<
+ T,
+ typeof ReverseGeocoderPluginId,
+ typeof ReverseGeocoderStore
+ >
+ | GetPluginStore
+
+type GetPluginResources<
+ T extends BundledPluginId,
+ I extends BundledPluginId,
+ S extends Locale['resources'],
+> = T extends I ? S : never
+
+/** @internal */
+export type BundledPluginLocaleResources =
+ | GetPluginResources
+ | GetPluginResources
+ | GetPluginResources<
+ T,
+ typeof GeoLocationPluginId,
+ typeof GeoLocationResources
+ >
+ | GetPluginResources
+ | GetPluginResources<
+ T,
+ typeof LayerChooserPluginId,
+ typeof LayerChooserResources
+ >
+ | GetPluginResources
+ | GetPluginResources
+
+/** @internal */
+export type ExternalPluginId = `external-${string}`
+
+/** @internal */
+export type PluginId = BundledPluginId | ExternalPluginId
+
+export interface PluginContainer {
+ /**
+ * Unique technical identifier.
+ *
+ * For bundled plugins, this is its name, e.g. `fullscreen`.
+ *
+ * For external plugins, use `external-` as a prefix and ensure uniqueness.
+ * For publicly published plugins, it is recommended to use
+ * `polar-plugin-X` as your package name and use `external-X` as ID.
+ *
+ * Please do not use `external-X` when `X` is a bundled plugin.
+ *
+ * @example `fullscreen`
+ */
+ id: PluginId
+
+ /**
+ * A Vue component if required.
+ *
+ * The component will be rendered by POLAR over the map.
+ * The position is either to be determined by the plugin if `layout === 'standard'`
+ * or will be determined by the layout.
+ */
+ component?: Component
+
+ /**
+ * Icon class for the plugin.
+ * This icon will be used as the default for rendering in menus.
+ */
+ icon?: string
+
+ /**
+ * Whether the plugin is independently rendered.
+ *
+ * @internal
+ * @defaultValue true
+ */
+ independent?: boolean
+
+ /**
+ * Locales used in the plugin.
+ *
+ * The locales will be loaded to the namespace that equals the plugin's ID.
+ */
+ locales?: Locale[]
+
+ /**
+ * Configuration options. Please also note that all configuration added via plugin constructors can be overridden in
+ * the {@link createMap | `createMap`'s parameter `mapConfiguration`} .
+ *
+ * You may use either object (or a mix of them) to create the configuration, e.g. use the constructors for a base
+ * configuration and the `mapConfiguration` object to override it for various use cases.
+ *
+ * How exactly you do this is up to you and influences the minimum API call requirements your client has.
+ */
+ options?: PluginOptions
+
+ /**
+ * Pinia store module if required.
+ * If the storeModule features a `setupPlugin` action, it will be executed automatically after initialization.
+ * If the storeModule features a `teardownPlugin` action, it will be executed automatically before unloading.
+ */
+ storeModule?: PolarPluginStore
+}
diff --git a/src/core/types/theme.ts b/src/core/types/theme.ts
new file mode 100644
index 000000000..0f732f139
--- /dev/null
+++ b/src/core/types/theme.ts
@@ -0,0 +1,60 @@
+export interface OklchColor {
+ // It is called lch, so you expect that order.
+ /* eslint-disable perfectionist/sort-interfaces */
+ l: string
+ c: string
+ h: string
+ /* eslint-enable perfectionist/sort-interfaces */
+}
+
+export interface KernThemeTree {
+ [key: string]: string | KernThemeTree
+}
+
+/**
+ * Describes the theming options of KERN.
+ * The exhaustive list of parameters is documented in `@kern-ux/native`.
+ */
+export interface KernTheme {
+ color: KernThemeTree
+ metric: KernThemeTree
+}
+
+/**
+ * Color expressed as RGB(A).
+ */
+export interface RgbaColor {
+ // It is called rgb(a), so you expect that order.
+ /* eslint-disable perfectionist/sort-interfaces */
+ r: string
+ g: string
+ b: string
+ a?: string
+ /* eslint-enable perfectionist/sort-interfaces */
+}
+
+/**
+ * A color, provided in one of many possible ways CSS allows.
+ */
+export type Color = { oklch: OklchColor } | { rgba: RgbaColor } | string
+
+/**
+ * An icon.
+ */
+export type Icon = `kern-icon--${string}`
+
+/**
+ * A theme for the POLAR map client.
+ */
+export interface PolarTheme {
+ /**
+ * This color will be defined as `--brand-color-{l,c,h}` CSS variable inside POLAR's shadow DOM.
+ * It can especially be used to define the KERN theme via {@link https://developer.mozilla.org/de/docs/Web/CSS/color_value/oklch | oklch}.
+ */
+ brandColor?: OklchColor
+
+ /**
+ * Theme for KERN UX library.
+ */
+ kern?: KernTheme
+}
diff --git a/src/core/types/utils.ts b/src/core/types/utils.ts
new file mode 100644
index 000000000..a339e260e
--- /dev/null
+++ b/src/core/types/utils.ts
@@ -0,0 +1,8 @@
+/**
+ * Copied from https://stackoverflow.com/a/54178819.
+ *
+ * Makes the properties defined by type `K` optional in type `T`.
+ *
+ * @example `PartialBy`
+ */
+export type PartialBy = Omit & Partial>
diff --git a/src/core/utils/NineLayoutTag.ts b/src/core/utils/NineLayoutTag.ts
new file mode 100644
index 000000000..9ea2e5a10
--- /dev/null
+++ b/src/core/utils/NineLayoutTag.ts
@@ -0,0 +1,11 @@
+export enum NineLayoutTag {
+ TOP_LEFT = 'top-left top left layout-region',
+ TOP_MIDDLE = 'top-mid mid top layout-region',
+ TOP_RIGHT = 'top-right top right layout-region',
+ MIDDLE_LEFT = 'mid-left mid left layout-region',
+ MIDDLE_MIDDLE = 'mid-mid mid layout-region',
+ MIDDLE_RIGHT = 'mid-right mid right layout-region',
+ BOTTOM_LEFT = 'bottom-left bottom left layout-region',
+ BOTTOM_MIDDLE = 'bottom-mid mid bottom layout-region',
+ BOTTOM_RIGHT = 'bottom-right bottom right layout-region',
+}
diff --git a/src/core/utils/addInterceptor.ts b/src/core/utils/addInterceptor.ts
new file mode 100644
index 000000000..0558db2a9
--- /dev/null
+++ b/src/core/utils/addInterceptor.ts
@@ -0,0 +1,32 @@
+export function addInterceptor(
+ secureServiceUrlRegex: RegExp,
+ interceptorHeadersCallback: () => Headers
+) {
+ // NOTE: Not applicable here.
+ // eslint-disable-next-line @typescript-eslint/unbound-method
+ const { fetch: originalFetch } = window
+
+ // If interceptors for XMLHttpRequest or axios are needed, add them here.
+ window.fetch = (resource, originalConfig) => {
+ let config = originalConfig
+ const interceptorHeaders = interceptorHeadersCallback()
+
+ if (
+ Object.keys(interceptorHeaders).length > 0 &&
+ typeof resource === 'string' &&
+ resource.match(secureServiceUrlRegex)
+ ) {
+ const headers = new Headers(originalConfig?.headers)
+ interceptorHeaders.entries().forEach(([key, value]) => {
+ headers.append(key, value)
+ })
+
+ config = {
+ ...originalConfig,
+ headers,
+ }
+ }
+
+ return originalFetch(resource, config)
+ }
+}
diff --git a/src/core/utils/checkServiceAvailability.ts b/src/core/utils/checkServiceAvailability.ts
new file mode 100644
index 000000000..6f1e36c77
--- /dev/null
+++ b/src/core/utils/checkServiceAvailability.ts
@@ -0,0 +1,59 @@
+import { ping } from '@masterportal/masterportalapi'
+import { t } from 'i18next'
+import type {
+ MapConfiguration,
+ MasterportalApiServiceRegister,
+ ServiceAvailabilityCheck,
+} from '../types'
+import { notifyUser } from '@/lib/notifyUser'
+
+export function checkServiceAvailability(
+ configuration: MapConfiguration,
+ register: MasterportalApiServiceRegister
+) {
+ configuration.layers
+ .map(({ id }) => ({
+ id,
+ service: register.find(({ id: serviceId }) => serviceId === id),
+ }))
+ .filter(
+ (
+ service
+ ): service is { id: string; service: Record } => {
+ if (!service.service) {
+ console.warn(
+ `Service with id "${service.id}" not found in service register.`
+ )
+ return false
+ }
+ return true
+ }
+ )
+ .map(
+ ({ service }): ServiceAvailabilityCheck => ({
+ ping: ping(service),
+ serviceId: service.id as string,
+ serviceName: service.name as string,
+ })
+ )
+ .forEach(({ ping, serviceId, serviceName }) => {
+ ping
+ .then((statusCode) => {
+ if (statusCode !== 200) {
+ notifyUser('warning', () =>
+ t(($) => $.error.serviceUnavailable, {
+ ns: 'core',
+ serviceId,
+ serviceName,
+ })
+ )
+
+ // always print status code for debugging purposes
+ console.error(`Ping to "${serviceId}" returned "${statusCode}".`)
+ }
+ })
+ .catch((e: unknown) => {
+ console.error(e)
+ })
+ })
+}
diff --git a/packages/core/src/utils/constants.ts b/src/core/utils/constants.ts
similarity index 100%
rename from packages/core/src/utils/constants.ts
rename to src/core/utils/constants.ts
diff --git a/src/core/utils/defaults.ts b/src/core/utils/defaults.ts
new file mode 100644
index 000000000..1a0c5cb4e
--- /dev/null
+++ b/src/core/utils/defaults.ts
@@ -0,0 +1,51 @@
+import type { MasterportalApiConfiguration, PartialBy } from '../types'
+
+// Default configuration parameters for @masterportal/masterportalapi
+export default {
+ epsg: 'EPSG:25832',
+ options: [
+ { resolution: 66.14579761460263, scale: 250000, zoomLevel: 0 },
+ { resolution: 26.458319045841044, scale: 100000, zoomLevel: 1 },
+ { resolution: 15.874991427504629, scale: 60000, zoomLevel: 2 },
+ { resolution: 10.583327618336419, scale: 40000, zoomLevel: 3 },
+ { resolution: 5.2916638091682096, scale: 20000, zoomLevel: 4 },
+ { resolution: 2.6458319045841048, scale: 10000, zoomLevel: 5 },
+ { resolution: 1.3229159522920524, scale: 5000, zoomLevel: 6 },
+ { resolution: 0.6614579761460262, scale: 2500, zoomLevel: 7 },
+ { resolution: 0.2645831904584105, scale: 1000, zoomLevel: 8 },
+ { resolution: 0.1322915952292052, scale: 500, zoomLevel: 9 },
+ ],
+ namedProjections: [
+ [
+ 'EPSG:25832',
+ '+proj=utm +zone=32 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
+ ],
+ [
+ 'EPSG:3857',
+ '+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs',
+ ],
+ [
+ 'EPSG:4326',
+ '+title=WGS 84 (long/lat) +proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs',
+ ],
+ [
+ 'EPSG:31467',
+ '+proj=tmerc +lat_0=0 +lon_0=9 +k=1 +x_0=3500000 +y_0=0 +ellps=bessel +towgs84=598.1,73.7,418.2,0.202,0.045,-2.455,6.70 +units=m +no_defs',
+ ],
+ [
+ 'EPSG:4647',
+ '+proj=tmerc +lat_0=0 +lon_0=9 +k=0.9996 +x_0=32500000 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs',
+ ],
+ ],
+ startResolution: 15.874991427504629,
+} as PartialBy<
+ // The type is this weird as CoreState.configuration has some values required ...
+ MasterportalApiConfiguration &
+ Required<
+ Pick<
+ MasterportalApiConfiguration,
+ 'epsg' | 'namedProjections' | 'options' | 'startResolution'
+ >
+ >,
+ 'startCenter'
+>
diff --git a/src/core/utils/export/createMap.ts b/src/core/utils/export/createMap.ts
new file mode 100644
index 000000000..c7ada099c
--- /dev/null
+++ b/src/core/utils/export/createMap.ts
@@ -0,0 +1,68 @@
+import { fetchServiceRegister, register } from '@/core'
+import type {
+ MapConfiguration,
+ MasterportalApiServiceRegister,
+} from '@/core/types'
+
+/**
+ * Creates an HTML map element with a given configuration.
+ *
+ * Instead of calling this function, you may also create the element yourself.
+ * Creating the element yourself yields benefits especially when you use a framework
+ * that is more used to creating elements itself and adding properties to them.
+ *
+ * Remember to always call `register` first.
+ *
+ * @privateRemarks
+ * In earlier versions of POLAR, this function did a lot of magic.
+ * However, the magic moved to the custom element itself, therefore, you may create the element by yourself now.
+ *
+ * @param mapConfiguration - Configuration options.
+ * @param serviceRegister - Service register.
+ */
+export function createMapElement(
+ mapConfiguration: MapConfiguration,
+ serviceRegister: MasterportalApiServiceRegister
+) {
+ // @ts-expect-error | We trust that the element is registered
+ const map = document.createElement('polar-map') as typeof PolarContainer
+ map.mapConfiguration = mapConfiguration
+ map.serviceRegister = serviceRegister
+ return map
+}
+
+/**
+ * Creates an HTML map element with a given configuration and inserts this at a given ID.
+ *
+ * This is a convenience function that combines `register`, `createMap` and `fetchServiceRegister`.
+ *
+ * It inserts the map element by replacing the element with the given ID.
+ * The ID and the classes of the container are transferred to the map element.
+ *
+ * @param mapConfiguration - Configuration options.
+ * @param serviceRegister - Service register given as an array, or an URL to fetch this from.
+ */
+export async function createMap(
+ containerId: string,
+ mapConfiguration: MapConfiguration,
+ serviceRegister: MasterportalApiServiceRegister | string
+) {
+ if (!customElements.get('polar-map')) {
+ register()
+ }
+
+ if (typeof serviceRegister === 'string') {
+ serviceRegister = await fetchServiceRegister(serviceRegister)
+ }
+
+ const map = createMapElement(mapConfiguration, serviceRegister)
+
+ const container = document.getElementById(containerId)
+ if (!container) {
+ throw new Error(`Container with ID '${containerId}' not found`)
+ }
+ map.id = container.id
+ container.classList.forEach((c) => map.classList.add(c))
+ container.replaceWith(map as unknown as HTMLElement)
+ return map
+}
diff --git a/src/core/utils/export/plugin.ts b/src/core/utils/export/plugin.ts
new file mode 100644
index 000000000..eddced0ab
--- /dev/null
+++ b/src/core/utils/export/plugin.ts
@@ -0,0 +1,63 @@
+import type { PluginContainer, PolarContainer } from '@/core'
+
+/**
+ * Calls `addPlugin` for each plugin in the array.
+ *
+ * @param map - Map to add the plugin to.
+ * @param plugins - Plugins to be added.
+ */
+export function addPlugins(
+ map: typeof PolarContainer,
+ plugins: PluginContainer[]
+) {
+ plugins.forEach((plugin) => {
+ addPlugin(map, plugin)
+ })
+}
+
+/**
+ * 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.
+ *
+ * You may add further plugins or proceed with `createMap`.
+ *
+ * Please note that the order of certain plugins is relevant when other plugins are referenced,
+ * e.g. `@polar/plugin-gfi`'s `coordinateSources` requires the configured sources to have previously been set up.
+ *
+ * In case you're integrating new plugins, call `addPlugin` with a plugin instance.
+ *
+ * If you want to add multiple plugins at once, you can use `addPlugins` instead.
+ *
+ * @example
+ * ```
+ * addPlugin(Plugin(pluginOptions: PluginOptions))
+ * ```
+ *
+ * @remarks
+ * In case you're writing a new plugin, it must fulfill the following API:
+ * ```
+ * const Plugin = (options: PluginOptions): PluginContainer => ({
+ * id,
+ * component,
+ * locales,
+ * options,
+ * storeModule,
+ * })
+ * ```
+ *
+ * @param map - Map to add the plugin to.
+ * @param plugin - Plugin to be added.
+ */
+export function addPlugin(map: typeof PolarContainer, plugin: PluginContainer) {
+ map.store.addPlugin(plugin)
+}
+
+/**
+ * Remove a plugin from a map by its ID.
+ *
+ * @param map - Map to remove the plugin from.
+ * @param pluginId - ID of the plugin to be removed.
+ */
+export function removePlugin(map: typeof PolarContainer, pluginId: string) {
+ map.store.removePlugin(pluginId)
+}
diff --git a/src/core/utils/export/store.ts b/src/core/utils/export/store.ts
new file mode 100644
index 000000000..455864d79
--- /dev/null
+++ b/src/core/utils/export/store.ts
@@ -0,0 +1,85 @@
+import { watch, type WatchOptions } from 'vue'
+import type { PolarContainer } from '@/core'
+import type { useCoreStore } from '@/core/stores/export'
+import type {
+ BundledPluginId,
+ BundledPluginStores,
+ PluginId,
+ PolarPluginStore,
+} from '@/core/types'
+
+export type SubscribeCallback = (value: unknown, oldValue: unknown) => void
+
+type StoreType = T extends BundledPluginId
+ ? ReturnType>
+ : ReturnType
+type ExtractStateAndGetters =
+ | keyof T['$state']
+ | {
+ [K in keyof T]: T[K] extends (..._args) => unknown
+ ? never
+ : K extends `$${string}` | `_${string}`
+ ? never
+ : K
+ }[keyof T]
+type StoreId = 'core' | PluginId
+type StoreParameter = T extends 'core' | BundledPluginId
+ ? ExtractStateAndGetters>
+ : string
+
+/**
+ * Returns the store module for the core or for an active plugin.
+ *
+ * @param map - Map to get the corresponding store for.
+ * @param storeName - Either `'core'` for the core store or the plugin ID for a plugin's store.
+ * @returns Core store for the map if `'core'` is given, or the plugin's store else.
+ */
+export function getStore(
+ map: typeof PolarContainer,
+ storeName: T
+): T extends 'core' | BundledPluginId ? StoreType : PolarPluginStore {
+ return storeName === 'core' ? map.store : map.store.getPluginStore(storeName)
+}
+
+/**
+ * Subscribe to a store value of the core store or any plugin's store.
+ *
+ * @param map - Map to subscribe the value at.
+ * @param storeName - Either `'core'` for the core store or the plugin ID for a plugin's store.
+ * @param parameterName - Name of the parameter to update.
+ * @param callback - Function to call on updates.
+ * @param options - Additional options to give to `watch`.
+ */
+export function subscribe(
+ map: typeof PolarContainer,
+ storeName: T,
+ parameterName: StoreParameter,
+ callback: SubscribeCallback,
+ options?: WatchOptions
+) {
+ const store = getStore(map, storeName)
+ // @ts-expect-error | Parameter name is checked, but TS does not infer this
+ return watch(() => store[parameterName], callback, {
+ immediate: true,
+ ...options,
+ })
+}
+
+/**
+ * Updates the parameter {@link parameterName | parameter} in the {@link storeName | store} with the {@link payload}.
+ *
+ * @param map - Map to update the value at.
+ * @param storeName - Either `'core'` for the core store or the plugin ID for a plugin's store.
+ * @param parameterName - Name of the parameter to update.
+ * @param payload - The payload to update the given parameter with.
+ */
+export function updateState(
+ map: typeof PolarContainer,
+ storeName: T,
+ parameterName: StoreParameter,
+ payload: unknown
+) {
+ const store = getStore(map, storeName)
+ // @ts-expect-error | Parameter name is checked, but TS does not infer this
+ store[parameterName] = payload
+}
diff --git a/src/core/utils/fetchServiceRegister.ts b/src/core/utils/fetchServiceRegister.ts
new file mode 100644
index 000000000..399dcf1a9
--- /dev/null
+++ b/src/core/utils/fetchServiceRegister.ts
@@ -0,0 +1,8 @@
+import { rawLayerList } from '@masterportal/masterportalapi'
+import type { MasterportalApiServiceRegister } from '../types'
+
+export async function fetchServiceRegister(url: string) {
+ return await new Promise((resolve) =>
+ rawLayerList.initializeLayerList(url, resolve)
+ )
+}
diff --git a/src/core/utils/interactions.ts b/src/core/utils/interactions.ts
new file mode 100644
index 000000000..2b0b5c667
--- /dev/null
+++ b/src/core/utils/interactions.ts
@@ -0,0 +1,62 @@
+import { platformModifierKeyOnly } from 'ol/events/condition'
+import {
+ DragPan,
+ KeyboardPan,
+ KeyboardZoom,
+ MouseWheelZoom,
+} from 'ol/interaction'
+import type { MapBrowserEvent } from 'ol'
+
+/**
+ * Desktop:
+ * - LeftHold: Pan
+ * - CTRL + Mousewheel: Zoom
+ * - Mousewheel: Scroll page
+ *
+ * Mobile:
+ * - 1 finger: Scroll page
+ * - 2 fingers: Zoom/Pan
+ *
+ * @param hasWindowSize - Whether the client is being rendered in the same size as the window.
+ * @param hasSmallScreen - Whether the user utilizes a device with a small screen.
+ */
+export function createPanAndZoomInteractions(
+ hasWindowSize: boolean,
+ hasSmallScreen: boolean
+) {
+ if (hasWindowSize) {
+ return [new DragPan(), new MouseWheelZoom()]
+ }
+ return [
+ new DragPan({
+ condition: function () {
+ // @ts-expect-error | As the DragPan is added to the interactions of the map, the 'this' context of the condition function should always be defined.
+ return hasSmallScreen ? this.getPointerCount() > 1 : true
+ },
+ }),
+ new MouseWheelZoom({
+ condition: platformModifierKeyOnly,
+ }),
+ ]
+}
+
+/**
+ * Modified version of `ol/events/condition.js#targetNotEditable` that is able
+ * to correctly detect editable elements inside a ShadowDOM, as needed in this
+ * situation.
+ */
+function targetNotEditable(mapBrowserEvent: MapBrowserEvent) {
+ const target = mapBrowserEvent.originalEvent.composedPath()[0] as HTMLElement
+ const { tagName } = target
+ return (
+ tagName !== 'INPUT' &&
+ tagName !== 'SELECT' &&
+ tagName !== 'TEXTAREA' &&
+ !target.isContentEditable
+ )
+}
+
+export const createKeyboardInteractions = () => [
+ new KeyboardPan({ condition: targetNotEditable }),
+ new KeyboardZoom({ condition: targetNotEditable }),
+]
diff --git a/src/core/utils/loadKern.ts b/src/core/utils/loadKern.ts
new file mode 100644
index 000000000..0bf833d1b
--- /dev/null
+++ b/src/core/utils/loadKern.ts
@@ -0,0 +1,47 @@
+import kernCss from '@kern-ux/native/dist/kern.min.css?raw'
+import kernExtraIcons from 'virtual:kern-extra-icons'
+import type { KernTheme, KernThemeTree } from '../types'
+
+function flattenKernTheme(theme: KernThemeTree, prefix: string[] = []) {
+ return Object.entries(theme).flatMap(([k, v]) => {
+ const keys = [...prefix, k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())]
+ if (typeof v === 'string') {
+ return [[`kern-${keys.join('-')}`, v]]
+ }
+ return flattenKernTheme(v, keys)
+ })
+}
+
+function buildKernTheme(theme: Partial): CSSStyleSheet {
+ const sheet = new CSSStyleSheet()
+ const flatTheme = flattenKernTheme(theme)
+ sheet.replaceSync(`
+ @layer kern-ux-theme {
+ :host {
+ ${flatTheme.map(([k, v]) => `--${k}: ${v} !important;`).join('\n')}
+ }
+ }
+ `)
+ return sheet
+}
+
+export function loadKern(host: ShadowRoot, theme: Partial = {}) {
+ host.adoptedStyleSheets.push(kernExtraIcons)
+
+ const kernSheet = new CSSStyleSheet()
+ kernSheet.replaceSync(`
+ @layer kern-ux {
+ ${kernCss.replaceAll(':root', ':host')}
+ }
+ `)
+ host.adoptedStyleSheets.push(kernSheet)
+
+ const kernTheme = buildKernTheme(theme)
+ host.adoptedStyleSheets.push(kernTheme)
+}
+
+if (import.meta.hot) {
+ import.meta.hot.on('kern-extra-icons', ({ icons }) => {
+ icons.forEach((icon) => kernExtraIcons.insertRule(icon))
+ })
+}
diff --git a/src/core/utils/map/setupMarkers.ts b/src/core/utils/map/setupMarkers.ts
new file mode 100644
index 000000000..53099e1c3
--- /dev/null
+++ b/src/core/utils/map/setupMarkers.ts
@@ -0,0 +1,344 @@
+import { toMerged } from 'es-toolkit'
+import { Feature, Map, MapBrowserEvent, MapEvent } from 'ol'
+import { createEmpty, extend } from 'ol/extent'
+import type BaseLayer from 'ol/layer/Base'
+import VectorLayer from 'ol/layer/Vector'
+import RenderFeature from 'ol/render/Feature'
+import Cluster from 'ol/source/Cluster'
+import VectorSource from 'ol/source/Vector'
+import { watch, markRaw, toRaw } from 'vue'
+import type { MarkerLayer, MarkerStyle, PluginId } from '../../types'
+import { getMarkerStyle } from '../../utils/markers'
+import { useMainStore } from '../../stores/main'
+import { isVisible } from '@/lib/invisibleStyle'
+import getCluster from '@/lib/getCluster'
+import { useMarkerStore } from '@/core/stores/marker'
+import { usePluginStore } from '@/core/stores/plugin'
+
+// these have been measured to fit once and influence marker size
+const imgSize: [number, number] = [26, 36]
+const imgSizeMulti: [number, number] = [40, 36]
+
+const defaultStroke = '#FFFFFF'
+const defaultStrokeWidth = '2'
+
+const defaultStyle: MarkerStyle = {
+ clusterSize: imgSizeMulti,
+ fill: '#005CA9',
+ size: imgSize,
+ stroke: defaultStroke,
+ strokeWidth: defaultStrokeWidth,
+}
+const hoverStyle: MarkerStyle = {
+ clusterSize: imgSizeMulti,
+ fill: '#7B1045',
+ size: imgSize,
+ stroke: defaultStroke,
+ strokeWidth: defaultStrokeWidth,
+}
+const selectionStyle: MarkerStyle = {
+ clusterSize: imgSizeMulti,
+ fill: '#679100',
+ size: imgSize,
+ stroke: defaultStroke,
+ strokeWidth: defaultStrokeWidth,
+}
+const unselectableStyle: MarkerStyle = {
+ clusterSize: imgSizeMulti,
+ fill: '#333333',
+ size: imgSize,
+ stroke: defaultStroke,
+ strokeWidth: defaultStrokeWidth,
+}
+
+let layers: MarkerLayer[] = []
+
+let lastZoom = 0
+
+// As this function is only internally used, it is expected that a layer is found.
+function getLayerConfiguration(id: string) {
+ return layers.find((layer) => layer.id === id) as MarkerLayer
+}
+
+function layerFilter(layer: BaseLayer) {
+ return layers.some(({ id }) => id === (layer.get('id') as string))
+}
+
+function findLayer(map: Map, layerId: string) {
+ return map
+ .getLayers()
+ .getArray()
+ .find((layer) => layer.get('id') === layerId) as VectorLayer | undefined
+}
+
+function resolveClusterClick(map: Map, feature: Feature) {
+ const features = feature.get('features') as Feature[]
+
+ const extent = createEmpty()
+ features.forEach((feature) =>
+ extend(extent, feature.getGeometry()?.getExtent() || [])
+ )
+
+ map.getView().fit(extent, {
+ duration: 400,
+ padding: [80, 30, 80, 30],
+ })
+}
+
+function updateSelection(
+ map: Map,
+ feature: Feature | null,
+ centerOnFeature = false
+) {
+ const store = useMarkerStore()
+
+ store.selected?.setStyle(undefined)
+ store.selected = null
+
+ if (feature === null) {
+ return
+ }
+
+ const layerId = feature.get('_polarLayerId') as string
+ const selectedCluster =
+ // @ts-expect-error | Found layers always have a source and getDistance is defined on cluster sources.
+ typeof findLayer(map, layerId)?.getSource().getDistance === 'function'
+ ? getCluster(map, feature, '_polarLayerId')
+ : feature
+
+ selectedCluster.setStyle(
+ getMarkerStyle(
+ getLayerConfiguration(feature.get('_polarLayerId') as string)
+ .selectionStyle,
+ selectedCluster.get('features')?.length > 1
+ )
+ )
+
+ store.selected = markRaw(selectedCluster)
+ if (centerOnFeature) {
+ const mainStore = useMainStore()
+ mainStore.centerOnFeature(store.selected as Feature)
+ }
+}
+
+function setLayerId(map: Map, feature: Feature) {
+ if (feature.get('_polarLayerId')) {
+ return
+ }
+
+ const layerId = map
+ .getLayers()
+ .getArray()
+ .find((layer) => {
+ if (layer instanceof VectorLayer) {
+ let step: VectorLayer | VectorSource | Cluster = layer
+ while (step instanceof VectorLayer || step instanceof Cluster) {
+ // @ts-expect-error | Clusters in masterportalapi always have a source.
+ step = step.getSource()
+ // @ts-expect-error | It's not a vector layer anymore.
+ if (step.hasFeature(feature)) {
+ return true
+ }
+ }
+ return step.hasFeature(feature)
+ }
+ return false
+ })
+ ?.get('id') as string | undefined
+ if (layerId) {
+ feature.set('_polarLayerId', layerId, true)
+ }
+}
+
+export function setupMarkers(map: Map) {
+ const store = useMarkerStore()
+ const configuration = store.configuration
+ if (!configuration) {
+ return
+ }
+
+ layers = configuration.layers.map((layer) =>
+ toMerged(
+ {
+ defaultStyle,
+ hoverStyle,
+ selectionStyle,
+ unselectableStyle,
+ isSelectable: () => true,
+ },
+ layer
+ )
+ )
+
+ lastZoom = map.getView().getZoom() as number
+
+ map
+ .getLayers()
+ .getArray()
+ .filter(layerFilter)
+ .forEach((layer) => {
+ // only vector layers reach this
+ const source = (layer as VectorLayer).getSource()
+ if (source !== null) {
+ // @ts-expect-error | Undocumented hook.
+ source.geometryFunction =
+ // prevents features from jumping due to invisible features "pulling"
+ (feature: Feature) =>
+ isVisible(feature) ? feature.getGeometry() : null
+ }
+ const layerConfiguration = getLayerConfiguration(
+ layer.get('id') as string
+ )
+ ;(layer as VectorLayer).setStyle((feature) =>
+ getMarkerStyle(
+ layerConfiguration.isSelectable(feature as Feature)
+ ? layerConfiguration.defaultStyle
+ : layerConfiguration.unselectableStyle,
+ feature.get('features')?.length > 1
+ )
+ )
+ })
+
+ // // // STORE EVENT HANDLING
+
+ watch(
+ () => store.hovered,
+ (feature) => {
+ if (feature !== null && feature !== toRaw(store.selected)) {
+ store.hovered?.setStyle(undefined)
+ store.hovered = null
+ }
+ if (feature !== null && feature !== toRaw(store.selected)) {
+ store.hovered = markRaw(feature)
+ const isMultiFeature = store.hovered.get('features')?.length > 1
+ const style = getMarkerStyle(
+ getLayerConfiguration(feature.get('_polarLayerId') as string)
+ .hoverStyle,
+ isMultiFeature
+ )
+ store.hovered.setStyle(style)
+ }
+ }
+ )
+
+ map.on('moveend', mapMoveEnd)
+ map.on('pointermove', mapPointerMove)
+ map.on('click', mapClick)
+
+ /*
+ * click leads to singlelick; if an element is selected,
+ * to not let other plugins pick it up, something was already done with it
+ */
+ map.on('singleclick', mapSingleClick)
+}
+
+// // // MAP EVENT HANDLING
+
+let lastClickEvent: MapBrowserEvent | null = null
+
+function mapMoveEnd({ map }: MapEvent) {
+ const store = useMarkerStore()
+ const zoom = map.getView().getZoom() as number
+ if (zoom !== lastZoom) {
+ lastZoom = zoom
+ if (store.selected) {
+ const baseFeature = (store.selected.get('features')?.[0] ||
+ store.selected) as Feature
+ setLayerId(map, baseFeature)
+ updateSelection(map, baseFeature)
+ }
+ }
+}
+
+function mapPointerMove({ map, pixel }: MapBrowserEvent) {
+ const store = useMarkerStore()
+ const feature = map.getFeaturesAtPixel(pixel, {
+ layerFilter,
+ })[0]
+
+ if (feature === toRaw(store.selected) || feature instanceof RenderFeature) {
+ return
+ }
+ if (
+ toRaw(store.hovered) !== null &&
+ toRaw(store.hovered) !== toRaw(store.selected)
+ ) {
+ store.hovered?.setStyle(undefined)
+ store.hovered = null
+ }
+
+ if (!feature) {
+ return
+ }
+ setLayerId(map, feature)
+ const layerConfiguration = getLayerConfiguration(
+ feature.get('_polarLayerId') as string
+ )
+ if (!layerConfiguration.isSelectable(feature)) {
+ return
+ }
+ const isMultiFeature = feature.get('features')?.length > 1
+ feature.setStyle(
+ getMarkerStyle(layerConfiguration.hoverStyle, isMultiFeature)
+ )
+ store.hovered = markRaw(feature)
+}
+
+function mapClick(event: MapBrowserEvent) {
+ const store = useMarkerStore()
+ const map = event.map
+ if (store.selected !== null) {
+ updateSelection(map, null)
+ }
+ const feature = map.getFeaturesAtPixel(event.pixel, { layerFilter })[0]
+
+ if (!feature || feature instanceof RenderFeature) {
+ return
+ }
+ setLayerId(map, feature)
+ const layerConfiguration = getLayerConfiguration(
+ feature.get('_polarLayerId') as string
+ )
+ if (!layerConfiguration.isSelectable(feature)) {
+ return
+ }
+
+ const isMultiFeature = feature.get('features')?.length > 1
+ lastClickEvent = event
+ event.stopPropagation()
+
+ const isMaxZoom = map.getView().getZoom() !== map.getView().getMaxZoom()
+ if (store.clusterClickZoom && isMultiFeature && isMaxZoom) {
+ resolveClusterClick(map, feature)
+ return
+ }
+
+ store.hovered?.setStyle(undefined)
+ store.hovered = null
+ updateSelection(map, feature, true)
+
+ if (store.callOnMapSelect) {
+ const mainStore = useMainStore()
+ const { action, payload, pluginName } = store.callOnMapSelect
+ if (!pluginName) {
+ mainStore[action](payload)
+ return
+ }
+
+ const pluginListStore = usePluginStore()
+ const pluginStore = pluginListStore.getPluginStore(pluginName as PluginId)
+ if (!pluginStore) {
+ console.error(
+ `Plugin ${pluginName} does not exist or is not configured or has no store module. Action ${action} could not be called.`
+ )
+ return
+ }
+ pluginStore[action](payload)
+ }
+}
+
+function mapSingleClick(event: MapBrowserEvent) {
+ if (event.originalEvent === lastClickEvent?.originalEvent) {
+ event.stopPropagation()
+ }
+}
diff --git a/src/core/utils/map/setupStyling.ts b/src/core/utils/map/setupStyling.ts
new file mode 100644
index 000000000..23fa72ce7
--- /dev/null
+++ b/src/core/utils/map/setupStyling.ts
@@ -0,0 +1,65 @@
+import type { Feature, Map } from 'ol'
+import createStyle from '@masterportal/masterportalapi/src/vectorStyle/createStyle'
+import styleList from '@masterportal/masterportalapi/src/vectorStyle/styleList'
+import noop from '@repositoryname/noop'
+import type VectorLayer from 'ol/layer/Vector'
+import type { FeatureLike } from 'ol/Feature'
+import type {
+ MapConfiguration,
+ MasterportalApiServiceRegister,
+} from '@/core/types'
+
+export async function setupStyling(
+ map: Map,
+ configuration: MapConfiguration,
+ register: MasterportalApiServiceRegister
+) {
+ if (configuration.featureStyles && Array.isArray(register)) {
+ await styleList.initializeStyleList(
+ // Masterportal specific field not required by POLAR
+ {},
+ { styleConf: configuration.featureStyles },
+ configuration.layers.map((layer) => {
+ const layerConfig = register.find((l) => l.id === layer.id)
+ if (layerConfig) {
+ return {
+ ...layer,
+ // Required by @masterportal/masterportalapi
+ typ: layerConfig.typ,
+ }
+ }
+ return layer
+ }),
+ // Masterportal specific field not required by POLAR
+ [],
+ // Callback currently yields no relevant benefit
+ noop
+ )
+ // A layer can either be styled through the provided styles or through the markers configuration; markers takes precedence.
+ const markerLayers = configuration.markers
+ ? configuration.markers.layers.map(({ id }) => id)
+ : []
+ map
+ .getLayers()
+ .getArray()
+ .filter(
+ (layer) =>
+ typeof layer.get('styleId') === 'string' &&
+ !markerLayers.includes(layer.get('id') as string)
+ )
+ .forEach((layer) => {
+ const styleObject = styleList.returnStyleObject(layer.get('styleId'))
+ if (styleObject) {
+ ;(layer as VectorLayer).setStyle((feature: Feature | FeatureLike) =>
+ createStyle.createStyle(
+ styleObject,
+ feature,
+ feature.get('features') !== undefined,
+ // NOTE: This field may be implemented in the future to be able to style points with graphics
+ ''
+ )
+ )
+ }
+ })
+ }
+}
diff --git a/src/core/utils/map/updateDragAndZoomInteractions.ts b/src/core/utils/map/updateDragAndZoomInteractions.ts
new file mode 100644
index 000000000..5eb676ce7
--- /dev/null
+++ b/src/core/utils/map/updateDragAndZoomInteractions.ts
@@ -0,0 +1,19 @@
+import type { Map } from 'ol'
+import type { Interaction } from 'ol/interaction'
+import { createPanAndZoomInteractions } from '../interactions'
+
+let interactions: Interaction[] = []
+
+export function updateDragAndZoomInteractions(
+ map: Map,
+ hasWindowSize: boolean,
+ hasSmallScreen: boolean
+) {
+ for (const interaction of interactions) {
+ map.removeInteraction(interaction)
+ }
+ interactions = createPanAndZoomInteractions(hasWindowSize, hasSmallScreen)
+ for (const interaction of interactions) {
+ map.addInteraction(interaction)
+ }
+}
diff --git a/packages/core/src/utils/mapZoomOffset.ts b/src/core/utils/mapZoomOffset.ts
similarity index 50%
rename from packages/core/src/utils/mapZoomOffset.ts
rename to src/core/utils/mapZoomOffset.ts
index 5ef74392e..d89c5c313 100644
--- a/packages/core/src/utils/mapZoomOffset.ts
+++ b/src/core/utils/mapZoomOffset.ts
@@ -1,4 +1,4 @@
-import { MapConfig } from '@polar/lib-custom-types'
+import type { MapConfiguration } from '../types'
/**
* NOTE This is a workaround addressing the recent change in `minZoom` logic in
@@ -10,17 +10,13 @@ import { MapConfig } from '@polar/lib-custom-types'
* forwarding the configuration to the value usage intended in POLAR; that is,
* inclusive.
*/
-export const mapZoomOffset = (mapConfiguration: MapConfig): MapConfig => {
- if (mapConfiguration.layers) {
- return {
- ...mapConfiguration,
- layers: mapConfiguration.layers.map((entry) => {
- if (typeof entry.minZoom !== 'undefined') {
- return { ...entry, minZoom: entry.minZoom - 1 }
- }
- return entry
- }),
- }
- }
- return mapConfiguration
-}
+export const mapZoomOffset = (
+ mapConfiguration: MapConfiguration
+): MapConfiguration => ({
+ ...mapConfiguration,
+ layers: mapConfiguration.layers.map((entry) =>
+ typeof entry.minZoom !== 'undefined'
+ ? { ...entry, minZoom: entry.minZoom - 1 }
+ : entry
+ ),
+})
diff --git a/src/core/utils/markers.ts b/src/core/utils/markers.ts
new file mode 100644
index 000000000..627266a0a
--- /dev/null
+++ b/src/core/utils/markers.ts
@@ -0,0 +1,101 @@
+import PolygonStyle from '@masterportal/masterportalapi/src/vectorStyle/styles/polygon/stylePolygon'
+import Style from 'ol/style/Style'
+import Icon from 'ol/style/Icon'
+import type { MarkerStyle } from '../types'
+
+const polygonStyle = new PolygonStyle()
+
+type GetMarkerFunction = (style: MarkerStyle, multi: boolean) => Style
+
+const prefix = 'data:image/svg+xml,'
+
+const getImagePattern = (fill: MarkerStyle['fill']) =>
+ typeof fill === 'string'
+ ? ''
+ : `
+
+
+
+ `
+
+/* Path of marker svg used in this file copied and adapted from
+ * @masterportal/masterportalapi/public/marker.svg. */
+
+const makeMarker = ({ fill, size, stroke, strokeWidth }: MarkerStyle) =>
+ `${prefix}${encodeURIComponent(`
+
+ DB6C494E-88E8-49F1-89CE-97CBEC3A5240
+ ${getImagePattern(fill)}
+
+
+`)}`
+
+const makeMultiMarker = ({
+ fill,
+ clusterSize,
+ stroke,
+ strokeWidth,
+}: MarkerStyle) =>
+ `${prefix}${encodeURIComponent(`
+
+ 0A6F4952-4A5A-4E86-88E4-4B3D2EA1E3DF
+ ${getImagePattern(fill)}
+
+
+
+
+
+
+`)}`
+
+// center bottom of marker 📍 is intended to show the spot
+const anchor = [0.5, 1]
+
+/**
+ * The map became a little laggy due to constant re-generation of styles.
+ * This memoization function optimises this issue by reusing styles.
+ * */
+const memoizeStyle = (getMarker: GetMarkerFunction): GetMarkerFunction => {
+ const singleCache = new Map()
+ const multiCache = new Map()
+ return (style, multi) => {
+ const cache = multi ? multiCache : singleCache
+ if (cache.has(style)) {
+ return cache.get(style)
+ }
+ const markerStyle = getMarker(style, multi)
+ cache.set(style, markerStyle)
+ if (cache.size > 1000) {
+ console.warn(
+ `1000+ styles have been created. This is possibly a memory leak. Please mind that the methods exported by this module are memoized. You *may* be calling the methods with constantly newly generated objects, or maybe there's just a lot of styles.`
+ )
+ }
+ return markerStyle
+ }
+}
+
+const getStyleFunction: GetMarkerFunction = (style, multi = false) =>
+ new Style({
+ image: new Icon({
+ src: (multi ? makeMultiMarker : makeMarker)(style),
+ anchor,
+ }),
+ })
+
+export const getMarkerStyle = memoizeStyle(getStyleFunction)
diff --git a/src/core/vuePlugins/i18next.ts b/src/core/vuePlugins/i18next.ts
new file mode 100644
index 000000000..54bb1fcc2
--- /dev/null
+++ b/src/core/vuePlugins/i18next.ts
@@ -0,0 +1,35 @@
+import i18next from 'i18next'
+import LanguageDetector from 'i18next-browser-languagedetector'
+import I18NextVue from 'i18next-vue'
+import type { Plugin } from 'vue'
+import locales from '../locales'
+
+export const CoreId = 'core'
+
+export const I18Next: Plugin = {
+ async install(app) {
+ app.use(I18NextVue, { i18next })
+
+ i18next.use(LanguageDetector)
+ try {
+ await i18next.init({
+ resources: Object.fromEntries(
+ locales.map(({ type, resources }) => [type, { core: resources }])
+ ),
+ detection: {
+ lookupQuerystring: 'lng',
+ order: ['querystring', 'navigator', 'htmlTag'],
+ },
+ load: 'languageOnly',
+ fallbackLng: locales[0]?.type,
+ ns: [CoreId],
+ supportedLngs: locales.map(({ type }) => type),
+ })
+
+ // eslint-disable-next-line no-console
+ console.info(`Successfully initialized i18next.`)
+ } catch (error: unknown) {
+ console.error('Error while initializing:', error)
+ }
+ },
+}
diff --git a/src/core/vuePlugins/pinia.ts b/src/core/vuePlugins/pinia.ts
new file mode 100644
index 000000000..a6fd07cdb
--- /dev/null
+++ b/src/core/vuePlugins/pinia.ts
@@ -0,0 +1,13 @@
+import type { Plugin } from 'vue'
+import { createPinia } from 'pinia'
+import { saveInstance } from '../piniaPlugins/saveInstance'
+import { actionLogger } from '../piniaPlugins/actionLogger'
+
+export const Pinia: Plugin = {
+ install(app) {
+ const pinia = createPinia()
+ pinia.use(saveInstance)
+ pinia.use(actionLogger)
+ app.use(pinia)
+ },
+}
diff --git a/src/lib/computedT.ts b/src/lib/computedT.ts
new file mode 100644
index 000000000..087901010
--- /dev/null
+++ b/src/lib/computedT.ts
@@ -0,0 +1,14 @@
+import { computed } from 'vue'
+import { useCoreStore } from '@/core/stores/export'
+
+export function computedT(translator: () => string) {
+ return computed(() => {
+ const coreStore = useCoreStore()
+
+ // This reactive value needs to recompute on language changes.
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ coreStore.language
+
+ return translator()
+ })
+}
diff --git a/src/lib/getCluster.ts b/src/lib/getCluster.ts
new file mode 100644
index 000000000..766597577
--- /dev/null
+++ b/src/lib/getCluster.ts
@@ -0,0 +1,43 @@
+import { Feature, Map } from 'ol'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+
+/*
+ * Helper function to retrieve the related cluster of a feature.
+ * Returns the feature if it's a cluster feature, or the cluster the feature is in.
+ */
+export default function (map: Map, feature: Feature, layerId: string): Feature {
+ if (feature.get('features')) {
+ return feature
+ }
+
+ const layer = map
+ .getLayers()
+ .getArray()
+ .find((layer) => layer.get('id') === feature.get(layerId))
+
+ if (!(layer instanceof VectorLayer)) {
+ throw new Error(
+ `@polar/lib-get-cluster: The layer with the id ${layerId} either does not exist or is not a VectorLayer.`
+ )
+ }
+
+ // If the layer can be found, it has a source
+ const cluster = (layer.getSource() as VectorSource)
+ .getFeatures()
+ .find((candidate: Feature) => candidate.get('features').includes(feature))
+
+ if (!(cluster instanceof Feature)) {
+ throw new Error(
+ '@polar/lib-get-cluster: No cluster could be found for the given feature.'
+ )
+ }
+ // The given feature should be the last in the array, as it the one "above" all thus added last
+ cluster.set('features', [
+ ...cluster.get('features').filter((f: Feature) => f !== feature),
+ feature,
+ ])
+ // true = silent change (prevents cluster recomputation & rerender)
+ cluster.set(layerId, feature.get(layerId), true)
+ return cluster
+}
diff --git a/src/lib/getCssColor.ts b/src/lib/getCssColor.ts
new file mode 100644
index 000000000..4de5d3f57
--- /dev/null
+++ b/src/lib/getCssColor.ts
@@ -0,0 +1,17 @@
+import type { Color } from '@/core'
+
+export function getCssColor(color: Color): string {
+ if (typeof color === 'object') {
+ if ('oklch' in color) {
+ return `oklch(${color.oklch.l}, ${color.oklch.c}, ${color.oklch.h})`
+ }
+ if ('rgba' in color) {
+ if (color.rgba.a) {
+ return `rgba(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b}, ${color.rgba.a})`
+ }
+ return `rgb(${color.rgba.r}, ${color.rgba.g}, ${color.rgba.b})`
+ }
+ }
+
+ return color
+}
diff --git a/src/lib/indicateLoading.ts b/src/lib/indicateLoading.ts
new file mode 100644
index 000000000..2ad64db83
--- /dev/null
+++ b/src/lib/indicateLoading.ts
@@ -0,0 +1,16 @@
+import { useCoreStore } from '@/core/stores/export'
+let loaderKeyCounter = 0
+
+export function indicateLoading() {
+ const coreStore = useCoreStore()
+ const loadingIndicatorStore = coreStore.getPluginStore('loadingIndicator')
+ if (!loadingIndicatorStore) {
+ return () => {}
+ }
+
+ const loaderKey = `lib-indicate-loading-${loaderKeyCounter++}`
+ loadingIndicatorStore.addLoadingKey(loaderKey)
+ return () => {
+ loadingIndicatorStore.removeLoadingKey(loaderKey)
+ }
+}
diff --git a/src/lib/invisibleStyle.ts b/src/lib/invisibleStyle.ts
new file mode 100644
index 000000000..6393afe03
--- /dev/null
+++ b/src/lib/invisibleStyle.ts
@@ -0,0 +1,34 @@
+import Style from 'ol/style/Style'
+import { Feature } from 'ol'
+
+/*
+ * Exports a style for vector layer features that results in invisibility.
+ * Plugins that work with feature visibility ought to use this lib-functionality
+ * to keep them interoperable.
+ */
+
+/**
+ * Makes feature invisible.
+ * To remove the invisibility, set the style to `undefined` or another style.
+ *
+ * Example usage:
+ * feature.setStyle(InvisibleStyle)
+ *
+ */
+export const InvisibleStyle = new Style()
+
+/**
+ * Checks if a feature is invisible.
+ *
+ * @param feature - The feature to check.
+ */
+export const isInvisible = (feature: Feature) =>
+ feature.getStyle() === InvisibleStyle
+
+/**
+ * Checks if a feature is visible.
+ *
+ * @param feature - The feature to check.
+ */
+export const isVisible = (feature: Feature) =>
+ feature.getStyle() !== InvisibleStyle
diff --git a/src/lib/notifyUser.ts b/src/lib/notifyUser.ts
new file mode 100644
index 000000000..dfa14fc5b
--- /dev/null
+++ b/src/lib/notifyUser.ts
@@ -0,0 +1,20 @@
+import type { Ref } from 'vue'
+import { useCoreStore } from '@/core/stores/export'
+import { computedT } from '@/lib/computedT'
+import { type ToastOptions } from '@/plugins/toast'
+
+export function notifyUser(
+ severity: 'error' | 'warning' | 'info' | 'success',
+ text: string | Ref | (() => string),
+ toastOptions?: ToastOptions
+) {
+ const coreStore = useCoreStore()
+ const toastStore = coreStore.getPluginStore('toast')
+ if (!toastStore) {
+ return
+ }
+ if (typeof text === 'function') {
+ text = computedT(text)
+ }
+ toastStore.addToast({ severity, text }, toastOptions)
+}
diff --git a/src/lib/passesBoundaryCheck.ts b/src/lib/passesBoundaryCheck.ts
new file mode 100644
index 000000000..4313c183b
--- /dev/null
+++ b/src/lib/passesBoundaryCheck.ts
@@ -0,0 +1,88 @@
+import type { Feature, Map } from 'ol'
+import type { Coordinate } from 'ol/coordinate'
+import VectorLayer from 'ol/layer/Vector'
+import VectorSource from 'ol/source/Vector'
+
+// arbitrarily give up after 10s of stalling (100 * 100ms)
+const readinessCheckLimit = 100
+const readinessWaitTime = 100
+
+export const errors = {
+ undefinedBoundaryLayer: Symbol.for('Boundary Layer undefined'),
+ undefinedBoundarySource: Symbol.for('Boundary Source undefined'),
+ sourceNotReady: Symbol.for('Source not ready'),
+} as const
+
+/**
+ * @param source - source to check for readiness
+ * @returns Promise that resolves true if source is in 'ready' state with at
+ * least one feature within time limit; else resolves false.
+ */
+async function isReady(source: VectorSource) {
+ let readinessChecks = 0
+
+ while (source.getState() !== 'ready' || source.getFeatures().length === 0) {
+ if (readinessChecks++ < readinessCheckLimit) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, readinessWaitTime)
+ })
+ } else {
+ return false
+ }
+ }
+
+ return true
+}
+
+/**
+ * Checks whether the given coordinate is withing the boundary of the layer that
+ * has the given layer id.
+ *
+ * @returns Resolves true if coordinate is within boundary, false if outside of
+ * boundary, and an error symbol if something about the check broke. If no
+ * boundaryLayerId is set, it always resolves true, as in "no boundary exists".
+ */
+export async function passesBoundaryCheck(
+ map: Map,
+ boundaryLayerId: string | undefined,
+ coordinate: Coordinate
+) {
+ if (typeof boundaryLayerId === 'undefined') {
+ return true
+ }
+
+ const boundaryLayer = map
+ .getLayers()
+ .getArray()
+ .find((layer) => layer.get('id') === boundaryLayerId)
+
+ if (!(boundaryLayer instanceof VectorLayer)) {
+ console.error(
+ `No layer configured to match boundaryLayerId "${boundaryLayerId}".`
+ )
+ return errors.undefinedBoundaryLayer
+ }
+
+ const boundaryLayerSource = boundaryLayer.getSource()
+
+ if (!(boundaryLayerSource instanceof VectorSource)) {
+ console.error(
+ `Layer with boundaryLayerId "${boundaryLayerId}" missing source.`
+ )
+ return errors.undefinedBoundarySource
+ }
+
+ const sourceReady = await isReady(boundaryLayerSource)
+
+ if (!sourceReady) {
+ console.error(
+ `Layer with boundaryLayerId "${boundaryLayerId}" did not load or is featureless.`
+ )
+ return errors.sourceNotReady
+ }
+
+ const features = boundaryLayerSource.getFeatures() as Feature[]
+ return features.some((feature) =>
+ feature.getGeometry()?.intersectsCoordinate(coordinate)
+ )
+}
diff --git a/src/lib/tooltip.ts b/src/lib/tooltip.ts
new file mode 100644
index 000000000..5df67bb66
--- /dev/null
+++ b/src/lib/tooltip.ts
@@ -0,0 +1,80 @@
+import i18next, { type TOptions } from 'i18next'
+
+type TooltipLocaleKeys = [string, string, TOptions?][]
+
+export interface Tooltip {
+ /** tooltip as a div, bound to inputs */
+ element: HTMLDivElement
+
+ /** unregisters i18next listeners so garbage collection may pick tooltip up when you no longer need it. usage only required on dynamic div creation. */
+ unregister: () => void
+}
+
+const setInnerHtml =
+ (tooltip: HTMLDivElement, localeKeys: TooltipLocaleKeys) => () =>
+ (tooltip.innerHTML = localeKeys
+ .map(
+ ([element, localeKey, options = {}]) =>
+ // @ts-expect-error | Locale keys are dynamic.
+ `<${element}>${i18next.t(localeKey, options)}${element}>`
+ )
+ .join(''))
+
+const defaultStyle = `
+ background: rgba(255, 255, 255, 0.8);
+ padding: 0.2em 0.5em;
+ border-radius: 4px;
+ color: #16161d;
+ box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.5);
+`
+
+/**
+ * This function is basically a `div` element factory bound to `i18next`
+ * translations. The element is supposed to be used in `ol/Overlay`, but may be
+ * used in any context. The typical use case is to add information when hovering
+ * features in the map.
+ *
+ * @param localeKeys - Locale keys to use in the tooltip. In the format
+ * [string, string][], the first entry is an HTML element tag, and the second
+ * entry is a locale key used as (translated) child of that tag. May also
+ * include values that are not locale keys. Translation will be tried on anything.
+ * @param style - Inline style string. If none used, default styling is applied.
+ *
+ * @example
+ * ```
+ * import { getTooltip } from '@/lib/tooltip'
+ *
+ * // 'unregister' to drop locale listener after usage of 'element' (div) ends
+ * const { element, unregister } = getTooltip(
+ * [
+ * // tag/content pairs
+ * ['h2', 'plugins.myPlugin.header'],
+ * ['p', 'plugins.myPlugin.body'],
+ * ],
+ * // optional inline style string; undefined for default, '' for none
+ * ''
+ * )
+ * ```
+ */
+export function getTooltip(
+ localeKeys: TooltipLocaleKeys,
+ style = defaultStyle
+): Tooltip {
+ const element = document.createElement('div')
+ element.style.cssText = style
+
+ const translate = setInnerHtml(element, localeKeys)
+ i18next.on('languageChanged', translate)
+ i18next.store.on('added', translate)
+
+ // initialize
+ translate()
+
+ return {
+ element,
+ unregister: () => {
+ i18next.off('languageChanges', translate)
+ i18next.store.off('added', translate)
+ },
+ }
+}
diff --git a/src/plugins/footer/components/PolarFooter.ce.vue b/src/plugins/footer/components/PolarFooter.ce.vue
new file mode 100644
index 000000000..347d6bc95
--- /dev/null
+++ b/src/plugins/footer/components/PolarFooter.ce.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/footer/index.ts b/src/plugins/footer/index.ts
new file mode 100644
index 000000000..7f324fc18
--- /dev/null
+++ b/src/plugins/footer/index.ts
@@ -0,0 +1,33 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/footer
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/PolarFooter.ce.vue'
+import locales from './locales'
+import { useFooterStore } from './store'
+import { PluginId, type FooterPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin which adds the possibility to display various content as a
+ * footer at the bottom of the map.
+ *
+ * Note that a link to the POLAR repository will always be displayed.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginFooter(
+ options: FooterPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ locales,
+ storeModule: useFooterStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/footer/locales.ts b/src/plugins/footer/locales.ts
new file mode 100644
index 000000000..57c853dc5
--- /dev/null
+++ b/src/plugins/footer/locales.ts
@@ -0,0 +1,36 @@
+import type { Locale } from '@/core'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the footer plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/footer
+ */
+/* eslint-enable tsdoc/syntax */
+
+/**
+ * German locales for footer plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {} as const
+
+/**
+ * English locales for footer plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {} as const
+
+// first type will be used as fallback language
+const locales: Locale[] = [
+ {
+ type: 'de',
+ resources: resourcesDe,
+ },
+ {
+ type: 'en',
+ resources: resourcesEn,
+ },
+]
+
+export default locales
diff --git a/src/plugins/footer/store.ts b/src/plugins/footer/store.ts
new file mode 100644
index 000000000..1e6c6f862
--- /dev/null
+++ b/src/plugins/footer/store.ts
@@ -0,0 +1,70 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/footer/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { type Component, markRaw, ref } from 'vue'
+import { toMerged } from 'es-toolkit'
+import type { PluginContainer } from '@/core'
+import { useCoreStore } from '@/core/stores/export'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for the footer.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useFooterStore = defineStore('plugins/footer', () => {
+ const coreStore = useCoreStore()
+
+ const leftEntries = ref([])
+ const rightEntries = ref([])
+
+ function setupPlugin() {
+ leftEntries.value = (
+ coreStore.configuration.footer?.leftEntries || []
+ ).filter(({ id }) => {
+ const display = coreStore.configuration[id]?.displayComponent
+ return typeof display === 'boolean' ? display : true
+ })
+ rightEntries.value = (
+ coreStore.configuration.footer?.rightEntries || []
+ ).filter(({ id }) => {
+ const display = coreStore.configuration[id]?.displayComponent
+ return typeof display === 'boolean' ? display : true
+ })
+ leftEntries.value.concat(rightEntries.value).forEach((plugin) => {
+ coreStore.addPlugin(toMerged(plugin, { independent: false }))
+ })
+ // Otherwise, the component itself is made reactive
+ leftEntries.value.map((plugin) =>
+ toMerged(plugin, { component: markRaw(plugin.component as Component) })
+ )
+ rightEntries.value.map((plugin) =>
+ toMerged(plugin, { component: markRaw(plugin.component as Component) })
+ )
+ }
+
+ function teardownPlugin() {}
+
+ return {
+ /** @internal */
+ leftEntries,
+
+ /** @internal */
+ rightEntries,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useFooterStore, import.meta.hot))
+}
diff --git a/src/plugins/footer/types.ts b/src/plugins/footer/types.ts
new file mode 100644
index 000000000..245ce23b3
--- /dev/null
+++ b/src/plugins/footer/types.ts
@@ -0,0 +1,18 @@
+import type { PluginContainer, PluginOptions } from '@/core'
+
+export const PluginId = 'footer'
+
+/**
+ * Plugin options for footer plugin.
+ */
+export interface FooterPluginOptions extends PluginOptions {
+ /**
+ * Plugins that are going to be directly rendered on the left side of the footer.
+ */
+ leftEntries: PluginContainer[]
+
+ /**
+ * Plugins that are going to be directly rendered on the right side of the footer.
+ */
+ rightEntries: PluginContainer[]
+}
diff --git a/src/plugins/fullscreen/components/FullscreenUI.ce.vue b/src/plugins/fullscreen/components/FullscreenUI.ce.vue
new file mode 100644
index 000000000..28acb71c7
--- /dev/null
+++ b/src/plugins/fullscreen/components/FullscreenUI.ce.vue
@@ -0,0 +1,46 @@
+
+ (fullscreenEnabled = !fullscreenEnabled)"
+ />
+
+
+
+
+
diff --git a/src/plugins/fullscreen/components/FullscreenUI.spec.ts b/src/plugins/fullscreen/components/FullscreenUI.spec.ts
new file mode 100644
index 000000000..a5dc57bd9
--- /dev/null
+++ b/src/plugins/fullscreen/components/FullscreenUI.spec.ts
@@ -0,0 +1,60 @@
+import { expect, test as _test, vi } from 'vitest'
+import { mount, VueWrapper } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { nextTick } from 'vue'
+import { useFullscreenStore } from '../store'
+import { PluginId } from '../types'
+import FullscreenUI from './FullscreenUI.ce.vue'
+import { mockedT } from '@/test/utils/mockI18n'
+
+/* eslint-disable no-empty-pattern */
+const test = _test.extend<{
+ wrapper: VueWrapper
+ store: ReturnType
+}>({
+ wrapper: async ({}, use) => {
+ vi.mock('i18next', () => ({
+ t: (key, { ns, context }) => `$t(${ns}:${key}_${context})`,
+ }))
+ const wrapper = mount(FullscreenUI, {
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn })],
+ mocks: {
+ $t: mockedT,
+ },
+ },
+ })
+ await use(wrapper)
+ },
+ store: async ({}, use) => {
+ const store = useFullscreenStore()
+ await use(store)
+ },
+})
+/* eslint-enable no-empty-pattern */
+
+test('Component listens to store changes', async ({ wrapper, store }) => {
+ store.fullscreenEnabled = false
+ await nextTick()
+ expect(wrapper.find('.kern-label').text()).toContain(
+ `$t(${PluginId}:button.label_on)`
+ )
+
+ store.fullscreenEnabled = true
+ await nextTick()
+ expect(wrapper.find('.kern-label').text()).toContain(
+ `$t(${PluginId}:button.label_off)`
+ )
+})
+
+test('Component triggers store changes', async ({ wrapper, store }) => {
+ store.fullscreenEnabled = false
+ await nextTick()
+
+ await wrapper.find('button').trigger('click')
+ expect(store.fullscreenEnabled).toBeTruthy()
+ await nextTick()
+
+ await wrapper.find('button').trigger('click')
+ expect(store.fullscreenEnabled).toBeFalsy()
+})
diff --git a/src/plugins/fullscreen/index.ts b/src/plugins/fullscreen/index.ts
new file mode 100644
index 000000000..2466f0549
--- /dev/null
+++ b/src/plugins/fullscreen/index.ts
@@ -0,0 +1,30 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/fullscreen
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/FullscreenUI.ce.vue'
+import locales from './locales'
+import { useFullscreenStore } from './store'
+import { PluginId, type FullscreenPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin which provides a fullscreen mode with a fullscreen toggle button.
+ *
+ * @returns Plugin for use with {@link addPlugin}
+ */
+export default function pluginFullscreen(
+ options: FullscreenPluginOptions = {}
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ locales,
+ storeModule: useFullscreenStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/fullscreen/locales.ts b/src/plugins/fullscreen/locales.ts
new file mode 100644
index 000000000..74e80cf20
--- /dev/null
+++ b/src/plugins/fullscreen/locales.ts
@@ -0,0 +1,55 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the fullscreen plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/fullscreen
+ */
+/* eslint-enable tsdoc/syntax */
+
+import type { Locale } from '@/core'
+
+/**
+ * German locales for fullscreen plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ button: {
+ label: 'Vollbildmodus',
+ label_off: 'Vollbildmodus deaktivieren',
+ label_on: 'Vollbildmodus aktivieren',
+ },
+} as const
+
+/**
+ * English locales for fullscreen plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ button: {
+ label: 'Fullscreen mode',
+ label_off: 'Disable fullscreen mode',
+ label_on: 'Enable fullscreen mode',
+ },
+} as const
+
+/**
+ * Fullscreen plugin 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/store.ts b/src/plugins/fullscreen/store.ts
new file mode 100644
index 000000000..4f02ffb3c
--- /dev/null
+++ b/src/plugins/fullscreen/store.ts
@@ -0,0 +1,278 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/fullscreen/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import type { Reactive } from 'vue'
+import { PluginId, type FullscreenPluginOptions } from './types'
+import { useCoreStore } from '@/core/stores/export'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for fullscreen mode detection and enablement.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useFullscreenStore = defineStore('plugins/fullscreen', () => {
+ const coreStore = useCoreStore()
+
+ const configuration = computed(
+ () => coreStore.configuration[PluginId] as FullscreenPluginOptions
+ )
+ const renderType = computed(() => configuration.value.renderType)
+
+ const targetContainer = computed(() => {
+ if (typeof configuration.value.targetContainer === 'string') {
+ return (
+ document.getElementById(configuration.value.targetContainer) ||
+ document.documentElement
+ )
+ }
+ if (!configuration.value.targetContainer) {
+ return coreStore.lightElement || document.documentElement
+ }
+ return configuration.value.targetContainer
+ })
+
+ const _fullscreenEnabled = ref(false)
+ const simulatedFullscreenSavedStyle = ref(null)
+
+ function enableSimulatedFullscreen() {
+ if (!coreStore.lightElement) {
+ return
+ }
+
+ simulatedFullscreenSavedStyle.value = coreStore.lightElement.style.cssText
+
+ coreStore.lightElement.style.position = 'fixed'
+ coreStore.lightElement.style.margin = '0'
+ coreStore.lightElement.style.top = '0'
+ coreStore.lightElement.style.left = '0'
+ coreStore.lightElement.style.width = '100%'
+ coreStore.lightElement.style.height = '100%'
+ coreStore.lightElement.style.zIndex = '9999'
+ }
+
+ async function enableFullscreen() {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (!targetContainer.value.requestFullscreen) {
+ // @ts-expect-error | WebKit is still needed for iOS Safari
+ if (targetContainer.value.webkitRequestFullscreen) {
+ // @ts-expect-error | WebKit is still needed for iOS Safari
+ await targetContainer.value.webkitRequestFullscreen()
+ updateFullscreenState()
+ return
+ }
+
+ // Fallback to simulated fullscreen
+ enableSimulatedFullscreen()
+ return
+ }
+
+ await targetContainer.value.requestFullscreen()
+ updateFullscreenState()
+ }
+
+ async function disableFullscreen() {
+ if (simulatedFullscreenSavedStyle.value !== null) {
+ if (coreStore.lightElement) {
+ coreStore.lightElement.style.cssText =
+ simulatedFullscreenSavedStyle.value
+ }
+ simulatedFullscreenSavedStyle.value = null
+ return
+ }
+
+ // @ts-expect-error | WebKit is still needed for iOS Safari
+ if (document.webkitExitFullscreen) {
+ // @ts-expect-error | WebKit is still needed for iOS Safari
+ await document.webkitExitFullscreen()
+ updateFullscreenState()
+ return
+ }
+
+ await document.exitFullscreen()
+ updateFullscreenState()
+ }
+
+ const fullscreenEnabled = computed({
+ get: () =>
+ simulatedFullscreenSavedStyle.value !== null || _fullscreenEnabled.value,
+ set: (value) => {
+ ;(value ? enableFullscreen : disableFullscreen)().catch(() => {
+ console.warn('Failed to toggle fullscreen mode')
+ })
+ },
+ })
+
+ function updateFullscreenState() {
+ _fullscreenEnabled.value =
+ // @ts-expect-error | WebKit is still needed for iOS Safari
+ Boolean(document.fullscreenElement || document.webkitFullscreenElement)
+ }
+
+ function setupPlugin() {
+ addEventListener('fullscreenchange', updateFullscreenState)
+ addEventListener('webkitfullscreenchange', updateFullscreenState)
+ }
+
+ function teardownPlugin() {
+ removeEventListener('fullscreenchange', updateFullscreenState)
+ removeEventListener('webkitfullscreenchange', updateFullscreenState)
+ }
+
+ return {
+ /**
+ * Reading this property describes if fullscreen mode is enabled or disabled.
+ * Writing this property enables or disables fullscreen mode, respectively.
+ *
+ * @defaultValue false
+ */
+ fullscreenEnabled,
+
+ /**
+ * Enable simulated fullscreen mode (without using the Fullscreen API).
+ * This is usually not necessary to call manually, as the plugin handles it automatically
+ * if the Fullscreen API is not available.
+ *
+ * @alpha
+ */
+ enableSimulatedFullscreen,
+
+ /** @internal */
+ renderType,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+})
+
+if (import.meta.vitest) {
+ const { expect, test: _test, vi } = import.meta.vitest
+ const { createPinia, setActivePinia } = await import('pinia')
+ const { reactive } = await import('vue')
+ const useCoreStoreFile = await import('@/core/stores/export')
+
+ /* eslint-disable no-empty-pattern */
+ const test = _test.extend<{
+ coreStore: Reactive>
+ store: ReturnType
+ }>({
+ coreStore: [
+ async ({}, use) => {
+ const coreStore = reactive({
+ configuration: { [PluginId]: {} },
+ })
+ // @ts-expect-error | Mocking useCoreStore
+ vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore)
+ await use(coreStore)
+ },
+ { auto: true },
+ ],
+ store: async ({}, use) => {
+ setActivePinia(createPinia())
+ const store = useFullscreenStore()
+ store.setupPlugin()
+ await use(store)
+ store.teardownPlugin()
+ },
+ })
+ /* eslint-enable no-empty-pattern */
+
+ test.for([
+ { native: true, webkit: true, result: true },
+ { native: true, webkit: false, result: true },
+ { native: false, webkit: true, result: true },
+ { native: false, webkit: false, result: false },
+ ])(
+ 'Fullscreen detection uses webkit prefix if necessary (native=$native, webkit=$webkit)',
+ ({ native, webkit, result }, { store }) => {
+ jsdom.window.document.fullscreenElement = native
+ jsdom.window.document.webkitFullscreenElement = webkit
+ dispatchEvent(new Event('fullscreenchange'))
+ expect(store.fullscreenEnabled).toBe(result)
+ }
+ )
+
+ test.for([
+ { native: true, webkit: true },
+ { native: true, webkit: false },
+ { native: false, webkit: true },
+ ])(
+ 'Enable fullscreen uses webkit prefix if necessary (native=$native, webkit=$webkit)',
+ async ({ native, webkit }, { store, coreStore }) => {
+ jsdom.window.document.fullscreenElement = null
+ const requestFullscreen = vi.fn(() => {
+ return new Promise((resolve) => {
+ jsdom.window.document.fullscreenElement =
+ document.createElement('div')
+ resolve()
+ })
+ })
+ coreStore.lightElement = {
+ ...(native ? { requestFullscreen } : {}),
+ ...(webkit ? { webkitRequestFullscreen: requestFullscreen } : {}),
+ }
+ store.fullscreenEnabled = true
+ expect(requestFullscreen).toHaveBeenCalled()
+ await vi.waitUntil(() => store.fullscreenEnabled)
+ expect(store.fullscreenEnabled).toBeTruthy()
+ }
+ )
+
+ test.for([
+ { native: true, webkit: true },
+ { native: true, webkit: false },
+ { native: false, webkit: true },
+ ])(
+ 'Disable fullscreen uses webkit prefix if necessary (native=$native, webkit=$webkit)',
+ async ({ native, webkit }, { store }) => {
+ jsdom.window.document.fullscreenElement = document.createElement('div')
+ dispatchEvent(new Event('fullscreenchange'))
+ const exitFullscreen = vi.fn(() => {
+ return new Promise((resolve) => {
+ jsdom.window.document.fullscreenElement = null
+ resolve()
+ })
+ })
+ if (native) {
+ jsdom.window.document.exitFullscreen = exitFullscreen
+ }
+ if (webkit) {
+ jsdom.window.document.webkitExitFullscreen = exitFullscreen
+ }
+ store.fullscreenEnabled = false
+ expect(exitFullscreen).toHaveBeenCalled()
+ await vi.waitUntil(() => !store.fullscreenEnabled)
+ expect(store.fullscreenEnabled).toBeFalsy()
+ delete jsdom.window.document.exitFullscreen
+ delete jsdom.window.document.webkitExitFullscreen
+ }
+ )
+
+ test('Enable simulated fullscreen if Fullscreen API is not available', ({
+ store,
+ coreStore,
+ }) => {
+ const style = new CSSStyleDeclaration()
+ coreStore.lightElement = { style }
+ style.cssText = 'position: relative; width: 400px; height: 300px;'
+ store.fullscreenEnabled = true
+ expect(store.fullscreenEnabled).toBeTruthy()
+ expect(style.position).toBe('fixed')
+ store.fullscreenEnabled = false
+ expect(store.fullscreenEnabled).toBeFalsy()
+ expect(style.position).toBe('relative')
+ })
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useFullscreenStore, import.meta.hot))
+}
diff --git a/src/plugins/fullscreen/types.ts b/src/plugins/fullscreen/types.ts
new file mode 100644
index 000000000..aa1e5c386
--- /dev/null
+++ b/src/plugins/fullscreen/types.ts
@@ -0,0 +1,29 @@
+import type { PluginOptions } from '@/core'
+
+/**
+ * Plugin identifier.
+ */
+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).
+ *
+ * If a string is provided, it is interpreted as the `id` of an `HTMLElement` which is searched by `document.getElementById`.
+ * For usage within Shadow DOMs, please provide the `HTMLElement` itself.
+ */
+ targetContainer?: HTMLElement | string
+}
diff --git a/src/plugins/geoLocation/components/GeoLocation.ce.vue b/src/plugins/geoLocation/components/GeoLocation.ce.vue
new file mode 100644
index 000000000..54e61134b
--- /dev/null
+++ b/src/plugins/geoLocation/components/GeoLocation.ce.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/geoLocation/components/GeoLocation.spec.ts b/src/plugins/geoLocation/components/GeoLocation.spec.ts
new file mode 100644
index 000000000..904eafd91
--- /dev/null
+++ b/src/plugins/geoLocation/components/GeoLocation.spec.ts
@@ -0,0 +1,74 @@
+import { createTestingPinia } from '@pinia/testing'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import { expect, test as _test, vi } from 'vitest'
+import { useGeoLocationStore } from '../store'
+import GeoLocation from './GeoLocation.ce.vue'
+import { mockedT } from '@/test/utils/mockI18n'
+
+/* eslint-disable no-empty-pattern */
+const test = _test.extend<{
+ wrapper: VueWrapper
+ store: ReturnType
+}>({
+ wrapper: async ({}, use) => {
+ const wrapper = mount(GeoLocation, {
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn })],
+ mocks: {
+ $t: mockedT,
+ },
+ },
+ })
+ await use(wrapper)
+ },
+ store: async ({}, use) => {
+ const store = useGeoLocationStore()
+ await use(store)
+ },
+})
+/* eslint-enable no-empty-pattern */
+
+test('The button should include a tooltip', ({ wrapper }) => {
+ const btn = wrapper.find('button')
+ expect(btn.element.disabled).toBe(false)
+ expect(btn.find('.polar-tooltip').exists()).toBe(true)
+ expect(btn.find('.kern-label').exists()).toBe(true)
+})
+
+// TODO: Fix test; the user interaction accepting the location request needs to be mocked in order for the test to pass
+test.skip('The icon of the button should change on click to a filled icon if the user accepts the location request', async ({
+ wrapper,
+}) => {
+ const btn = wrapper.find('button')
+ expect(btn.element.disabled).toBe(false)
+ expect(btn.find('.kern-icon').element.classList).toContain(
+ 'kern-icon--near-me'
+ )
+
+ await btn.trigger('click')
+
+ expect(btn.element.disabled).toBe(false)
+ expect(btn.find('.kern-icon').element.classList).toContain(
+ 'kern-icon--near-me-filled'
+ )
+})
+
+test('The icon of the button should change on click to a disabled icon and be disabled if the user declines the location', async ({
+ wrapper,
+ store,
+}) => {
+ const btn = wrapper.find('button')
+
+ expect(btn.element.disabled).toBe(false)
+ expect(btn.find('.kern-icon').element.classList).toContain(
+ 'kern-icon--near-me'
+ )
+
+ store.isGeolocationDenied = true
+ await btn.trigger('click')
+
+ expect(btn.element.disabled).toBe(true)
+ expect(btn.find('.kern-icon').element.classList).toContain(
+ 'kern-icon--near-me-disabled'
+ )
+})
diff --git a/src/plugins/geoLocation/index.ts b/src/plugins/geoLocation/index.ts
new file mode 100644
index 000000000..2d841410f
--- /dev/null
+++ b/src/plugins/geoLocation/index.ts
@@ -0,0 +1,35 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/geoLocation
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/GeoLocation.ce.vue'
+import locales from './locales'
+import { PluginId, type GeoLocationPluginOptions } from './types'
+import { useGeoLocationStore } from './store'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * The GeoLocation plugin is responsible for collecting and displaying a user's
+ * GPS location for display on the map. The tracking can be triggered initially
+ * on startup or via a button.
+ *
+ * If a users denies the location tracking, the button for this plugin gets
+ * disabled and indicates the user's decision.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginGeoLocation(
+ options: GeoLocationPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ locales,
+ storeModule: useGeoLocationStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/geoLocation/locales.ts b/src/plugins/geoLocation/locales.ts
new file mode 100644
index 000000000..d5f57c3cb
--- /dev/null
+++ b/src/plugins/geoLocation/locales.ts
@@ -0,0 +1,65 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the geoLocation plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/geoLocation
+ */
+/* eslint-enable tsdoc/syntax */
+
+import type { Locale } from '@/core'
+
+/**
+ * German locales for geoLocation plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ markerText: 'Aktuelle Position',
+ button: {
+ locationAccessDenied: 'Standortzugriff nutzerseitig abgelehnt',
+ tooltip: 'Eigene Position markieren',
+ },
+ toast: {
+ boundaryError:
+ 'Die Überprüfung Ihrer Position ist fehlgeschlagen. Bitte versuchen Sie es später erneut oder wenden Sie sich an einen Administrator, wenn das Problem bestehen bleibt.',
+ notInBoundary: 'Sie befinden sich nicht im Kartengebiet.',
+ },
+} as const
+
+/**
+ * English locales for geoLocation plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ markerText: 'Current location',
+ button: {
+ locationAccessDenied: 'Location access denied by user',
+ tooltip: 'Mark own location',
+ },
+ toast: {
+ boundaryError:
+ 'Validating your position failed. Please try later again or contact an administrator if the issue persists.',
+ notInBoundary: "You are not within the map's boundaries.",
+ },
+} as const
+
+/**
+ * GeoLocation plugin 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/geoLocation/store.ts b/src/plugins/geoLocation/store.ts
new file mode 100644
index 000000000..3b1560a0f
--- /dev/null
+++ b/src/plugins/geoLocation/store.ts
@@ -0,0 +1,524 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/geoLocation/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { noop, toMerged } from 'es-toolkit'
+import { t } from 'i18next'
+import type { Coordinate } from 'ol/coordinate'
+import { containsCoordinate } from 'ol/extent'
+import Feature from 'ol/Feature'
+import Geolocation from 'ol/Geolocation'
+import Point from 'ol/geom/Point'
+import VectorLayer from 'ol/layer/Vector'
+import Overlay from 'ol/Overlay'
+import * as Proj from 'ol/proj'
+import { transform as transformCoordinates } from 'ol/proj'
+import VectorSource from 'ol/source/Vector'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import type { PluginState, GeoLocationPluginOptions } from './types'
+import { detectDeniedGeolocationEarly } from './utils/detectDeniedGeolocationEarly'
+import { getGeoLocationStyle } from './utils/olStyle'
+import { positionChanged } from './utils/positionChanged'
+import { useCoreStore } from '@/core/stores/export'
+import { notifyUser } from '@/lib/notifyUser'
+import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck'
+import { getTooltip } from '@/lib/tooltip'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for geoLocation.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useGeoLocationStore = defineStore('plugins/geoLocation', () => {
+ const coreStore = useCoreStore()
+
+ const isGeolocationDenied = ref(false)
+ const geolocation = ref(null)
+ const lastBoundaryCheck = ref(null)
+ const position = ref([])
+
+ const configuration = computed<
+ GeoLocationPluginOptions & { showTooltip: boolean; zoomLevel: number }
+ >(() =>
+ toMerged(
+ { showTooltip: false, zoomLevel: 7 },
+ coreStore.configuration.geoLocation || {}
+ )
+ )
+ const boundary = computed(() => configuration.value.boundary)
+ const state = computed(() => {
+ if (isGeolocationDenied.value) {
+ return 'DISABLED'
+ } else if (geolocation.value === null) {
+ return 'LOCATABLE'
+ }
+
+ return 'LOCATED'
+ })
+
+ const markerFeature = new Feature({
+ type: 'point',
+ name: 'geoLocationMarker',
+ })
+ const geoLocationMarkerLayer = new VectorLayer({
+ source: new VectorSource({ features: [markerFeature] }),
+ properties: { name: 'geoLocationMarkerLayer' },
+ zIndex: Infinity,
+ style: getGeoLocationStyle(),
+ })
+
+ function setupPlugin() {
+ coreStore.map.addLayer(geoLocationMarkerLayer)
+ if (configuration.value.checkLocationInitially) {
+ track()
+ } else {
+ void detectDeniedGeolocationEarly().then(
+ (isDenied) => (isGeolocationDenied.value = isDenied)
+ )
+ }
+ setupTooltip()
+ }
+
+ function teardownPlugin() {
+ coreStore.map.removeLayer(geoLocationMarkerLayer)
+ untrack()
+ removeMarker()
+ teardownTooltip()
+ }
+
+ let teardownTooltip = noop
+ function setupTooltip() {
+ if (configuration.value.showTooltip) {
+ const { unregister, element } = getTooltip([
+ ['h2', 'markerText', { ns: 'geoLocation' }],
+ ])
+ const overlay = new Overlay({
+ element,
+ positioning: 'bottom-center',
+ offset: [0, -5],
+ })
+ coreStore.map.addOverlay(overlay)
+ const updateTooltip = ({ pixel, dragging }) => {
+ if (dragging) {
+ return
+ }
+ const features = coreStore.map.getFeaturesAtPixel(pixel, {
+ layerFilter: (layer) =>
+ layer.get('name') === 'geoLocationMarkerLayer',
+ })
+
+ const coordinate = features.length
+ ? coreStore.map.getCoordinateFromPixel(pixel)
+ : undefined
+ overlay.setPosition(coordinate)
+ }
+ coreStore.map.on('pointermove', updateTooltip)
+
+ teardownTooltip = () => {
+ unregister()
+ coreStore.map.removeOverlay(overlay)
+ coreStore.map.un('pointermove', updateTooltip)
+ teardownTooltip = noop
+ }
+ }
+ }
+
+ function locate() {
+ ;(state.value === 'LOCATABLE' ? track : untrack)()
+ }
+
+ /** Enable tracking of geo position */
+ function track() {
+ if (isGeolocationDenied.value) {
+ onError({
+ message: 'Geolocation API usage was denied by user or configuration.',
+ })
+ return
+ }
+ if (geolocation.value === null) {
+ geolocation.value = new Geolocation({
+ trackingOptions: {
+ // required for heading
+ enableHighAccuracy: true,
+ },
+ tracking: true,
+ projection: Proj.get('EPSG:4326') as Proj.Projection,
+ })
+ } else {
+ void positioning()
+ }
+ geolocation.value.on('change:position', positioning)
+ geolocation.value.on('change:heading', ({ target }) => {
+ markerFeature.set('heading', target.getHeading())
+ })
+ geolocation.value.on('error', onError)
+ }
+
+ /**
+ * Show error information and stop tracking if there are errors by tracking the position
+ */
+ function onError(error: { message: string }) {
+ notifyUser(
+ 'error',
+ t(($) => $.button.locationAccessDenied, {
+ ns: 'geoLocation',
+ })
+ )
+ console.error(error.message)
+
+ isGeolocationDenied.value = true
+ removeMarker()
+ }
+
+ /**
+ * Stop tracking of geo position
+ */
+ function untrack() {
+ // For FireFox - cannot handle geolocation.un(...).
+ geolocation.value?.setTracking(false)
+ removeMarker()
+ geolocation.value = null
+ }
+
+ async function positioning() {
+ const coordinatesInMapCrs = transformCoordinates(
+ geolocation.value?.getPosition() as number[],
+ Proj.get('EPSG:4326') as Proj.Projection,
+ coreStore.configuration.epsg
+ )
+
+ const isCoordinateInExtent = coreStore.configuration.extent
+ ? containsCoordinate(coreStore.configuration.extent, coordinatesInMapCrs)
+ : true
+
+ const boundaryCheckPassed = await passesBoundaryCheck(
+ coreStore.map,
+ boundary.value?.layerId,
+ coordinatesInMapCrs
+ )
+
+ const boundaryCheckChanged = lastBoundaryCheck.value !== boundaryCheckPassed
+
+ lastBoundaryCheck.value = boundaryCheckPassed
+
+ const showBoundaryLayerError =
+ typeof boundaryCheckPassed === 'symbol' &&
+ boundary.value?.onError === 'strict'
+
+ if (!isCoordinateInExtent || showBoundaryLayerError) {
+ printPositioningFailed(showBoundaryLayerError)
+ untrack()
+ return
+ }
+
+ if (positionChanged(position.value, coordinatesInMapCrs)) {
+ addMarker(coordinatesInMapCrs)
+
+ if (boundaryCheckChanged && !boundaryCheckPassed) {
+ printPositioningFailed(false)
+ }
+ }
+ }
+
+ /**
+ * Adds a marker to the map, which indicates the users geoLocation.
+ * This happens by applying a style to the geoLocationMarkerLayer and
+ * a geometry to the geoLocationMarker.
+ */
+ function addMarker(coordinate: Coordinate) {
+ position.value = coordinate
+
+ const hadPosition = Boolean(markerFeature.getGeometry())
+ markerFeature.setGeometry(new Point(coordinate))
+
+ if (
+ (configuration.value.keepCentered || !hadPosition) &&
+ lastBoundaryCheck.value
+ ) {
+ coreStore.map.getView().setCenter(coordinate)
+ coreStore.map.getView().setZoom(configuration.value.zoomLevel)
+ }
+ }
+
+ /**
+ * Removes the geoLocation marker from the map.
+ */
+ function removeMarker() {
+ markerFeature.setGeometry(undefined)
+ position.value = []
+ }
+
+ function printPositioningFailed(boundaryErrorOccurred: boolean) {
+ if (boundaryErrorOccurred) {
+ const msg = t(($) => $.toast.boundaryError, { ns: 'geoLocation' })
+ notifyUser('error', msg)
+ console.error(msg)
+ return
+ }
+ const msg = t(($) => $.toast.notInBoundary, {
+ ns: 'geoLocation',
+ })
+ notifyUser('info', msg, { timeout: 10000 })
+ // eslint-disable-next-line no-console
+ console.info(msg)
+ }
+
+ return {
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+
+ /**
+ * The action that would currently unfold upon clicking the icon, depending
+ * on the state.
+ *
+ * @internal
+ */
+ locate,
+
+ /**
+ * GeoLocation plugin configuration including default values.
+ *
+ * @internal
+ */
+ configuration,
+
+ /**
+ * @internal
+ */
+ isGeolocationDenied,
+
+ /**
+ * The plugin's current state. It can either currently have the user's
+ * position ('LOCATED'), be ready to retrieve it ('LOCATABLE'), or be
+ * disabled ('DISABLED') due to the user or browser settings not allowing
+ * the Geolocation API access.
+ *
+ * @internal
+ */
+ state,
+
+ /**
+ * While in state 'LOCATED', the user's location's coordinated are available
+ * as [number, number] of the map's configured CRS.
+ */
+ position,
+
+ /**
+ * Initially null. If no boundary check is configured or the check is
+ * passed, this field holds `true`. If the boundary check is not passed,
+ * this field holds `false`.
+ *
+ * May also hold a symbol from the `@polar/polar/lib/passesBoundaryCheck.ts`
+ * errors export, if such an error occurred.
+ */
+ boundaryCheck: lastBoundaryCheck,
+ }
+})
+
+// TODO: Migrate tests from jest to vitest
+/*
+import Geolocation from 'ol/Geolocation.js'
+import { makeStoreModule } from '../src/store/index'
+
+describe('plugin-geolocation', () => {
+ describe('store', () => {
+ describe('actions', () => {
+ let consoleErrorSpy
+ let consoleLogSpy
+ let actionContext
+ let commit
+ let dispatch
+ let storeModule
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.fn()
+ consoleLogSpy = jest.fn()
+ jest.spyOn(console, 'error').mockImplementation(consoleErrorSpy)
+ jest.spyOn(console, 'log').mockImplementation(consoleLogSpy)
+ commit = jest.fn()
+ dispatch = jest.fn()
+ actionContext = {
+ commit,
+ dispatch,
+ getters: {
+ geolocation: null,
+ configuredEpsg: 'EPSG:4326',
+ position: [100, 100],
+ },
+ }
+ storeModule = makeStoreModule()
+ })
+ afterEach(jest.restoreAllMocks)
+
+ describe('onError', () => {
+ const error = { message: 'uhoh' }
+ it('should dispatch a toast if the toastAction is configured', () => {
+ actionContext.getters.toastAction = 'actionName'
+
+ storeModule.actions.onError(actionContext, error)
+
+ expect(commit.mock.calls.length).toEqual(2)
+ expect(commit.mock.calls[0]).toEqual(['setIsGeolocationDenied', true])
+ expect(commit.mock.calls[1]).toEqual(['setTracking', false])
+ expect(dispatch.mock.calls.length).toEqual(2)
+ expect(dispatch.mock.calls[0]).toEqual([
+ 'actionName',
+ {
+ type: 'error',
+ text: 'plugins.geoLocation.button.tooltip.locationAccessDenied',
+ },
+ { root: true },
+ ])
+ expect(dispatch.mock.calls[1]).toEqual(['removeMarker'])
+ expect(consoleErrorSpy.mock.calls.length).toEqual(1)
+ expect(consoleErrorSpy.mock.calls[0]).toEqual([
+ '@polar/plugin-geo-location',
+ error.message,
+ ])
+ })
+ it('should log an additional error if the toastAction is not configured', () => {
+ storeModule.actions.onError(actionContext, error)
+
+ expect(commit.mock.calls.length).toEqual(2)
+ expect(commit.mock.calls[0]).toEqual(['setIsGeolocationDenied', true])
+ expect(commit.mock.calls[1]).toEqual(['setTracking', false])
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual(['removeMarker'])
+ expect(consoleErrorSpy.mock.calls.length).toEqual(2)
+ expect(consoleErrorSpy.mock.calls[0]).toEqual([
+ '@polar/plugin-geo-location: Location access denied by user.',
+ ])
+ expect(consoleErrorSpy.mock.calls[1]).toEqual([
+ '@polar/plugin-geo-location',
+ error.message,
+ ])
+ })
+ })
+ describe('printPositioningFailed', () => {
+ it('should dispatch a toast for a boundaryError if the toastAction is configured and the given parameter has a relevant value', () => {
+ actionContext.getters.toastAction = 'actionName'
+
+ storeModule.actions.printPositioningFailed(
+ actionContext,
+ 'boundaryError'
+ )
+
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual([
+ 'actionName',
+ {
+ type: 'error',
+ text: 'plugins.geoLocation.toast.boundaryError',
+ },
+ { root: true },
+ ])
+ expect(consoleErrorSpy.mock.calls.length).toEqual(0)
+ expect(consoleLogSpy.mock.calls.length).toEqual(0)
+ })
+ it('should dispatch a toast for a generic not in boundary error if the toastAction is configured and the given parameter does not have a relevant value', () => {
+ actionContext.getters.toastAction = 'actionName'
+
+ storeModule.actions.printPositioningFailed(actionContext, '')
+
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual([
+ 'actionName',
+ {
+ type: 'info',
+ text: 'plugins.geoLocation.toast.notInBoundary',
+ timeout: 10000,
+ },
+ { root: true },
+ ])
+ expect(consoleErrorSpy.mock.calls.length).toEqual(0)
+ expect(consoleLogSpy.mock.calls.length).toEqual(0)
+ })
+ it('should log only an error for a boundaryError if the toastAction is not configured and the given parameter has a relevant value', () => {
+ storeModule.actions.printPositioningFailed(
+ actionContext,
+ 'boundaryError'
+ )
+
+ expect(dispatch.mock.calls.length).toEqual(0)
+ expect(consoleErrorSpy.mock.calls.length).toEqual(1)
+ expect(consoleErrorSpy.mock.calls[0]).toEqual([
+ 'Checking boundary layer failed.',
+ ])
+ expect(consoleLogSpy.mock.calls.length).toEqual(0)
+ })
+ it('should log only an error for a generic not in boundary error if the toastAction is not configured and the given parameter does not have a relevant value', () => {
+ storeModule.actions.printPositioningFailed(actionContext, '')
+
+ expect(dispatch.mock.calls.length).toEqual(0)
+ expect(consoleErrorSpy.mock.calls.length).toEqual(0)
+ expect(consoleLogSpy.mock.calls.length).toEqual(1)
+ expect(consoleLogSpy.mock.calls[0]).toEqual([
+ 'User position outside of boundary layer.',
+ ])
+ })
+ })
+ describe('track', () => {
+ it('instantiate the OpenLayers GeoLocation object and commit it to the store if the geolocation was not denied and the GeoLocation object has not been set yet', () => {
+ actionContext.getters.isGeolocationDenied = false
+
+ storeModule.actions.track(actionContext)
+
+ expect(commit.mock.calls.length).toEqual(2)
+ expect(commit.mock.calls[0][0]).toEqual('setGeolocation')
+ expect(commit.mock.calls[0][1] instanceof Geolocation).toEqual(true)
+ expect(commit.mock.calls[0][1].getTracking()).toEqual(true)
+ expect(commit.mock.calls[0][1].getProjection().getCode()).toEqual(
+ 'EPSG:4326'
+ )
+ expect(commit.mock.calls[1]).toEqual(['setTracking', true])
+ expect(dispatch.mock.calls.length).toEqual(0)
+ })
+ it('trigger the action to reposition the location if the geolocation was not denied and the geolocation has been instantiated already', () => {
+ actionContext.getters.isGeolocationDenied = false
+ actionContext.getters.geolocation = { on: jest.fn() }
+
+ storeModule.actions.track(actionContext)
+
+ expect(commit.mock.calls.length).toEqual(1)
+ expect(commit.mock.calls[0]).toEqual(['setTracking', true])
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual(['positioning'])
+ })
+ it('should dispatch the onError action if the geolocation was denied', () => {
+ actionContext.getters.isGeolocationDenied = true
+
+ storeModule.actions.track(actionContext)
+
+ expect(commit.mock.calls.length).toEqual(0)
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual(['onError'])
+ })
+ })
+ describe('untrack', () => {
+ it('should reset all relevant fields in the store, remove the marker and stop tracking', () => {
+ const setTracking = jest.fn()
+ actionContext.getters.geolocation = { setTracking }
+
+ storeModule.actions.untrack(actionContext)
+
+ expect(setTracking.mock.calls.length).toEqual(1)
+ expect(setTracking.mock.calls[0]).toEqual([false])
+ expect(commit.mock.calls.length).toEqual(2)
+ expect(commit.mock.calls[0]).toEqual(['setTracking', false])
+ expect(commit.mock.calls[1]).toEqual(['setGeolocation', null])
+ expect(dispatch.mock.calls.length).toEqual(1)
+ expect(dispatch.mock.calls[0]).toEqual(['removeMarker'])
+ })
+ })
+ })
+ })
+})
+*/
diff --git a/src/plugins/geoLocation/types.ts b/src/plugins/geoLocation/types.ts
new file mode 100644
index 000000000..3e99ddd84
--- /dev/null
+++ b/src/plugins/geoLocation/types.ts
@@ -0,0 +1,59 @@
+import type { LayerBoundPluginOptions } from '@/core'
+
+/**
+ * Plugin identifier.
+ */
+export const PluginId = 'geoLocation'
+
+/**
+ * Current state of the GeoLocation plugin.
+ */
+export type PluginState = 'LOCATABLE' | 'LOCATED' | 'DISABLED'
+
+/**
+ * Plugin options for geoLocation plugin.
+ */
+export interface GeoLocationPluginOptions extends LayerBoundPluginOptions {
+ /**
+ * If `true`, the location check will be run on map start-up. If `false`, the
+ * feature has to be triggered with a button press by the user.
+ *
+ * @defaultValue `false`
+ */
+ checkLocationInitially?: boolean
+
+ /**
+ * If `true`, the map will re-center on the user on any position change. This
+ * effectively hinders map panning on moving devices.
+ *
+ * If `false`, only the first position will be centered on.
+ *
+ * @defaultValue `false`
+ */
+ 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.
+ *
+ * @defaultValue `false`
+ */
+ showTooltip?: boolean
+
+ /**
+ * Zoom level to zoom to on geolocating the user and panning to the position.
+ *
+ * @defaultValue `7`
+ */
+ zoomLevel?: number
+}
diff --git a/src/plugins/geoLocation/utils/detectDeniedGeolocationEarly.ts b/src/plugins/geoLocation/utils/detectDeniedGeolocationEarly.ts
new file mode 100644
index 000000000..6b257e3a4
--- /dev/null
+++ b/src/plugins/geoLocation/utils/detectDeniedGeolocationEarly.ts
@@ -0,0 +1,9 @@
+export function detectDeniedGeolocationEarly() {
+ return navigator.permissions
+ .query({ name: 'geolocation' })
+ .then(({ state }) => state === 'denied')
+ .catch(() => {
+ // Can't help it, we'll figure this one out later.
+ return false
+ })
+}
diff --git a/src/plugins/geoLocation/utils/olStyle.ts b/src/plugins/geoLocation/utils/olStyle.ts
new file mode 100644
index 000000000..8791e96f4
--- /dev/null
+++ b/src/plugins/geoLocation/utils/olStyle.ts
@@ -0,0 +1,77 @@
+import type { FeatureLike } from 'ol/Feature'
+import Circle from 'ol/style/Circle'
+import Fill from 'ol/style/Fill'
+import RegularShape from 'ol/style/RegularShape'
+import Stroke from 'ol/style/Stroke'
+import Style from 'ol/style/Style'
+
+/*
+ * TODO: The colors here should stem from KERN e.g. received by:
+ * getComputedStyle(
+ * (
+ * (document.querySelector('polar-map') as HTMLDivElement)
+ * .shadowRoot as ShadowRoot
+ * ).firstChild as HTMLStyleElement
+ * ).getPropertyValue('--kern-color-action-default')
+ */
+
+function createLinearGradient(radians: number, radius: number) {
+ const sideLength = (radius / 2) * Math.sqrt(3)
+
+ const gradient = (
+ document
+ .createElement('canvas')
+ .getContext('2d') as CanvasRenderingContext2D
+ ).createLinearGradient(
+ 0,
+ -sideLength,
+ Math.sin(radians),
+ sideLength + Math.cos(radians)
+ )
+ gradient.addColorStop(0, '#0794FAFF') // '#0794FA' polar-blue/400
+ gradient.addColorStop(2 / 3, '#0794FA00')
+
+ return gradient
+}
+
+function dropDirectionalShadow(feature: FeatureLike) {
+ if (typeof feature.get('heading') === 'undefined') {
+ return new Style()
+ }
+
+ const radius = 42
+ const heading = Math.PI - feature.get('heading')
+
+ return new Style({
+ image: new RegularShape({
+ points: 3,
+ radius,
+ rotation: heading,
+ fill: new Fill({ color: createLinearGradient(heading, radius) }),
+ displacement: [0, -(radius - 12)],
+ }),
+ })
+}
+
+export function getGeoLocationStyle() {
+ const fill = new Fill({
+ color: '#0078D4', // polar-blue/500
+ })
+ const stroke = new Stroke({
+ color: '#FFFFFF',
+ width: 2,
+ })
+
+ return (feature: FeatureLike) => [
+ dropDirectionalShadow(feature),
+ new Style({
+ image: new Circle({
+ fill,
+ stroke,
+ radius: 12,
+ }),
+ fill,
+ stroke,
+ }),
+ ]
+}
diff --git a/src/plugins/geoLocation/utils/positionChanged.ts b/src/plugins/geoLocation/utils/positionChanged.ts
new file mode 100644
index 000000000..8e4d21259
--- /dev/null
+++ b/src/plugins/geoLocation/utils/positionChanged.ts
@@ -0,0 +1,2 @@
+export const positionChanged = (oldPosition: number[], newPosition: number[]) =>
+ oldPosition[0] !== newPosition[0] || oldPosition[1] !== newPosition[1]
diff --git a/src/plugins/iconMenu/components/IconMenu.ce.vue b/src/plugins/iconMenu/components/IconMenu.ce.vue
new file mode 100644
index 000000000..554fbbbfe
--- /dev/null
+++ b/src/plugins/iconMenu/components/IconMenu.ce.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/src/plugins/iconMenu/components/NineRegionsButton.ce.vue b/src/plugins/iconMenu/components/NineRegionsButton.ce.vue
new file mode 100644
index 000000000..4935b6a23
--- /dev/null
+++ b/src/plugins/iconMenu/components/NineRegionsButton.ce.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue b/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue
new file mode 100644
index 000000000..a921a7ece
--- /dev/null
+++ b/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue b/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue
new file mode 100644
index 000000000..081104fce
--- /dev/null
+++ b/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/iconMenu/components/StandardMenu.ce.vue b/src/plugins/iconMenu/components/StandardMenu.ce.vue
new file mode 100644
index 000000000..dcf7f5622
--- /dev/null
+++ b/src/plugins/iconMenu/components/StandardMenu.ce.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/src/plugins/iconMenu/components/StandardMenuList.ce.vue b/src/plugins/iconMenu/components/StandardMenuList.ce.vue
new file mode 100644
index 000000000..bebc91c43
--- /dev/null
+++ b/src/plugins/iconMenu/components/StandardMenuList.ce.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/iconMenu/index.ts b/src/plugins/iconMenu/index.ts
new file mode 100644
index 000000000..cfb46eaeb
--- /dev/null
+++ b/src/plugins/iconMenu/index.ts
@@ -0,0 +1,35 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/iconMenu
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/IconMenu.ce.vue'
+import locales from './locales'
+import { useIconMenuStore } from './store'
+import { PluginId, type IconMenuPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin which adds the possibility to open various functionality as
+ * cards from an iconized menu.
+ * This way, obstructive UI can be hidden until the user desires to open it.
+ *
+ * Please use carefully – users may have issues finding process-relevant
+ * buttons or interactions if you hide them here.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginIconMenu(
+ options: IconMenuPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ locales,
+ storeModule: useIconMenuStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/iconMenu/locales.ts b/src/plugins/iconMenu/locales.ts
new file mode 100644
index 000000000..50ef15d82
--- /dev/null
+++ b/src/plugins/iconMenu/locales.ts
@@ -0,0 +1,58 @@
+import type { Locale } from '@/core'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the iconMenu plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/iconMenu
+ */
+/* eslint-enable tsdoc/syntax */
+
+/**
+ * German locales for iconMenu plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ mobileCloseButton: '{{plugin}} schließen',
+
+ /** Allows overriding the hints displayed as the tooltip, the aria-label or also sometimes the label. */
+ hints: {
+ attributions: 'Quellennachweis',
+ draw: 'Zeichenwerkzeuge',
+ filter: 'Filter',
+ layerChooser: 'Kartenauswahl',
+ gfi: 'Objektliste',
+ },
+} as const
+
+/**
+ * English locales for iconMenu plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ mobileCloseButton: 'Close {{plugin}}',
+
+ /** Allows overriding the hints displayed as the tooltip, the aria-label or also sometimes the label. */
+ hints: {
+ attributions: 'Attributions',
+ draw: 'Draw tools',
+ filter: 'Filter',
+ layerChooser: 'Choose map',
+ gfi: 'Feature list',
+ },
+} as const
+
+// first type will be used as fallback language
+const locales: Locale[] = [
+ {
+ type: 'de',
+ resources: resourcesDe,
+ },
+ {
+ type: 'en',
+ resources: resourcesEn,
+ },
+]
+
+export default locales
diff --git a/src/plugins/iconMenu/store.ts b/src/plugins/iconMenu/store.ts
new file mode 100644
index 000000000..6f51c8b5d
--- /dev/null
+++ b/src/plugins/iconMenu/store.ts
@@ -0,0 +1,183 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/iconMenu/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { t } from 'i18next'
+import { toMerged } from 'es-toolkit'
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { type Component, computed, markRaw, ref } from 'vue'
+import type { Menu } from './types'
+import { useCoreStore } from '@/core/stores/export'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for the icon menu.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useIconMenuStore = defineStore('plugins/iconMenu', () => {
+ const coreStore = useCoreStore()
+
+ const menus = ref>([])
+ const focusMenus = ref<(Menu & { icon: string })[]>([])
+ const open = ref(-1)
+ const focusOpen = ref(-1)
+
+ const buttonComponent = computed(() =>
+ coreStore.configuration.iconMenu?.buttonComponent
+ ? markRaw(coreStore.configuration.iconMenu.buttonComponent)
+ : null
+ )
+
+ function setupPlugin() {
+ menus.value = (coreStore.configuration.iconMenu?.menus || []).map(
+ (menuGroup) =>
+ menuGroup.filter(({ plugin: { id } }) => {
+ const display = coreStore.configuration[id]?.displayComponent
+ return typeof display === 'boolean' ? display : true
+ })
+ )
+ focusMenus.value = (
+ coreStore.configuration.iconMenu?.focusMenus || []
+ ).filter(({ plugin: { id } }) => {
+ const display = coreStore.configuration[id]?.displayComponent
+ return typeof display === 'boolean' ? display : true
+ })
+
+ menus.value
+ .concat(focusMenus.value)
+ .flat()
+ .forEach(({ plugin }) => {
+ coreStore.addPlugin(toMerged(plugin, { independent: false }))
+ })
+
+ // Otherwise, the component itself is made reactive
+ menus.value.map((menuGroup) =>
+ menuGroup.map((menuItem) =>
+ toMerged(menuItem, {
+ plugin: {
+ component: markRaw(menuItem.plugin.component as Component),
+ },
+ })
+ )
+ )
+ focusMenus.value.map((menuItem) =>
+ toMerged(menuItem, {
+ plugin: {
+ component: markRaw(menuItem.plugin.component as Component),
+ },
+ })
+ )
+
+ const initiallyOpen = coreStore.configuration.iconMenu?.initiallyOpen
+ if (
+ !coreStore.hasSmallHeight &&
+ !coreStore.hasSmallWidth &&
+ initiallyOpen
+ ) {
+ openMenuById(initiallyOpen)
+ }
+ const focusInitiallyOpen =
+ coreStore.configuration.iconMenu?.focusInitiallyOpen
+ if (
+ !coreStore.hasSmallHeight &&
+ !coreStore.hasSmallWidth &&
+ focusInitiallyOpen
+ ) {
+ openFocusMenuById(focusInitiallyOpen)
+ }
+ }
+ function teardownPlugin() {}
+
+ function openMenuById(openId: string) {
+ const index = menus.value.reduce((foundIndex, menuGroup, outerIndex) => {
+ const innerIndex = menuGroup.findIndex(
+ ({ plugin: { id } }) => id === openId
+ )
+ if (innerIndex === -1) {
+ if (foundIndex !== -1) {
+ return foundIndex
+ }
+ return -1
+ }
+ return outerIndex + innerIndex
+ }, -1)
+
+ if (index !== -1) {
+ open.value = index
+ openInMoveHandle(index)
+ }
+ }
+
+ function openFocusMenuById(openId: string) {
+ const index = focusMenus.value.findIndex(
+ ({ plugin: { id } }) => id === openId
+ )
+
+ if (index !== -1) {
+ focusOpen.value = index
+ openInMoveHandle(index, true)
+ }
+ }
+
+ function openInMoveHandle(index: number, focusMenu = false) {
+ const menu = focusMenu ? focusMenus.value[index] : menus.value.flat()[index]
+ if (!menu) {
+ console.error(`Menu with index ${index} could not be found.`)
+ return
+ }
+ if (!menu.plugin.component) {
+ console.error(
+ `The plugin ${menu.plugin.id} does not have any component to display and thus can not be opened in the moveHandle.`
+ )
+ return
+ }
+ // Content is displayed in the MoveHandle in this case. Thus, only one menu can be open at a time.
+ if (coreStore.hasWindowSize && coreStore.hasSmallWidth) {
+ if (focusMenu && open.value !== -1) {
+ open.value = -1
+ } else if (!focusMenu && focusOpen.value !== -1) {
+ focusOpen.value = -1
+ }
+ }
+ coreStore.setMoveHandle({
+ closeFunction: () => {
+ if (focusMenu) {
+ focusOpen.value = -1
+ return
+ }
+ open.value = -1
+ },
+ closeLabel: t(($) => $.mobileCloseButton, {
+ ns: 'iconMenu',
+ plugin: t(($) => $.hints[menu.plugin.id], { ns: 'iconMenu' }),
+ }),
+ component: menu.plugin.component,
+ plugin: 'iconMenu',
+ })
+ }
+
+ return {
+ menus,
+ focusMenus,
+ open,
+ focusOpen,
+ buttonComponent,
+ openInMoveHandle,
+ openMenuById,
+ openFocusMenuById,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+})
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useIconMenuStore, import.meta.hot))
+}
diff --git a/src/plugins/iconMenu/types.ts b/src/plugins/iconMenu/types.ts
new file mode 100644
index 000000000..477799f47
--- /dev/null
+++ b/src/plugins/iconMenu/types.ts
@@ -0,0 +1,99 @@
+import type { Component } from 'vue'
+import type { PluginContainer, PluginOptions } from '@/core'
+import type { NineLayoutTag } from '@/core/utils/NineLayoutTag.ts'
+
+export const PluginId = 'iconMenu'
+
+export interface Menu {
+ /**
+ * The plugin that should be part of the icon menu.
+ */
+ plugin: PluginContainer
+
+ /**
+ * Icon for icon menu button. If given, render a button with the icon. When clicked, open the content of the
+ * configured plugin. If not given, render the plugin content as is inside the IconMenu.
+ *
+ * Current examples for the usage without icon include Zoom and Fullscreen if
+ * {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'nineRegions'`
+ */
+ icon?: string
+}
+
+/**
+ * Plugin options for iconMenu plugin.
+ */
+export interface IconMenuPluginOptions extends PluginOptions {
+ /**
+ * Defines which plugins should be rendered as part of the icon menu.
+ * If {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'standard'`, multiple groups can be
+ * added through different arrays to differentiate plugins visually. Using multiple groups (arrays) doesn't yield any
+ * change if {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'nineRegions'`.
+ *
+ * @example
+ * ```
+ * {
+ * initiallyOpen: 'draw',
+ * displayComponent: true,
+ * menus: [
+ * [
+ * {
+ * plugin: PolarPluginFullscreen({}),
+ * icon: 'kern-icon--fullscreen',
+ * id: 'fullscreen',
+ * },
+ * {
+ * plugin: PolarPluginDraw({}),
+ * icon: 'kern-icon-fill--draw',
+ * id: 'draw',
+ * hint: 'Draw or write something on the map'
+ * },
+ * ]
+ * ]
+ * }
+ * ```
+ */
+ menus: Array
+
+ /**
+ * If {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'nineRegions'`, then this parameter
+ * allows overriding the `IconMenuButton.vue` component for custom design and functionality. Coding knowledge is required
+ * to use this feature, as any implementation will have to rely upon the Pinia store model and has to implement the
+ * same props as the default `IconMenuButton.vue`. Please refer to the implementation.
+ */
+ buttonComponent?: Component
+
+ /**
+ * ID of the plugin which should be open on start in the {@link focusMenus | `focusMenu`}.
+ *
+ * @remarks
+ * Only applicable if the device doesn't have a small display.
+ */
+ focusInitiallyOpen?: string
+
+ /**
+ * If {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'standard'`, a second menu that includes
+ * the hints as labels of the buttons is being displayed at the bottom of the map.
+ *
+ * Content is shown in the top left corner.
+ *
+ * @remarks
+ * Plugins like GeoLocation can not be added here, as only plugins containing content are allowed.
+ */
+ focusMenus?: (Menu & { icon: string })[]
+
+ /**
+ * ID of the plugin which should be open on start.
+ *
+ * @remarks
+ * Only applicable if the device doesn't have a small display.
+ */
+ initiallyOpen?: string
+
+ /**
+ * If {@link MapConfiguration.layout | `mapConfiguration.layers`} is set to `'nineRegions'`, then this parameter
+ * declares the positioning of the IconMenu. However, if {@link buttonComponent} is not set, then only `"TOP_RIGHT"`
+ * is allowed as value.
+ */
+ layoutTag?: keyof typeof NineLayoutTag
+}
diff --git a/src/plugins/layerChooser/components/LayerChooser.ce.vue b/src/plugins/layerChooser/components/LayerChooser.ce.vue
new file mode 100644
index 000000000..5853d05de
--- /dev/null
+++ b/src/plugins/layerChooser/components/LayerChooser.ce.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/layerChooser/components/LayerInformationCard.ce.vue b/src/plugins/layerChooser/components/LayerInformationCard.ce.vue
new file mode 100644
index 000000000..312a9c922
--- /dev/null
+++ b/src/plugins/layerChooser/components/LayerInformationCard.ce.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {{ $t(($) => $.returnToLayers, { ns: PluginId }) }}
+
+
+
+
+
+
+
+
diff --git a/src/plugins/layerChooser/components/LayerLegend.ce.vue b/src/plugins/layerChooser/components/LayerLegend.ce.vue
new file mode 100644
index 000000000..edc7a018b
--- /dev/null
+++ b/src/plugins/layerChooser/components/LayerLegend.ce.vue
@@ -0,0 +1,47 @@
+
+
+
+ {{ $t(($) => $.legend.to, { name: legend.name, ns: PluginId }) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/layerChooser/components/LayerOptions.ce.vue b/src/plugins/layerChooser/components/LayerOptions.ce.vue
new file mode 100644
index 000000000..1863be1b8
--- /dev/null
+++ b/src/plugins/layerChooser/components/LayerOptions.ce.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/layerChooser/components/LayerSelection.ce.vue b/src/plugins/layerChooser/components/LayerSelection.ce.vue
new file mode 100644
index 000000000..299968a39
--- /dev/null
+++ b/src/plugins/layerChooser/components/LayerSelection.ce.vue
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/layerChooser/components/LegendButton.ce.vue b/src/plugins/layerChooser/components/LegendButton.ce.vue
new file mode 100644
index 000000000..a7845f2d2
--- /dev/null
+++ b/src/plugins/layerChooser/components/LegendButton.ce.vue
@@ -0,0 +1,21 @@
+
+
+
+
+ {{ $t(($) => $.legend.open, { ns: PluginId }) }}
+
+
+
+
+
diff --git a/src/plugins/layerChooser/index.ts b/src/plugins/layerChooser/index.ts
new file mode 100644
index 000000000..733b7315c
--- /dev/null
+++ b/src/plugins/layerChooser/index.ts
@@ -0,0 +1,39 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/layerChooser
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/LayerChooser.ce.vue'
+import locales from './locales'
+import { useLayerChooserStore } from './store'
+import { PluginId } from './types'
+import type { PluginContainer, PluginOptions, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin that offers an additive (usually Overlays, technically named
+ * with `type: 'mask'`) and an exclusive (usually background maps,
+ * `type: 'background'`) selection of layers to the users.
+ *
+ * Order of layers within a layer is always as initially configured.
+ *
+ * The tool does not require any configuration for itself but is based on the
+ * {@link MapConfiguration.layers | `mapConfiguration.layers`}.
+ * It will infer `id` and `name` from that configuration.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginLayerChooser(
+ options: PluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ icon: 'kern-icon-fill--layers',
+ locales,
+ storeModule: useLayerChooserStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/layerChooser/locales.ts b/src/plugins/layerChooser/locales.ts
new file mode 100644
index 000000000..495d4c1ab
--- /dev/null
+++ b/src/plugins/layerChooser/locales.ts
@@ -0,0 +1,59 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the layerChooser plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/layerChooser
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { PluginId } from './types'
+import type { Locale } from '@/core'
+
+/**
+ * German locales for layerChooser plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ backgroundTitle: 'Hintergrundkarten',
+ maskTitle: 'Fachdaten',
+ layerHeader: 'Auswahl sichtbarer Ebenen für Layer "{{name}}"',
+ layerOptions: 'Optionen für Kartenmaterial',
+ legend: {
+ title: 'Legende',
+ to: 'Legendenbild zu "{{name}}"',
+ open: `$t(legend.to, {ns: ${PluginId}}) öffnen`,
+ },
+ returnToLayers: 'Zurück',
+} as const
+
+/**
+ * English locales for layerChooser plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ backgroundTitle: 'Background maps',
+ maskTitle: 'Subject data',
+ layerHeader: 'Visible layer selection for layer "{{name}}"',
+ layerOptions: 'Map data options',
+ legend: {
+ title: 'Legend',
+ to: '"{{name}}" legend image',
+ open: `Open $t(legend.to, {ns: ${PluginId}})`,
+ },
+ returnToLayers: 'Back',
+} as const
+
+// first type will be used as fallback language
+const locales: Locale[] = [
+ {
+ type: 'de',
+ resources: resourcesDe,
+ },
+ {
+ type: 'en',
+ resources: resourcesEn,
+ },
+]
+
+export default locales
diff --git a/src/plugins/layerChooser/store.ts b/src/plugins/layerChooser/store.ts
new file mode 100644
index 000000000..0a8c6b05b
--- /dev/null
+++ b/src/plugins/layerChooser/store.ts
@@ -0,0 +1,264 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/layerChooser/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { toMerged } from 'es-toolkit'
+import { defineStore } from 'pinia'
+import { computed, ref, watch } from 'vue'
+import Layer from 'ol/layer/Layer'
+import { ImageWMS, TileWMS } from 'ol/source'
+import type { LayerLegend, LayerOptions } from './types'
+import { areLayersActive } from './utils/areLayersActive'
+import {
+ loadCapabilities,
+ prepareLayersWithOptions,
+} from './utils/capabilities'
+import { prepareLegends } from './utils/prepareLegends'
+import { getBackgroundsAndMasks } from './utils/getBackgroundsAndMasks'
+import type { LayerConfiguration } from '@/core'
+import { useCoreStore } from '@/core/stores/export'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for the layer chooser.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useLayerChooserStore = defineStore('plugins/layerChooser', () => {
+ const coreStore = useCoreStore()
+
+ const capabilities = ref>({})
+
+ const backgrounds = ref([])
+ const masks = ref([])
+ const availableBackgrounds = ref([])
+ const availableMasks = ref([])
+ const activeBackgroundId = ref('')
+ const activeMaskIds = ref([])
+
+ const layersWithLegends = ref>({})
+ const openedLegendId = ref('')
+
+ const layersWithOptions = ref>({})
+ const openedOptionsId = ref('')
+
+ const disabledBackgrounds = computed(() =>
+ backgrounds.value.reduce(
+ (acc, { id }) => ({
+ ...acc,
+ [id]:
+ availableBackgrounds.value.findIndex(
+ ({ id: availableId }) => availableId === id
+ ) === -1,
+ }),
+ {}
+ )
+ )
+ const disabledMasks = computed(() =>
+ shownMasks.value.reduce(
+ (acc, { id }) => ({
+ ...acc,
+ [id]:
+ availableMasks.value.findIndex(
+ ({ id: availableId }) => availableId === id
+ ) === -1,
+ }),
+ {}
+ )
+ )
+ const shownMasks = computed(() =>
+ masks.value.filter(({ hideInMenu }) => !hideInMenu)
+ )
+ const masksSeparatedByType = computed(() =>
+ shownMasks.value.reduce>(
+ (acc, mask) =>
+ toMerged(acc, {
+ [mask.type]: Array.isArray(acc[mask.type])
+ ? // @ts-expect-error | TS says it might be undefined, even though the previous line checks existence.
+ acc[mask.type].concat(mask)
+ : [mask],
+ }),
+ {}
+ )
+ )
+
+ function setupPlugin() {
+ const [configuredBackgrounds, configuredMasks] = getBackgroundsAndMasks(
+ coreStore.configuration.layers
+ )
+ backgrounds.value = configuredBackgrounds
+ masks.value = configuredMasks
+
+ if (configuredBackgrounds.length === 0) {
+ console.error('No layers of type "background" have been configured.')
+ }
+
+ // At most one background, arbitrarily many masks
+ activeBackgroundId.value =
+ configuredBackgrounds.find(({ visibility }) => visibility)?.id || ''
+ activeMaskIds.value = configuredMasks
+ .filter(({ visibility }) => visibility)
+ .map(({ id }) => id)
+ updateActiveAndAvailableLayersByZoom()
+ coreStore.map.on('moveend', updateActiveAndAvailableLayersByZoom)
+
+ layersWithLegends.value = prepareLegends(coreStore.configuration.layers)
+
+ void loadCapabilities(
+ coreStore.configuration.layers,
+ capabilities.value
+ ).then((newCapabilities) => {
+ capabilities.value = newCapabilities
+
+ coreStore.configuration.layers.forEach((layer) => {
+ const layerOptions = layer.options?.layers
+ if (layerOptions) {
+ layersWithOptions.value = toMerged(
+ layersWithOptions.value,
+ prepareLayersWithOptions(layer.id, newCapabilities, layerOptions)
+ )
+ }
+ })
+ })
+ }
+ function teardownPlugin() {
+ coreStore.map.un('moveend', updateActiveAndAvailableLayersByZoom)
+ }
+
+ watch(activeBackgroundId, (id) => {
+ coreStore.map
+ .getLayers()
+ .getArray()
+ .forEach((layer) => {
+ // Only influence visibility if layer is managed as background
+ if (backgrounds.value.find(({ id }) => id === layer.get('id'))) {
+ layer.setVisible(layer.get('id') === id)
+ }
+ })
+ })
+
+ watch(activeMaskIds, (ids) => {
+ setActiveMaskIdsVisibility(ids)
+ })
+
+ function setActiveMaskIdsVisibility(ids: string[]) {
+ coreStore.map
+ .getLayers()
+ .getArray()
+ .forEach((layer) => {
+ // Only influence visibility if layer is managed as a mask
+ if (masks.value.find(({ id }) => id === layer.get('id'))) {
+ layer.setVisible(ids.includes(layer.get('id')))
+ }
+ })
+ }
+
+ function updateActiveAndAvailableLayersByZoom() {
+ /*
+ * NOTE: It is assumed that getZoom actually returns the currentZoomLevel,
+ * thus the view has a constraint in the resolution.
+ */
+ const currentZoomLevel = coreStore.map.getView().getZoom() as number
+
+ availableBackgrounds.value = areLayersActive(
+ backgrounds.value,
+ currentZoomLevel
+ )
+ availableMasks.value = areLayersActive(masks.value, currentZoomLevel)
+
+ const availableBackgroundIds = availableBackgrounds.value.map(
+ ({ id }) => id
+ )
+
+ // If the background map is no longer available, switch to first-best or none
+ if (!availableBackgroundIds.includes(activeBackgroundId.value)) {
+ activeBackgroundId.value = availableBackgroundIds[0] || ''
+ }
+
+ /*
+ * Update mask layer visibility, but don't toggle on/off in the UI.
+ * We still keep active layers active even when currently not available,
+ * so after zooming back they snap right back in.
+ */
+ setActiveMaskIdsVisibility(
+ availableMasks.value
+ .map(({ id }) => id)
+ .filter((id) => activeMaskIds.value.includes(id))
+ )
+ }
+
+ function toggleOpenedOptionsServiceLayer(layerIds: string[]) {
+ const olSource = (
+ coreStore.map
+ .getLayers()
+ .getArray()
+ .find((l) => l.get('id') === openedOptionsId.value) as Layer<
+ ImageWMS | TileWMS
+ >
+ ).getSource()
+
+ if (!olSource) {
+ console.error(
+ `Action 'toggleOpenedOptionsServiceLayer' failed on ${openedOptionsId.value}. Layer not found in OpenLayers or source not initialized in OpenLayers.`
+ )
+ return
+ }
+ olSource.updateParams({ ...olSource.getParams(), LAYERS: layerIds })
+ }
+
+ return {
+ /** Id of the currently active background layer. */
+ activeBackgroundId,
+
+ /**
+ * Ids of the currently active mask layers without distinction between mask groups.
+ */
+ activeMaskIds,
+
+ /** @internal */
+ backgrounds,
+
+ /**
+ * Maps a layer id to its GetCapabilities xml return value or null if an error happened.
+ *
+ * @internal
+ */
+ capabilities,
+
+ /** @internal */
+ disabledBackgrounds,
+
+ /** @internal */
+ disabledMasks,
+
+ /** @internal */
+ layersWithLegends,
+
+ /** @internal */
+ layersWithOptions,
+
+ /** @internal */
+ masksSeparatedByType,
+
+ /** @internal */
+ shownMasks,
+
+ /** @internal */
+ openedLegendId,
+
+ /** @internal */
+ openedOptionsId,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+
+ /** @internal */
+ toggleOpenedOptionsServiceLayer,
+ }
+})
diff --git a/src/plugins/layerChooser/types.ts b/src/plugins/layerChooser/types.ts
new file mode 100644
index 000000000..4b791a199
--- /dev/null
+++ b/src/plugins/layerChooser/types.ts
@@ -0,0 +1,28 @@
+export const PluginId = 'layerChooser'
+
+export interface LayerLegend {
+ name: string
+ url: string
+}
+
+export interface LayerOptions {
+ /**
+ * Name to be displayed in the layer options menu.
+ * Maps to the title received from the GetCapabilities request or the
+ * layer name if not configured or not part of the response.
+ */
+ displayName: string
+
+ /**
+ * Image to be displayed for the layer in the layer options menu.
+ * Maps to the legend image requested from the legend URL received from the
+ * GetCapabilities request. If not configured or not part of the response,
+ * this value is null so no image is displayed.
+ */
+ layerImage: string | null
+
+ /**
+ * Technical layer name.
+ */
+ layerName: string
+}
diff --git a/src/plugins/layerChooser/utils/areLayersActive.ts b/src/plugins/layerChooser/utils/areLayersActive.ts
new file mode 100644
index 000000000..5ad1f6df7
--- /dev/null
+++ b/src/plugins/layerChooser/utils/areLayersActive.ts
@@ -0,0 +1,20 @@
+import type { LayerConfiguration } from '@/core'
+
+/**
+ * Returns a boolean list which contains the attributions for every visible Layer.
+ *
+ * @param layers - layers carrying setup information.
+ * @param zoom - the zoom the map is currently in.
+ * @returns information about layer active property.
+ */
+export const areLayersActive = (layers: LayerConfiguration[], zoom: number) =>
+ layers.filter((layer) => {
+ let { minZoom, maxZoom } = layer
+ if (typeof minZoom === 'undefined') {
+ minZoom = 0
+ }
+ if (typeof maxZoom === 'undefined') {
+ maxZoom = Number.MAX_SAFE_INTEGER
+ }
+ return minZoom <= zoom && zoom <= maxZoom
+ })
diff --git a/src/plugins/layerChooser/utils/capabilities.ts b/src/plugins/layerChooser/utils/capabilities.ts
new file mode 100644
index 000000000..7069d92cc
--- /dev/null
+++ b/src/plugins/layerChooser/utils/capabilities.ts
@@ -0,0 +1,99 @@
+import { rawLayerList } from '@masterportal/masterportalapi'
+import { toMerged } from 'es-toolkit'
+import WMSCapabilities from 'ol/format/WMSCapabilities'
+import {
+ findLayerTitleInCapabilitiesByName,
+ findLegendUrlInCapabilitiesByName,
+} from './findInCapabilities'
+import type { LayerConfiguration, LayerConfigurationOptionLayers } from '@/core'
+
+function wmsCapabilitiesAsJsonById(
+ id: string,
+ capabilities: Record
+): object | null {
+ const xml = capabilities[id]
+ if (xml) {
+ try {
+ return new WMSCapabilities().read(xml)
+ } catch (e) {
+ console.error(`Error reading xml '${xml}' for id '${id}'.`, e)
+ }
+ }
+ return null
+}
+
+export function loadCapabilities(
+ configuredLayers: LayerConfiguration[],
+ capabilities: Record
+): Promise> {
+ return Promise.all(
+ configuredLayers.map(async (layer): Promise<[string, string | null]> => {
+ const { id } = layer
+ const layerOptions = layer.options?.layers
+ if (
+ layerOptions &&
+ (layerOptions.title === true || layerOptions.legend === true)
+ ) {
+ const previousCapabilities = capabilities[id]
+ if (typeof previousCapabilities === 'string') {
+ console.warn(
+ `Re-fired loadCapabilities on id '${id}' albeit the GetCapabilities have already been successfully fetched. No re-fetch will occur.`
+ )
+ return [id, null]
+ }
+
+ const service = rawLayerList.getLayerWhere({ id: layer.id })
+ if (!service || !service.url || !service.version || !service.typ) {
+ console.error(
+ `Missing data for service '${service}' with id '${id}'.`
+ )
+ return [id, null]
+ }
+
+ const capabilitiesUrl = `${service.url}?service=${service.typ}&version=${service.version}&request=GetCapabilities`
+
+ try {
+ const response = await fetch(capabilitiesUrl)
+ return [id, await response.text()]
+ } catch (e: unknown) {
+ console.error(
+ `Capabilities from ${capabilitiesUrl} could not be fetched.`,
+ e
+ )
+ return [id, null]
+ }
+ }
+ return [id, null]
+ })
+ ).then((values) =>
+ values.reduce((acc, [id, value]) => toMerged(acc, { [id]: value }), {})
+ )
+}
+
+export function prepareLayersWithOptions(
+ id: string,
+ capabilities: Record,
+ layerOptions: LayerConfigurationOptionLayers
+) {
+ const rawLayer: { layers: string } = rawLayerList.getLayerWhere({ id })
+ const wmsCapabilitiesJson = wmsCapabilitiesAsJsonById(id, capabilities)
+ return {
+ [id]: (layerOptions.order?.split(',') || rawLayer.layers.split(',')).map(
+ (layerName) => ({
+ layerName,
+ displayName:
+ layerOptions.title === true && wmsCapabilitiesJson
+ ? findLayerTitleInCapabilitiesByName(wmsCapabilitiesJson, layerName)
+ : layerOptions.title === false
+ ? layerName
+ : layerOptions.title?.[layerName] || layerName,
+ layerImage:
+ layerOptions.legend === true && wmsCapabilitiesJson
+ ? findLegendUrlInCapabilitiesByName(wmsCapabilitiesJson, layerName)
+ : layerOptions.legend === false
+ ? null
+ : layerOptions.legend?.[layerName] || null,
+ })
+ ),
+ }
+}
diff --git a/src/plugins/layerChooser/utils/findInCapabilities.ts b/src/plugins/layerChooser/utils/findInCapabilities.ts
new file mode 100644
index 000000000..2bc3c5831
--- /dev/null
+++ b/src/plugins/layerChooser/utils/findInCapabilities.ts
@@ -0,0 +1,74 @@
+/* NOTE: dig up from Capabilities by OGC WMS Capabilities specification E.1 in
+ * https://portal.ogc.org/files/?artifact_id=14416
+ * OL currently has no TS support for its return object, hence :any'ing here
+ */
+
+/**
+ * Finds a named layer from a root layer (array). First-found is returned,
+ * assuming that not multiple layers will have the same name, since they're a
+ * distinguishing feature for layer enabling/disabling via URL. Layers can be
+ * nested arbitrarily deep.
+ * NOTE: Should we start doing this a lot, consider memoization.
+ *
+ * @param layer - layer from ol/format/WMSCapabilities.
+ * @param name - name to search for.
+ * @returns capabilities layer with matching name.
+ */
+function deepLayerFind(layer, name: string) {
+ if (Array.isArray(layer)) {
+ return (
+ layer.map((l) => deepLayerFind(l, name)).find((l) => l !== null) || null
+ )
+ } else if (typeof layer === 'object') {
+ if (layer.Name === name) {
+ return layer
+ } else if (layer.Layer) {
+ return deepLayerFind(layer.Layer, name)
+ }
+ }
+
+ // layer is minOccurs="0", so we may always end up empty-handed
+ return null
+}
+
+/**
+ * @param style - style of a layer from ol/format/WMSCapabilities.
+ * @returns array of all found legend URLs.
+ */
+const getAllLegendURLs = (style): string[] =>
+ (Array.isArray(style) ? style : [style])
+ .map((styleObject) =>
+ (Array.isArray(styleObject.LegendURL)
+ ? styleObject.LegendURL
+ : typeof styleObject.LegendURL === 'object'
+ ? [styleObject.LegendURL]
+ : []
+ ).map((legendUrl) => legendUrl.OnlineResource)
+ )
+ .flat(1)
+
+/**
+ * @param capabilities - capabilities from ol/format/WMSCapabilities.
+ * @param name - name of the layer to find title for.
+ * @returns title, or empty string if not found.
+ */
+export function findLayerTitleInCapabilitiesByName(capabilities, name: string) {
+ const layer = deepLayerFind(capabilities.Capability.Layer, name)
+ return layer?.Title || ''
+}
+
+/**
+ * @param capabilities - capabilities from ol/format/WMSCapabilities.
+ * @param name - name of the layer to find legendURL for.
+ * @returns legend URL as string, or empty string if not found.
+ */
+export function findLegendUrlInCapabilitiesByName(capabilities, name: string) {
+ const layer = deepLayerFind(capabilities.Capability.Layer, name)
+ const style = layer?.Style
+ if (!style) {
+ return ''
+ }
+ const urls: string[] = getAllLegendURLs(style)
+ // NOTE: choosing URL is more complex when supporting layer styles
+ return urls[0] || ''
+}
diff --git a/src/plugins/layerChooser/utils/getBackgroundsAndMasks.ts b/src/plugins/layerChooser/utils/getBackgroundsAndMasks.ts
new file mode 100644
index 000000000..0daa8cc9e
--- /dev/null
+++ b/src/plugins/layerChooser/utils/getBackgroundsAndMasks.ts
@@ -0,0 +1,26 @@
+import { rawLayerList } from '@masterportal/masterportalapi'
+import type { LayerConfiguration } from '@/core'
+
+export const getBackgroundsAndMasks = (
+ layers: LayerConfiguration[]
+): [LayerConfiguration[], LayerConfiguration[]] =>
+ layers.reduce(
+ ([backgrounds, masks], current) => {
+ const rawLayer = rawLayerList.getLayerWhere({
+ id: current.id,
+ })
+
+ if (rawLayer === null) {
+ console.error(
+ `Layer ${current.id} not found in service register. This is a configuration issue. The map might behave in unexpected ways.`,
+ current
+ )
+ return [backgrounds, masks]
+ }
+
+ return current.type === 'background'
+ ? [[...backgrounds, current], masks]
+ : [backgrounds, [...masks, current]]
+ },
+ [[] as LayerConfiguration[], [] as LayerConfiguration[]]
+ )
diff --git a/src/plugins/layerChooser/utils/prepareLegends.ts b/src/plugins/layerChooser/utils/prepareLegends.ts
new file mode 100644
index 000000000..8141162f2
--- /dev/null
+++ b/src/plugins/layerChooser/utils/prepareLegends.ts
@@ -0,0 +1,22 @@
+import { layerLib, rawLayerList } from '@masterportal/masterportalapi'
+import { toMerged } from 'es-toolkit'
+import type { LayerLegend } from '../types'
+import type { LayerConfiguration } from '@/core'
+
+export const prepareLegends = (
+ layers: LayerConfiguration[]
+): Record =>
+ layers
+ .map(({ id, name }) => ({ id, name }))
+ .map((layer) =>
+ toMerged(layer, {
+ rawLayer: rawLayerList.getLayerWhere({ id: layer.id }),
+ })
+ )
+ .filter(({ rawLayer }) => rawLayer !== null)
+ .reduce((acc, layer) => {
+ const url = layerLib.getLegendURLs(layer.rawLayer)[0]
+ return typeof url === 'string'
+ ? { ...acc, [layer.id]: { name: layer.name, url } }
+ : acc
+ }, {})
diff --git a/src/plugins/loadingIndicator/assets/BasicLoader.gif b/src/plugins/loadingIndicator/assets/BasicLoader.gif
new file mode 100644
index 000000000..8ae360b0f
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/BasicLoader.gif differ
diff --git a/src/plugins/loadingIndicator/assets/CircleLoader.gif b/src/plugins/loadingIndicator/assets/CircleLoader.gif
new file mode 100644
index 000000000..1ed00949d
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/CircleLoader.gif differ
diff --git a/src/plugins/loadingIndicator/assets/KernLoader.gif b/src/plugins/loadingIndicator/assets/KernLoader.gif
new file mode 100644
index 000000000..47237b7f3
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/KernLoader.gif differ
diff --git a/src/plugins/loadingIndicator/assets/RingLoader.gif b/src/plugins/loadingIndicator/assets/RingLoader.gif
new file mode 100644
index 000000000..71fffb662
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/RingLoader.gif differ
diff --git a/src/plugins/loadingIndicator/assets/RollerLoader.gif b/src/plugins/loadingIndicator/assets/RollerLoader.gif
new file mode 100644
index 000000000..4bbe376ca
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/RollerLoader.gif differ
diff --git a/src/plugins/loadingIndicator/assets/SpinnerLoader.gif b/src/plugins/loadingIndicator/assets/SpinnerLoader.gif
new file mode 100644
index 000000000..6af68a109
Binary files /dev/null and b/src/plugins/loadingIndicator/assets/SpinnerLoader.gif differ
diff --git a/src/plugins/loadingIndicator/components/LoadingIndicator.ce.vue b/src/plugins/loadingIndicator/components/LoadingIndicator.ce.vue
new file mode 100644
index 000000000..d5bad0e2b
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/LoadingIndicator.ce.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/BasicLoader.ce.vue b/src/plugins/loadingIndicator/components/loaderStyles/BasicLoader.ce.vue
new file mode 100644
index 000000000..a3b10c651
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/BasicLoader.ce.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/CircleLoader.ce.vue b/src/plugins/loadingIndicator/components/loaderStyles/CircleLoader.ce.vue
new file mode 100644
index 000000000..08f2f3075
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/CircleLoader.ce.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/RingLoader.ce.vue b/src/plugins/loadingIndicator/components/loaderStyles/RingLoader.ce.vue
new file mode 100644
index 000000000..d8339add0
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/RingLoader.ce.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/RollerLoader.ce.vue b/src/plugins/loadingIndicator/components/loaderStyles/RollerLoader.ce.vue
new file mode 100644
index 000000000..1a352b10f
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/RollerLoader.ce.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/SpinnerLoader.ce.vue b/src/plugins/loadingIndicator/components/loaderStyles/SpinnerLoader.ce.vue
new file mode 100644
index 000000000..bb2ea288d
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/SpinnerLoader.ce.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/loadingIndicator/components/loaderStyles/index.ts b/src/plugins/loadingIndicator/components/loaderStyles/index.ts
new file mode 100644
index 000000000..39a5d8c89
--- /dev/null
+++ b/src/plugins/loadingIndicator/components/loaderStyles/index.ts
@@ -0,0 +1,5 @@
+export { default as BasicLoader } from './BasicLoader.ce.vue'
+export { default as CircleLoader } from './CircleLoader.ce.vue'
+export { default as RingLoader } from './RingLoader.ce.vue'
+export { default as RollerLoader } from './RollerLoader.ce.vue'
+export { default as SpinnerLoader } from './SpinnerLoader.ce.vue'
diff --git a/src/plugins/loadingIndicator/index.ts b/src/plugins/loadingIndicator/index.ts
new file mode 100644
index 000000000..57a15ca49
--- /dev/null
+++ b/src/plugins/loadingIndicator/index.ts
@@ -0,0 +1,29 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/loadingIndicator
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/LoadingIndicator.ce.vue'
+import { useLoadingIndicatorStore } from './store'
+import { type LoadingIndicatorOptions, PluginId } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin that offers a generic loading indicator that may be used by
+ * any plugin or outside procedure to indicate loading.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginLoadingIndicator(
+ options: LoadingIndicatorOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ storeModule: useLoadingIndicatorStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/loadingIndicator/store.ts b/src/plugins/loadingIndicator/store.ts
new file mode 100644
index 000000000..908a09886
--- /dev/null
+++ b/src/plugins/loadingIndicator/store.ts
@@ -0,0 +1,108 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/loadingIndicator/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import { useCoreStore } from '@/core/stores/export.ts'
+
+const styles = [
+ 'KernLoader',
+ 'BasicLoader',
+ 'RingLoader',
+ 'RollerLoader',
+ 'CircleLoader',
+ 'SpinnerLoader',
+] as const
+
+export type LoaderStyles = (typeof styles)[number]
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for the loading indicator.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useLoadingIndicatorStore = defineStore(
+ 'plugins/loadingIndicator',
+ () => {
+ const loadKeys = ref(new Set())
+ const loaderStyle = ref('KernLoader')
+ const showLoader = computed(() => loadKeys.value.size > 0)
+
+ function setupPlugin() {
+ const configuredStyle =
+ useCoreStore().configuration.loadingIndicator?.loaderStyle
+ if (configuredStyle) {
+ setLoaderStyle(configuredStyle)
+ }
+ }
+ function teardownPlugin() {
+ setLoaderStyle('KernLoader')
+ }
+
+ function addLoadingKey(key: string) {
+ loadKeys.value = new Set([...loadKeys.value, key])
+ }
+
+ function removeLoadingKey(key: string) {
+ const newLoadKeys = new Set(loadKeys.value)
+ newLoadKeys.delete(key)
+ loadKeys.value = newLoadKeys
+ }
+
+ function setLoaderStyle(style: LoaderStyles) {
+ if (styles.includes(style)) {
+ loaderStyle.value = style
+ } else {
+ console.error(
+ `Loader style ${style} does not exist. Using previous style (${loaderStyle.value}).`
+ )
+ }
+ }
+
+ return {
+ /** The current loader style. */
+ loaderStyle,
+
+ /** Whether the loader should currently be shown. */
+ showLoader,
+
+ /**
+ * Adds a loading indicator with the given `key`.
+ *
+ * The `key` is a unique identifier used to keep track of the added loader
+ * via a Set. It can't be added multiple times, and removing it once always
+ * removes it altogether.
+ *
+ * The LoadingIndicator will usually be used for asynchronous code.
+ *
+ * @remarks
+ * It is advised to use a key like `{my-plugin-or-application-name}-{procedure-name}`
+ * to avoid name conflicts.
+ */
+ addLoadingKey,
+
+ /**
+ * Removes the loading indicator with the given `key`.
+ *
+ * @remarks
+ * This function **always has to be called in the `finally` section of your code**
+ * to prevent hanging loading indicators.
+ */
+ removeLoadingKey,
+
+ /** Change the loader style at runtime. */
+ setLoaderStyle,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+ }
+)
diff --git a/src/plugins/loadingIndicator/types.ts b/src/plugins/loadingIndicator/types.ts
new file mode 100644
index 000000000..7fb721b2f
--- /dev/null
+++ b/src/plugins/loadingIndicator/types.ts
@@ -0,0 +1,31 @@
+import type { LoaderStyles } from './store'
+import type { PluginOptions } from '@/core'
+
+export const PluginId = 'loadingIndicator'
+
+export interface LoadingIndicatorOptions extends PluginOptions {
+ /**
+ * Choose between different loader styles.
+ *
+ * Supported options:
+ *
+ *
+ * KernLoader
+ * BasicLoader
+ * RingLoader
+ *
+ *
+ * RollerLoader
+ * CircleLoader
+ * SpinnerLoader
+ *
+ *
+ *
+ * It is also possible to choose `null` as a `loaderStyle` to hide the loader.
+ *
+ * @defaultValue `'KernLoader'`
+ * @privateRemarks
+ * TODO(dopenguin): Add PolarLoader that includes the Logo
+ */
+ loaderStyle?: LoaderStyles | null
+}
diff --git a/src/plugins/pins/index.ts b/src/plugins/pins/index.ts
new file mode 100644
index 000000000..56de5b0c7
--- /dev/null
+++ b/src/plugins/pins/index.ts
@@ -0,0 +1,33 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/pins
+ */
+/* eslint-enable tsdoc/syntax */
+
+import locales from './locales'
+import { usePinsStore } from './store'
+import { PluginId, type PinsPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Pins plugin for POLAR that adds map interactions to client that allow users
+ * to indicate a specific point on the map.
+ *
+ * The plugin handles marking locations. Embedding processes can then use that
+ * coordinate for further steps. The plugin may react to other plugins,
+ * especially address searches.
+ *
+ * @returns Plugin for use with {@link addPlugin}.
+ */
+export default function pluginPins(
+ options: PinsPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ locales,
+ storeModule: usePinsStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/pins/locales.ts b/src/plugins/pins/locales.ts
new file mode 100644
index 000000000..aa800119e
--- /dev/null
+++ b/src/plugins/pins/locales.ts
@@ -0,0 +1,51 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the pins plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/pins
+ */
+/* eslint-enable tsdoc/syntax */
+
+import type { Locale } from '@/core'
+
+/**
+ * German locales for pins plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ boundaryError:
+ 'Die Überprüfung der Koordinate ist fehlgeschlagen. Bitte versuchen Sie es später erneut oder wenden Sie sich an einen Administrator, wenn das Problem bestehen bleibt.',
+ notInBoundary: 'Diese Koordinate kann nicht gewählt werden.',
+} as const
+
+/**
+ * English locales for pins plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ boundaryError:
+ 'Validating the coordinate failed. Please try again later or contact an administrator if the issue persists.',
+ notInBoundary: 'It is not possible to select this coordinate.',
+} as const
+
+/**
+ * Pins plugin 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/pins/store.ts b/src/plugins/pins/store.ts
new file mode 100644
index 000000000..8841ec312
--- /dev/null
+++ b/src/plugins/pins/store.ts
@@ -0,0 +1,258 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/pins/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { toMerged } from 'es-toolkit'
+import type { GeoJsonGeometryTypes } from 'geojson'
+import { defineStore } from 'pinia'
+import type { Coordinate } from 'ol/coordinate'
+import { pointerMove } from 'ol/events/condition'
+import Feature from 'ol/Feature'
+import Point from 'ol/geom/Point'
+import { Draw, Modify, Select, Translate } from 'ol/interaction'
+import VectorLayer from 'ol/layer/Vector'
+import { toLonLat } from 'ol/proj'
+import { Vector } from 'ol/source'
+import { computed, ref, watch, type WatchHandle } from 'vue'
+import type { PinMovable, PinsPluginOptions } from './types'
+import { getPinStyle } from './utils/getPinStyle'
+import { getPointCoordinate } from './utils/getPointCoordinate'
+import { isCoordinateInBoundaryLayer } from './utils/isCoordinateInBoundaryLayer'
+import { useCoreStore } from '@/core/stores/export'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for adding a pin to the map for e.g. coordinate retrieval or
+ * marking the location of a found address.
+ */
+/* eslint-enable tsdoc/syntax */
+export const usePinsStore = defineStore('plugins/pins', () => {
+ const coreStore = useCoreStore()
+
+ const coordinate = ref([])
+ const getsDragged = ref(false)
+
+ const configuration = computed<
+ PinsPluginOptions & {
+ minZoomLevel: number
+ movable: PinMovable
+ toZoomLevel: number
+ }
+ >(() =>
+ toMerged(
+ { minZoomLevel: 0, movable: 'none', toZoomLevel: 0 },
+ coreStore.configuration.pins || {}
+ )
+ )
+ const latLon = computed(() => {
+ const lonLat = toLonLat(coordinate.value, coreStore.configuration.epsg)
+ return [lonLat[1], lonLat[0]]
+ })
+
+ const pinLayer = new VectorLayer({
+ source: new Vector(),
+ style: getPinStyle(configuration.value.style || {}),
+ })
+ const move = new Select({
+ layers: (l) => l === pinLayer,
+ style: null,
+ condition: pointerMove,
+ })
+ const translate = new Translate({
+ condition: () =>
+ (coreStore.map.getView().getZoom() as number) >=
+ configuration.value.minZoomLevel,
+ layers: [pinLayer],
+ })
+ let coordinateSourceWatcher: WatchHandle | null = null
+
+ function setupPlugin() {
+ coreStore.map.addLayer(pinLayer)
+ pinLayer.setZIndex(100)
+ coreStore.map.on('singleclick', async ({ coordinate }) => {
+ await click(coordinate)
+ })
+ setupCoordinateSource()
+ setupInitial()
+ setupInteractions()
+ }
+
+ function teardownPlugin() {
+ const { map } = coreStore
+ map.un('singleclick', async ({ coordinate }) => {
+ await click(coordinate)
+ })
+ removePin()
+ 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 pluginStore = coreStore.getPluginStore(source.pluginName)
+ if (!pluginStore) {
+ return
+ }
+ // TODO: After e.g. AddressSearch has been implemented, check if {deep: true} is needed as an option for watch
+ // redo pin if source (e.g. from addressSearch) changes
+ coordinateSourceWatcher = watch(
+ () => pluginStore[source.getterName],
+ (feature) => {
+ // 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, {
+ epsg: feature.epsg,
+ type: feature.geometry.type,
+ })
+ }
+ }
+ )
+ })
+ }
+
+ function setupInitial() {
+ const { initial } = configuration.value
+ if (initial) {
+ const { coordinate, centerOn, epsg } = initial
+
+ if (centerOn) {
+ addPin(coordinate, false, {
+ epsg: epsg || coreStore.configuration.epsg,
+ type: 'Point',
+ })
+ return
+ }
+ addPin(coordinate)
+ }
+ }
+
+ function setupInteractions() {
+ move.on('select', ({ selected }) => {
+ if (configuration.value.movable === 'none') {
+ document.body.style.cursor = selected.length ? 'not-allowed' : ''
+ }
+ })
+ coreStore.map.addInteraction(move)
+
+ const { movable } = configuration.value
+ if (movable !== 'drag') {
+ return
+ }
+ translate.on('translatestart', () => (getsDragged.value = true))
+ translate.on('translateend', ({ features }) => {
+ getsDragged.value = false
+
+ features.forEach(async (feature) => {
+ const geometryCoordinates = (
+ feature.getGeometry() as Point
+ ).getCoordinates()
+
+ addPin(
+ !(await isCoordinateInBoundaryLayer(
+ geometryCoordinates,
+ coreStore.map,
+ configuration.value.boundary
+ ))
+ ? coordinate.value
+ : geometryCoordinates
+ )
+ })
+ })
+ coreStore.map.addInteraction(translate)
+ }
+
+ async function click(coordinate: Coordinate) {
+ const isDrawing = coreStore.map
+ .getInteractions()
+ .getArray()
+ .some(
+ (interaction) =>
+ (interaction instanceof Draw &&
+ // @ts-expect-error | internal hack to detect it from @polar/plugin-gfi and @polar/plugin-draw
+ (interaction._isMultiSelect || interaction._isDrawPlugin)) ||
+ interaction instanceof Modify ||
+ // @ts-expect-error | internal hack to detect it from @polar/plugin-draw
+ interaction._isDeleteSelect
+ )
+ const { minZoomLevel, movable } = configuration.value
+ if (
+ (movable === 'drag' || movable === 'click') &&
+ // NOTE: It is assumed that getZoom actually returns the currentZoomLevel, thus the view has a constraint in the resolution.
+ (coreStore.map.getView().getZoom() as number) >= minZoomLevel &&
+ !isDrawing &&
+ (await isCoordinateInBoundaryLayer(
+ coordinate,
+ coreStore.map,
+ configuration.value.boundary
+ ))
+ ) {
+ addPin(coordinate)
+ }
+ }
+
+ function addPin(
+ newCoordinate: Coordinate,
+ clicked = true,
+ pinInformation?: {
+ epsg: string
+ type: Exclude
+ }
+ ) {
+ // Always clean up other/old pin first – single pin only atm.
+ removePin()
+ coordinate.value = newCoordinate
+ if (!clicked && pinInformation) {
+ coordinate.value = getPointCoordinate(
+ pinInformation.epsg,
+ coreStore.configuration.epsg,
+ pinInformation.type,
+ newCoordinate
+ )
+ coreStore.map.getView().setCenter(coordinate.value)
+ coreStore.map.getView().setZoom(configuration.value.toZoomLevel)
+ }
+ ;(pinLayer.getSource() as Vector).addFeature(
+ new Feature({
+ geometry: new Point(coordinate.value),
+ type: 'point',
+ name: 'mapMarker',
+ zIndex: 100,
+ })
+ )
+ }
+
+ function removePin() {
+ ;(pinLayer.getSource() as Vector).clear()
+ }
+
+ return {
+ /**
+ * Current coordinate of the pin.
+ */
+ coordinate,
+
+ /**
+ * The {@link coordinate | pinCoordinate} transcribed to latitude / longitude.
+ */
+ latLon,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+})
diff --git a/src/plugins/pins/types.ts b/src/plugins/pins/types.ts
new file mode 100644
index 000000000..db1761638
--- /dev/null
+++ b/src/plugins/pins/types.ts
@@ -0,0 +1,112 @@
+import type {
+ Color,
+ LayerBoundPluginOptions,
+ PluginId as PolarPluginId,
+} from '@/core'
+
+/** Plugin identifier. */
+export const PluginId = 'pins'
+
+export type PinMovable = 'drag' | 'click' | 'none'
+
+/** Plugin options for pins plugin. */
+export interface PinsPluginOptions extends LayerBoundPluginOptions {
+ /**
+ * The pins plugin may react to changes in other plugins.
+ * This parameter specifies the paths to such store positions.
+ *
+ * The position must, when subscribed to, return a GeoJSON feature.
+ *
+ * Please mind that, when referencing another plugin, that plugin must be
+ * added through `addPlugin` before this plugin for the connection to work.
+ *
+ * @example
+ * ```
+ * [{
+ * pluginName: 'addressSearch',
+ * getterName: 'chosenAddress'
+ * }]
+ * ```
+ */
+ coordinateSources?: { pluginName: PolarPluginId; getterName: string }[]
+
+ /**
+ * Configuration options for setting an initial pin.
+ *
+ * @example
+ * ```
+ * {
+ * coordinate: [611694.909470, 5975658.233007],
+ * centerOn: true,
+ * epsg: 'EPSG:25832'
+ * }
+ * ```
+ */
+ initial?: InitialPin
+
+ /**
+ * Minimum zoom level for sensible marking.
+ *
+ * @defaultValue 0
+ */
+ minZoomLevel?: number
+
+ /**
+ * Whether a user may drag and re-click the pin (`'drag'`), only re-click it
+ * (`'click'`) or may only be placed programmatically (`'none'`).
+ *
+ * @defaultValue 'none'
+ */
+ movable?: PinMovable
+
+ /** Display style configuration. */
+ style?: PinStyle
+
+ /**
+ * Zoom level to use on outside input by e.g. address search.
+ *
+ * @defaultValue 0
+ */
+ toZoomLevel?: number
+}
+
+// TODO(dopenguin): Expand this to also be able to change the SVG
+export interface PinStyle {
+ /**
+ * Fill color of the pin.
+ *
+ * @defaultValue '#005CA9'
+ */
+ fill?: Color
+
+ /**
+ * Stroke (that is, border) color of the pin.
+ *
+ * @defaultValue '#FFF'
+ */
+ stroke?: Color
+
+ /**
+ * Custom SVG icon for the pin icon.
+ */
+ svg?: string
+}
+
+interface InitialPin {
+ /** Coordinate pair for the pin. */
+ coordinate: number[]
+
+ /**
+ * If set to true, center on and zoom to the given coordinates on start
+ *
+ * @defaultValue false
+ */
+ centerOn?: boolean
+
+ /**
+ * Coordinate reference system in which the given coordinates are encoded.
+ *
+ * Defaults to {@link MapConfiguration.epsg | `mapConfiguration.epsg`}.
+ */
+ epsg?: string
+}
diff --git a/src/plugins/pins/utils/getPinStyle.ts b/src/plugins/pins/utils/getPinStyle.ts
new file mode 100644
index 000000000..9e4dae2b2
--- /dev/null
+++ b/src/plugins/pins/utils/getPinStyle.ts
@@ -0,0 +1,35 @@
+import { Style, Icon } from 'ol/style'
+import type { PinStyle } from '../types'
+import { getPinSvg } from './getPinSvg'
+
+export const getPinStyle = ({
+ fill = '#005CA9',
+ stroke = '#FFF',
+ svg,
+}: PinStyle) => {
+ let usedFill = ''
+ if (typeof fill === 'string') {
+ usedFill = fill
+ } else if ('oklch' in fill) {
+ usedFill = `oklch(${fill.oklch.l} ${fill.oklch.c} ${fill.oklch.h})`
+ } else if ('rgba' in fill) {
+ usedFill = `${fill.rgba.r} ${fill.rgba.g} ${fill.rgba.b} ${fill.rgba.a ? fill.rgba.a : ''}`
+ }
+
+ let usedStroke = ''
+ if (typeof stroke === 'string') {
+ usedStroke = stroke
+ } else if ('oklch' in stroke) {
+ usedStroke = `oklch(${stroke.oklch.l} ${stroke.oklch.c} ${stroke.oklch.h})`
+ } else if ('rgba' in stroke) {
+ usedStroke = `${stroke.rgba.r} ${stroke.rgba.g} ${stroke.rgba.b} ${stroke.rgba.a ? stroke.rgba.a : ''}`
+ }
+
+ return new Style({
+ image: new Icon({
+ src: `data:image/svg+xml;base64,${btoa(getPinSvg(usedFill, usedStroke, svg))}`,
+ scale: 2,
+ anchor: [0.5, 1],
+ }),
+ })
+}
diff --git a/packages/plugins/Pins/src/util/getPinSvg.ts b/src/plugins/pins/utils/getPinSvg.ts
similarity index 93%
rename from packages/plugins/Pins/src/util/getPinSvg.ts
rename to src/plugins/pins/utils/getPinSvg.ts
index 8793b4c59..1f93ec1bc 100644
--- a/packages/plugins/Pins/src/util/getPinSvg.ts
+++ b/src/plugins/pins/utils/getPinSvg.ts
@@ -11,7 +11,30 @@
*/
-export const getPinSvg = ({ fill = '#005CA9', stroke = '#FFF' }) => `
+export const getPinSvg = (fill: string, stroke: string, svg?: string) => {
+ if (svg) {
+ const document = new DOMParser().parseFromString(svg, 'image/svg+xml')
+ // Update fill and stroke values
+ document.querySelectorAll('[fill]').forEach((el) => {
+ el.setAttribute('fill', fill)
+ })
+ document.querySelectorAll('[stroke]').forEach((el) => {
+ el.setAttribute('stroke', stroke)
+ })
+ // Set fill and stoke values on elements that do not have it.
+ document
+ .querySelectorAll('path, circle, rect, polygon, ellipse')
+ .forEach((el) => {
+ if (!el.hasAttribute('fill')) {
+ el.setAttribute('fill', fill)
+ }
+ if (!el.hasAttribute('stroke')) {
+ el.setAttribute('stroke', stroke)
+ }
+ })
+ return new XMLSerializer().serializeToString(document)
+ }
+ return `
`
/>
`
+}
/*
diff --git a/src/plugins/pins/utils/getPointCoordinate.ts b/src/plugins/pins/utils/getPointCoordinate.ts
new file mode 100644
index 000000000..89e4aff72
--- /dev/null
+++ b/src/plugins/pins/utils/getPointCoordinate.ts
@@ -0,0 +1,55 @@
+import type { GeoJsonGeometryTypes } from 'geojson'
+import type { Coordinate } from 'ol/coordinate'
+import {
+ Circle,
+ LinearRing,
+ LineString,
+ MultiLineString,
+ MultiPoint,
+ MultiPolygon,
+ Point,
+ Polygon,
+} from 'ol/geom'
+import { transform } from 'ol/proj'
+import { getCenter } from 'ol/extent'
+
+// TODO: This function is exported as part of the module and currently used in DISH. Check whether that is still needed.
+
+/* eslint-disable @typescript-eslint/naming-convention */
+const geometries = {
+ Circle,
+ LinearRing,
+ LineString,
+ MultiLineString,
+ MultiPoint,
+ MultiPolygon,
+ Point,
+ Polygon,
+}
+/* eslint-enable @typescript-eslint/naming-convention */
+
+export function getPointCoordinate(
+ sourceEpsg: string,
+ targetEpsg: string,
+ geometryType: Exclude,
+ coordinate: Coordinate
+) {
+ const instance = new geometries[geometryType](coordinate)
+ let pointCoordinate = getCenter(instance.getExtent())
+
+ // return random point if bbox center is not in shape
+ if (
+ (geometryType === 'Polygon' || geometryType === 'MultiPolygon') &&
+ !instance.intersectsCoordinate(pointCoordinate)
+ ) {
+ pointCoordinate = (
+ instance.getType() === 'Polygon'
+ ? (instance as Polygon).getInteriorPoint()
+ : (instance as MultiPolygon).getInteriorPoints()
+ ).getFirstCoordinate()
+ }
+
+ return sourceEpsg === targetEpsg
+ ? pointCoordinate
+ : transform(pointCoordinate, sourceEpsg, targetEpsg)
+}
diff --git a/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts b/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts
new file mode 100644
index 000000000..4e2598168
--- /dev/null
+++ b/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts
@@ -0,0 +1,43 @@
+import { t } from 'i18next'
+import type { Map } from 'ol'
+import type { Coordinate } from 'ol/coordinate'
+import type { BoundaryOptions } from '@/core'
+import { notifyUser } from '@/lib/notifyUser'
+import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck'
+
+/**
+ * Checks if boundary layer conditions are met; returns false if not and
+ * toasts to the user about why the action was blocked, if `toastAction` is
+ * configured. If no boundaryLayer configured, always returns true.
+ */
+export async function isCoordinateInBoundaryLayer(
+ coordinate: Coordinate,
+ map: Map,
+ boundary?: BoundaryOptions
+) {
+ if (!boundary) {
+ return true
+ }
+ const boundaryCheckResult = await passesBoundaryCheck(
+ map,
+ boundary.layerId,
+ coordinate
+ )
+ if (
+ boundaryCheckResult === true ||
+ // If a setup error occurred, client will act as if no boundary was specified.
+ (typeof boundaryCheckResult === 'symbol' && boundary.onError !== 'strict')
+ ) {
+ return true
+ }
+
+ if (typeof boundaryCheckResult === 'symbol') {
+ notifyUser('error', () => t(($) => $.boundaryError, { ns: 'pins' }))
+ console.error('Checking boundary layer failed.')
+ } else {
+ notifyUser('info', () => t(($) => $.notInBoundary, { ns: 'pins' }))
+ // eslint-disable-next-line no-console
+ console.info('Pin position outside of boundary layer:', coordinate)
+ }
+ return false
+}
diff --git a/src/plugins/reverseGeocoder/index.ts b/src/plugins/reverseGeocoder/index.ts
new file mode 100644
index 000000000..6eb1dac6b
--- /dev/null
+++ b/src/plugins/reverseGeocoder/index.ts
@@ -0,0 +1,26 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/reverseGeocoder
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { useReverseGeocoderStore } from './store'
+import { PluginId, type ReverseGeocoderPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin which converts coordinates into addresses.
+ *
+ * @returns Plugin for use with {@link addPlugin}
+ */
+export default function pluginReverseGeocoder(
+ options: ReverseGeocoderPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ storeModule: useReverseGeocoderStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts
new file mode 100644
index 000000000..5a31b0e51
--- /dev/null
+++ b/src/plugins/reverseGeocoder/store.ts
@@ -0,0 +1,238 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/reverseGeocoder/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { computed, ref, watch, type Reactive, type WatchHandle } from 'vue'
+import { Point } from 'ol/geom'
+import { easeOut } from 'ol/easing'
+import type { Mock } from 'vitest'
+import { reverseGeocode as reverseGeocodeUtil } from './utils/reverseGeocode'
+import {
+ PluginId,
+ type ReverseGeocoderFeature,
+ type ReverseGeocoderPluginOptions,
+} from './types'
+import { useCoreStore } from '@/core/stores/export'
+import { indicateLoading } from '@/lib/indicateLoading'
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for reverse geocoder that converts coordinates into addresses.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useReverseGeocoderStore = defineStore(
+ 'plugins/reverseGeocoder',
+ () => {
+ const coreStore = useCoreStore()
+
+ const configuration = computed(
+ () => 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
+ }
+ watchHandles.value.push(
+ watch(
+ () => store[source.key],
+ async (coordinate) => {
+ if (coordinate) {
+ await reverseGeocode(coordinate)
+ }
+ },
+ { immediate: true }
+ )
+ )
+ }
+ }
+
+ function teardownPlugin() {
+ watchHandles.value.forEach((handle) => {
+ handle()
+ })
+ }
+
+ function passFeatureToTarget(
+ target: NonNullable,
+ feature: ReverseGeocoderFeature
+ ) {
+ const targetStore = target.plugin
+ ? coreStore.getPluginStore(target.plugin)
+ : coreStore
+ if (!targetStore) {
+ return
+ }
+ targetStore[target.key] = feature
+ }
+
+ async function reverseGeocode(coordinate: [number, number]) {
+ const finish = indicateLoading()
+ try {
+ const feature = await reverseGeocodeUtil(
+ configuration.value.url,
+ coordinate
+ )
+ if (configuration.value.addressTarget) {
+ passFeatureToTarget(configuration.value.addressTarget, feature)
+ }
+ if (configuration.value.zoomTo) {
+ coreStore.map.getView().fit(new Point(coordinate), {
+ maxZoom: configuration.value.zoomTo,
+ duration: 400,
+ easing: easeOut,
+ })
+ }
+ return feature
+ } catch (error) {
+ console.error('Reverse geocoding failed:', error)
+ return null
+ } finally {
+ finish()
+ }
+ }
+
+ return {
+ /**
+ * Resolve address for the given coordinate.
+ *
+ * @param coordinate - Coordinate to reverse geocode.
+ * @returns A promise that resolves to the reverse geocoded feature or null.
+ */
+ reverseGeocode,
+
+ /** @internal */
+ setupPlugin,
+
+ /** @internal */
+ teardownPlugin,
+ }
+ }
+)
+
+if (import.meta.vitest) {
+ const { expect, test: _test, vi } = import.meta.vitest
+ const { createPinia, setActivePinia } = await import('pinia')
+ const { reactive } = await import('vue')
+ const useCoreStoreFile = await import('@/core/stores/export')
+ const reverseGeocodeUtilFile = await import('./utils/reverseGeocode')
+ const indicateLoadingFile = await import('@/lib/indicateLoading')
+
+ /* eslint-disable no-empty-pattern */
+ const test = _test.extend<{
+ reverseGeocodeUtil: Mock
+ indicateLoading: Mock
+ coreStore: Reactive>
+ store: ReturnType
+ }>({
+ reverseGeocodeUtil: [
+ async ({}, use) => {
+ const reverseGeocodeUtil = vi
+ .spyOn(reverseGeocodeUtilFile, 'reverseGeocode')
+ .mockResolvedValue(null as unknown as ReverseGeocoderFeature)
+ await use(reverseGeocodeUtil)
+ },
+ { auto: true },
+ ],
+ indicateLoading: [
+ async ({}, use) => {
+ const indicateLoading = vi
+ .spyOn(indicateLoadingFile, 'indicateLoading')
+ .mockImplementation(() => () => {})
+ await use(indicateLoading)
+ },
+ { auto: true },
+ ],
+ coreStore: [
+ async ({}, use) => {
+ const fit = vi.fn()
+ const coreStore = reactive({
+ configuration: {
+ [PluginId]: {
+ url: 'https://wps.example',
+ coordinateSources: [{ key: 'coordinateSource' }],
+ addressTarget: { key: 'addressTarget' },
+ zoomTo: 99,
+ },
+ },
+ map: {
+ getView: () => ({ fit }),
+ },
+ coordinateSource: null,
+ addressTarget: null,
+ })
+ // @ts-expect-error | Mocking useCoreStore
+ vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore)
+ await use(coreStore)
+ },
+ { auto: true },
+ ],
+ store: [
+ async ({}, use) => {
+ setActivePinia(createPinia())
+ const store = useReverseGeocoderStore()
+ store.setupPlugin()
+ await use(store)
+ store.teardownPlugin()
+ },
+ { auto: true },
+ ],
+ })
+ /* eslint-enable no-empty-pattern */
+
+ test('detects changes in coordinate sources', async ({
+ reverseGeocodeUtil,
+ coreStore,
+ }) => {
+ coreStore.coordinateSource = [1, 2]
+ await new Promise((resolve) => setTimeout(resolve))
+ expect(reverseGeocodeUtil).toHaveBeenCalledWith(
+ 'https://wps.example',
+ [1, 2]
+ )
+ })
+
+ test('passes geocoding result to address target', async ({
+ reverseGeocodeUtil,
+ coreStore,
+ store,
+ }) => {
+ const feature = Symbol('feature')
+ reverseGeocodeUtil.mockResolvedValueOnce(
+ feature as unknown as ReverseGeocoderFeature
+ )
+ await store.reverseGeocode([3, 4])
+ expect(coreStore.addressTarget).toBe(feature)
+ })
+
+ test('zooms to input coordinate', async ({
+ reverseGeocodeUtil,
+ coreStore,
+ store,
+ }) => {
+ const feature = Symbol('feature')
+ reverseGeocodeUtil.mockResolvedValueOnce(
+ feature as unknown as ReverseGeocoderFeature
+ )
+ await store.reverseGeocode([3, 4])
+ // @ts-expect-error | fit is mocked
+ expect(coreStore.map.getView().fit).toHaveBeenCalledOnce()
+ })
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept(
+ acceptHMRUpdate(useReverseGeocoderStore, import.meta.hot)
+ )
+}
diff --git a/src/plugins/reverseGeocoder/types.ts b/src/plugins/reverseGeocoder/types.ts
new file mode 100644
index 000000000..f433f555a
--- /dev/null
+++ b/src/plugins/reverseGeocoder/types.ts
@@ -0,0 +1,40 @@
+import type { Feature } from 'geojson'
+import type { PluginOptions, StoreReference } from '@/core'
+
+/**
+ * Plugin identifier.
+ */
+export const PluginId = 'reverseGeocoder'
+
+/**
+ * Plugin options for reverse geocoder plugin.
+ */
+export interface ReverseGeocoderPluginOptions extends PluginOptions {
+ /**
+ * URL of a WPS service to use for reverse geocoding.
+ */
+ url: string
+
+ /**
+ * Store state parameter that should receive the result of the reverse geocoding.
+ */
+ addressTarget?: StoreReference
+
+ /**
+ * Array of store fields that contain a coordinate.
+ * If a coordinate is refreshed, reverse geocoding for that coordinate is done automatically.
+ */
+ coordinateSources?: StoreReference[]
+
+ /**
+ * Zoom level to zoom to when a successful answer was received.
+ */
+ zoomTo?: number
+}
+
+// a little clunky, but this has been established
+export type ReverseGeocoderFeature = Omit & {
+ type: 'reverse_geocoded'
+ title: string
+ addressGeometry: Feature['geometry']
+}
diff --git a/src/plugins/reverseGeocoder/utils/reverseGeocode.ts b/src/plugins/reverseGeocoder/utils/reverseGeocode.ts
new file mode 100644
index 000000000..9a1e5c079
--- /dev/null
+++ b/src/plugins/reverseGeocoder/utils/reverseGeocode.ts
@@ -0,0 +1,170 @@
+import { Parser, processors } from 'xml2js'
+import type { ReverseGeocoderFeature } from '../types'
+
+const buildPostBody = ([x, y]: [number, number]) => `
+ ReverseGeocoder.fmw
+
+
+ X
+
+ ${x}
+
+
+
+ Y
+
+ ${y}
+
+
+
+ `
+
+const parser = new Parser({
+ tagNameProcessors: [processors.stripPrefix],
+})
+
+export async function reverseGeocode(
+ url: string,
+ coordinate: [number, number]
+): Promise {
+ const response = await fetch(url, {
+ method: 'POST',
+ body: buildPostBody(coordinate),
+ })
+
+ const parsedBody = await parser.parseStringPromise(await response.text())
+
+ const address = Object.fromEntries(
+ Object.entries(
+ parsedBody.ExecuteResponse.ProcessOutputs[0].Output[0].Data[0]
+ .ComplexData[0].ReverseGeocoder[0].Ergebnis[0].Adresse[0]
+ // @ts-expect-error | This was any anyway
+ ).map(([k, v]) => [k, v[0]])
+ )
+
+ // NOTE: Property names come from the WPS
+ /* eslint-disable @typescript-eslint/naming-convention */
+ const properties = {
+ Distanz: parseFloat(address.Distanz),
+ Hausnr: parseInt(address.Hausnr, 10),
+ Plz: parseInt(address.Plz, 10),
+ Strasse: address.Strasse,
+ XKoordinate: parseFloat(address.XKoordinate),
+ YKoordinate: parseFloat(address.YKoordinate),
+ Zusatz: address.Zusatz,
+ }
+ /* eslint-enable @typescript-eslint/naming-convention */
+
+ const resultObject: ReverseGeocoderFeature = {
+ type: 'reverse_geocoded',
+ title: `${properties.Strasse} ${properties.Hausnr}${properties.Zusatz}`,
+ properties,
+ geometry: {
+ // as clicked by user - usually want to keep this since user is pointing at something
+ coordinates: coordinate,
+ type: 'Point',
+ },
+ addressGeometry: {
+ // as returned by reverse geocoder
+ coordinates: [properties.XKoordinate, properties.YKoordinate],
+ type: 'Point',
+ },
+ }
+ return resultObject
+}
+
+if (import.meta.vitest) {
+ const { expect, test, vi } = import.meta.vitest
+
+ const testUrl = 'https://wps.example'
+
+ const testCoordinates: [number, number] = [
+ 565192.2974622496, 5933428.820743558,
+ ]
+
+ const testResponse = `
+
+
+ ReverseGeocoder.fmw
+ ReverseGeocoder
+ <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">prio: normal</p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">kritisch: nein</p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Ansprechpartner: webdienste@gv.hamburg.de</p> <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"> <br/> </p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Beschreibung: startet mit einem Punkt und findet dazu die nächst gelegene Adresse und ermittelt die Zuständigkeit</p> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">das Ergebnis wird zurückgegeben</p>
+
+
+ Process execution finished@2023-10-13T07:54:26.579Z
+
+
+
+ FMEResponse
+ Response from FME (Job Submitter Service)
+
+
+
+
+ ${testCoordinates[0]}
+ ${testCoordinates[1]}
+ 25832
+
+
+
+ Herrlichkeit
+ 1
+
+ 20459
+ 16.20141565450446
+ 565200.347
+ 5933442.881
+
+
+
+
+
+
+
+ `
+
+ test('reverseGeocode works with Hamburg-WPS-style', async () => {
+ const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce({
+ text: () => Promise.resolve(testResponse),
+ } as Response)
+
+ const feature = await reverseGeocode(testUrl, testCoordinates)
+
+ expect(fetchMock).toHaveBeenCalledOnce()
+ expect(fetchMock).toHaveBeenCalledWith(testUrl, {
+ method: 'POST',
+ body: buildPostBody(testCoordinates),
+ })
+
+ expect(feature).toEqual({
+ type: 'reverse_geocoded',
+ title: 'Herrlichkeit 1',
+ addressGeometry: {
+ coordinates: [565200.347, 5933442.881],
+ type: 'Point',
+ },
+ geometry: {
+ coordinates: testCoordinates,
+ type: 'Point',
+ },
+ properties: {
+ /* eslint-disable @typescript-eslint/naming-convention */
+ Distanz: 16.20141565450446,
+ Hausnr: 1,
+ Plz: 20459,
+ Strasse: 'Herrlichkeit',
+ XKoordinate: 565200.347,
+ YKoordinate: 5933442.881,
+ Zusatz: '',
+ /* eslint-enable @typescript-eslint/naming-convention */
+ },
+ })
+ })
+}
diff --git a/src/plugins/toast/components/ToastContainer.ce.vue b/src/plugins/toast/components/ToastContainer.ce.vue
new file mode 100644
index 000000000..95680d1f7
--- /dev/null
+++ b/src/plugins/toast/components/ToastContainer.ce.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/toast/components/ToastUI.ce.vue b/src/plugins/toast/components/ToastUI.ce.vue
new file mode 100644
index 000000000..8bfdd12df
--- /dev/null
+++ b/src/plugins/toast/components/ToastUI.ce.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/plugins/toast/components/ToastUI.spec.ts b/src/plugins/toast/components/ToastUI.spec.ts
new file mode 100644
index 000000000..0da67f6c1
--- /dev/null
+++ b/src/plugins/toast/components/ToastUI.spec.ts
@@ -0,0 +1,58 @@
+import { expect, test as _test, vi } from 'vitest'
+import { mount, VueWrapper } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { nextTick } from 'vue'
+import { useToastStore } from '../store'
+import ToastUI from './ToastUI.ce.vue'
+import { mockedT } from '@/test/utils/mockI18n'
+
+/* eslint-disable no-empty-pattern */
+const test = _test.extend<{
+ wrapper: VueWrapper
+ store: ReturnType
+}>({
+ wrapper: async ({}, use) => {
+ const wrapper = mount(ToastUI, {
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn })],
+ mocks: {
+ $t: mockedT,
+ },
+ },
+ })
+ await use(wrapper)
+ },
+ store: async ({}, use) => {
+ const store = useToastStore()
+ await use(store)
+ },
+})
+/* eslint-enable no-empty-pattern */
+
+test('Component shows multiple toasts', async ({ wrapper, store }) => {
+ // @ts-expect-error | toasts are readonly
+ store.toasts = [
+ { text: 'ALPHA', severity: 'info' },
+ { text: 'BETA', severity: 'error' },
+ ]
+ await nextTick()
+
+ expect(
+ wrapper.find('.kern-alert:nth-of-type(1) .kern-title').text()
+ ).toContain('ALPHA')
+ expect(
+ wrapper.find('.kern-alert:nth-of-type(2) .kern-title').text()
+ ).toContain('BETA')
+})
+
+test('Component removes toast on dismiss click', async ({ wrapper, store }) => {
+ // @ts-expect-error | toasts are readonly
+ store.toasts = [
+ { text: 'ALPHA', severity: 'info' },
+ { text: 'BETA', severity: 'error' },
+ ]
+ await nextTick()
+
+ await wrapper.find('.kern-alert:nth-of-type(2) button').trigger('click')
+ expect(store.removeToast).toHaveBeenCalledExactlyOnceWith(store.toasts[1])
+})
diff --git a/src/plugins/toast/index.ts b/src/plugins/toast/index.ts
new file mode 100644
index 000000000..dbae9d0be
--- /dev/null
+++ b/src/plugins/toast/index.ts
@@ -0,0 +1,34 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/toast
+ */
+/* eslint-enable tsdoc/syntax */
+
+import component from './components/ToastContainer.ce.vue'
+import locales from './locales'
+import { useToastStore } from './store'
+import { PluginId, type ToastPluginOptions } from './types'
+import type { PluginContainer, PolarPluginStore } from '@/core'
+
+/**
+ * Creates a plugin which provides toast messages.
+ *
+ * The plugin offers global functionality to display text messages to the user.
+ * These are the classic success, warning, info, and error messages,
+ * helping to understand what's going on or why something happened.
+ *
+ * @returns Plugin for use with {@link addPlugin}
+ */
+export default function pluginToast(
+ options: ToastPluginOptions
+): PluginContainer {
+ return {
+ id: PluginId,
+ component,
+ locales,
+ storeModule: useToastStore as PolarPluginStore,
+ options,
+ }
+}
+
+export * from './types'
diff --git a/src/plugins/toast/locales.ts b/src/plugins/toast/locales.ts
new file mode 100644
index 000000000..8a0c0acfe
--- /dev/null
+++ b/src/plugins/toast/locales.ts
@@ -0,0 +1,51 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * This is the documentation for the locales keys in the toast plugin.
+ * These locales are *NOT* exported, but documented only.
+ *
+ * @module locales/plugins/toast
+ */
+/* eslint-enable tsdoc/syntax */
+
+import type { Locale } from '@/core'
+
+/**
+ * German locales for toast plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesDe = {
+ dismissButton: {
+ label: 'Benachrichtigung ausblenden',
+ },
+} as const
+
+/**
+ * English locales for toast plugin.
+ * For overwriting these values, use the plugin's ID as namespace.
+ */
+export const resourcesEn = {
+ dismissButton: {
+ label: 'Hide notification',
+ },
+} as const
+
+/**
+ * Toast plugin 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/toast/store.ts b/src/plugins/toast/store.ts
new file mode 100644
index 000000000..f92b06d2c
--- /dev/null
+++ b/src/plugins/toast/store.ts
@@ -0,0 +1,216 @@
+/* eslint-disable tsdoc/syntax */
+/**
+ * @module \@polar/polar/plugins/toast/store
+ */
+/* eslint-enable tsdoc/syntax */
+
+import { acceptHMRUpdate, defineStore } from 'pinia'
+import { computed, ref, toRaw, type Reactive } from 'vue'
+import { toMerged } from 'es-toolkit'
+import {
+ PluginId,
+ type Toast,
+ type ToastOptions,
+ type ToastPluginOptions,
+ type ToastSeverity,
+ type ToastTheme,
+} from './types'
+import { useCoreStore } from '@/core/stores/export'
+
+interface ToastItem {
+ toast: Toast
+ timeout?: ReturnType
+}
+
+/* eslint-disable tsdoc/syntax */
+/**
+ * @function
+ *
+ * Plugin store for showing messages to the user.
+ */
+/* eslint-enable tsdoc/syntax */
+export const useToastStore = defineStore('plugins/toast', () => {
+ const coreStore = useCoreStore()
+
+ const configuration = computed(
+ () => coreStore.configuration[PluginId] as ToastPluginOptions
+ )
+
+ const toasts = ref([])
+
+ function addToast(toast: Toast, options?: ToastOptions) {
+ const optionsWithDefaults = toMerged(
+ {
+ timeout: toast.severity === 'error' ? null : 5000,
+ },
+ options || {}
+ )
+ toast.theme = toMerged(
+ configuration.value[toast.severity] || {},
+ toast.theme || {}
+ )
+
+ const toastItem: ToastItem = { toast }
+ toasts.value.push(toastItem as (typeof toasts.value)[number])
+
+ if (typeof optionsWithDefaults.timeout === 'number') {
+ toastItem.timeout = setTimeout(
+ () => removeToast(toast),
+ optionsWithDefaults.timeout
+ )
+ }
+ }
+
+ function removeToast(toast: Toast): boolean {
+ const index = toasts.value.findIndex(
+ (item) => toRaw(item.toast) === toRaw(toast)
+ )
+ if (index < 0) {
+ return false
+ }
+ const [toastItem] = toasts.value.splice(index, 1)
+ if (toastItem?.timeout) {
+ clearTimeout(toastItem.timeout)
+ }
+ return true
+ }
+
+ return {
+ /**
+ * List of all toasts that are visible.
+ *
+ * @alpha
+ */
+ toasts: computed(() => toasts.value.map(({ toast }) => toast)),
+
+ /**
+ * Shows a toast.
+ *
+ * If no timeout is given, the toast disappears after five seconds.
+ * Error toasts have no timeout by default.
+ * To disable the timeout, pass `null` explicitly.
+ */
+ addToast,
+
+ /**
+ * Removes a toast.
+ *
+ * The exact object reference to the toast object passed to `addToast` is needed.
+ * A deep equal object will not work.
+ *
+ * If the toast was already removed, this method does nothing.
+ * If the toast has a connected timeout, it is canceled.
+ *
+ * @returns `true` if the toast could be found and removed, `false` otherwise
+ */
+ removeToast,
+ }
+})
+
+if (import.meta.vitest) {
+ const { expect, test: _test, vi } = import.meta.vitest
+ const { createPinia, setActivePinia } = await import('pinia')
+ const { reactive } = await import('vue')
+ const useCoreStoreFile = await import('@/core/stores/export')
+
+ /* eslint-disable no-empty-pattern */
+ const test = _test.extend<{
+ coreStore: Reactive>
+ store: ReturnType
+ timer: null
+ }>({
+ coreStore: [
+ async ({}, use) => {
+ const coreStore = reactive({
+ configuration: { [PluginId]: {} },
+ })
+ // @ts-expect-error | Mocking useCoreStore
+ vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore)
+ await use(coreStore)
+ },
+ { auto: true },
+ ],
+ store: async ({}, use) => {
+ setActivePinia(createPinia())
+ const store = useToastStore()
+ await use(store)
+ },
+ timer: [
+ async ({}, use) => {
+ vi.useFakeTimers()
+ await use(null)
+ vi.resetAllMocks()
+ },
+ { auto: true },
+ ],
+ })
+ /* eslint-enable no-empty-pattern */
+
+ test('Toast can be added and safely removed', ({ store }) => {
+ const toast: Toast = {
+ text: 'TOAST',
+ severity: 'error',
+ }
+ store.addToast(toast)
+ expect(store.toasts.length).toBe(1)
+ expect(toRaw(store.toasts[0])).toEqual(toast)
+
+ expect(store.removeToast(toast)).toBe(true)
+ expect(store.toasts.length).toBe(0)
+ expect(store.removeToast(toast)).toBe(false)
+ })
+
+ test.for([
+ { severity: 'error', timeout: null },
+ { severity: 'warning', timeout: 5 },
+ { severity: 'info', timeout: 5 },
+ { severity: 'success', timeout: 5 },
+ ])(
+ 'Toast with severity $severity is automatically removed after $timeout seconds (null = never)',
+ ({ severity, timeout }, { store }) => {
+ store.addToast({
+ text: 'TOAST',
+ severity: severity as ToastSeverity,
+ })
+ if (timeout) {
+ vi.advanceTimersByTime(timeout * 1000 - 1)
+ expect(store.toasts.length).toBe(1)
+ vi.advanceTimersByTime(1)
+ expect(store.toasts.length).toBe(0)
+ } else {
+ vi.runAllTimers()
+ expect(store.toasts.length).toBe(1)
+ }
+ }
+ )
+
+ test.for([
+ {
+ config: { color: 'SC', icon: 'SI' },
+ options: { icon: 'OI' },
+ result: { color: 'SC', icon: 'OI' },
+ },
+ {
+ config: { icon: 'SI' },
+ options: {},
+ result: { icon: 'SI' },
+ },
+ ])(
+ 'Toast consideres theme settings in the right precedence',
+ ({ config, options, result }, { coreStore, store }) => {
+ // @ts-expect-error | This is a test
+ coreStore.configuration[PluginId].info = config
+ store.addToast({
+ text: 'TEXT',
+ severity: 'info',
+ theme: options as ToastTheme,
+ })
+ expect(store.toasts.length).toBe(1)
+ expect(store.toasts[0]?.theme).toEqual(result)
+ }
+ )
+}
+
+if (import.meta.hot) {
+ import.meta.hot.accept(acceptHMRUpdate(useToastStore, import.meta.hot))
+}
diff --git a/src/plugins/toast/types.ts b/src/plugins/toast/types.ts
new file mode 100644
index 000000000..7df62f0f9
--- /dev/null
+++ b/src/plugins/toast/types.ts
@@ -0,0 +1,46 @@
+import type { Ref } from 'vue'
+import type { Color, Icon, PluginOptions } from '@/core'
+
+/**
+ * Plugin identifier.
+ */
+export const PluginId = 'toast'
+
+/**
+ * Toast severity.
+ */
+export type ToastSeverity = 'error' | 'warning' | 'info' | 'success'
+
+/**
+ * Customized toast theme.
+ */
+export interface ToastTheme {
+ color?: Color
+ icon?: Icon
+}
+
+/**
+ * Toast.
+ */
+export interface Toast {
+ severity: ToastSeverity
+ text: string | Ref
+ theme?: ToastTheme
+}
+
+/**
+ * Options for adding a toast.
+ */
+export interface ToastOptions {
+ timeout?: number | null
+}
+
+/**
+ * Plugin options for toast plugin.
+ */
+export interface ToastPluginOptions extends PluginOptions {
+ error?: ToastTheme
+ info?: ToastTheme
+ success?: ToastTheme
+ warning?: ToastTheme
+}
diff --git a/src/test/utils/mockI18n.ts b/src/test/utils/mockI18n.ts
new file mode 100644
index 000000000..5b6f5150b
--- /dev/null
+++ b/src/test/utils/mockI18n.ts
@@ -0,0 +1,25 @@
+import type { ResourceKey } from 'i18next'
+
+type MockedSelectorFn = ($: Record) => string
+
+export function mockedT(
+ keyFn: MockedSelectorFn,
+ options: {
+ ns: string
+ context?: string
+ }
+) {
+ const target = {
+ keys: [] as string[],
+ }
+ const proxy = new Proxy(target, {
+ get(target, prop) {
+ if (prop === Symbol.toPrimitive) {
+ return () => target.keys.join('.')
+ }
+ target.keys.push(prop.toString())
+ return proxy
+ },
+ })
+ return `$t(${options.ns}:${keyFn(proxy)}${options.context ? `_${options.context}` : ''})`
+}
diff --git a/src/tsconfig.json b/src/tsconfig.json
new file mode 100644
index 000000000..e67a93c0c
--- /dev/null
+++ b/src/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "extends": [
+ "@vue/tsconfig/tsconfig.dom.json",
+ "@vue/tsconfig/tsconfig.lib.json",
+ "../tsconfig.settings.json"
+ ],
+ "compilerOptions": {
+ "types": [
+ "vitest/importMeta",
+ "vitest/jsdom",
+ "node",
+ "vite-plugin-kern-extra-icons/client"
+ ],
+ "lib": [
+ "ESNext",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 480672cc9..b395138bf 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,20 +1,19 @@
{
- "compilerOptions": {
- "target": "es6",
- "module": "esnext",
- "strict": true,
- "noImplicitAny": false,
- "jsx": "preserve",
- "importHelpers": true,
- "moduleResolution": "node",
- "skipLibCheck": true,
- "skipDefaultLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "experimentalDecorators": true,
- "sourceMap": true,
- "isolatedModules": true,
- "lib": ["esnext", "dom", "dom.iterable", "scripthost"],
- "typeRoots": ["node_modules/@types", "@types"]
- }
+ "extends": [
+ "./tsconfig.settings.json"
+ ],
+ "exclude": [
+ "src",
+ "examples",
+ "vue2"
+ ],
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "skipLibCheck": true,
+ "types": [
+ "node"
+ ]
+ }
}
diff --git a/tsconfig.settings.json b/tsconfig.settings.json
new file mode 100644
index 000000000..c250144c0
--- /dev/null
+++ b/tsconfig.settings.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "noImplicitAny": false,
+ "importHelpers": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "experimentalDecorators": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "noEmit": true,
+ "strict": true,
+ "isolatedModules": true
+ }
+}
diff --git a/typedoc.json b/typedoc.json
new file mode 100644
index 000000000..dadc684a4
--- /dev/null
+++ b/typedoc.json
@@ -0,0 +1,28 @@
+{
+ "tsconfig": "src/tsconfig.json",
+ "entryPoints": [
+ "src/core/index.ts",
+ "src/core/locales.ts",
+ "src/core/stores/export.ts",
+ "src/plugins/*/index.ts",
+ "src/plugins/*/locales.ts",
+ "src/plugins/*/store.ts"
+ ],
+ "out": "docs-html/reference",
+ "skipErrorChecking": true,
+ "projectDocuments": [],
+ "name": "POLAR reference",
+ "sort": [
+ "kind",
+ "instance-first",
+ "required-first",
+ "alphabetical-ignoring-documents"
+ ],
+ "navigation": {
+ "includeFolders": false
+ },
+ "plugin": [
+ "typedoc-plugin-vue",
+ "./typedocPlugins/targetAudience.ts"
+ ]
+}
diff --git a/typedocPlugins/targetAudience.ts b/typedocPlugins/targetAudience.ts
new file mode 100644
index 000000000..d745ea635
--- /dev/null
+++ b/typedocPlugins/targetAudience.ts
@@ -0,0 +1,35 @@
+import * as td from 'typedoc'
+
+const targetAudiences = {
+ core: [],
+ plugin: ['internal'],
+ client: ['internal', 'alpha'],
+} as Record
+
+export function load(app: td.Application) {
+ app.options.addDeclaration({
+ type: td.ParameterType.String,
+ name: 'targetAudience',
+ help: 'Target audience for the generated documentation.',
+ defaultValue: 'client',
+ })
+
+ app.converter.on(td.Converter.EVENT_RESOLVE_BEGIN, (context) => {
+ const targetAudience = app.options.getValue('targetAudience') as string
+ if (!Object.keys(targetAudiences).includes(targetAudience)) {
+ app.logger.error('Invalid target audience: ' + targetAudience)
+ return
+ }
+ const hiddenModifiers = targetAudiences[targetAudience]
+
+ const project = context.project
+ const reflections = Object.values(project.reflections)
+ reflections
+ .filter(({ comment }) =>
+ hiddenModifiers.some((modifier) => comment?.hasModifier(`@${modifier}`))
+ )
+ .forEach((reflection) => {
+ project.removeReflection(reflection)
+ })
+ })
+}
diff --git a/vite.config.preview.ts b/vite.config.preview.ts
new file mode 100644
index 000000000..56ee83ff3
--- /dev/null
+++ b/vite.config.preview.ts
@@ -0,0 +1,43 @@
+import { resolve } from 'node:path'
+
+import { defineConfig } from 'vite'
+
+import commonJs from 'vite-plugin-commonjs'
+import vue from '@vitejs/plugin-vue'
+import kernExtraIcons from 'vite-plugin-kern-extra-icons'
+import enrichedConsole from './vitePlugins/enrichedConsole.js'
+
+export default defineConfig({
+ plugins: [
+ // @ts-expect-error | commonJs dts is broken
+ commonJs(),
+ vue({
+ template: {
+ compilerOptions: {
+ isCustomElement: (tag) => tag.includes('-'),
+ },
+ },
+ }),
+ kernExtraIcons({
+ cssLayer: 'kern-ux-icons',
+ ignoreFilename: (filename) => !filename.includes('/examples/iceberg/'),
+ }),
+ enrichedConsole(),
+ ],
+ build: {
+ outDir: '.dist.preview',
+ rollupOptions: {
+ input: {
+ main: resolve(__dirname, 'index.html'),
+ snowbox: resolve(__dirname, 'examples', 'snowbox', 'index.html'),
+ iceberg: resolve(__dirname, 'examples', 'iceberg', 'index.html'),
+ },
+ },
+ },
+ preview: {
+ port: 1235,
+ },
+ optimizeDeps: {
+ entries: ['snowbox', 'iceberg'],
+ },
+})
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 000000000..f2b60474e
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,125 @@
+import { createRequire } from 'node:module'
+import { resolve, basename, sep } from 'node:path'
+import { globSync } from 'node:fs'
+
+import { defineConfig } from 'vite'
+
+import commonJs from 'vite-plugin-commonjs'
+import vue from '@vitejs/plugin-vue'
+import vueDevTools from 'vite-plugin-vue-devtools'
+import dts from 'vite-plugin-dts'
+import checker from 'vite-plugin-checker'
+import kernExtraIcons from 'vite-plugin-kern-extra-icons'
+import enrichedConsole from './vitePlugins/enrichedConsole.js'
+
+const require = createRequire(import.meta.url)
+
+export default defineConfig(({ mode }) => ({
+ plugins: [
+ // @ts-expect-error | commonJs dts is broken
+ commonJs(),
+ vue({
+ template: {
+ compilerOptions: {
+ isCustomElement: (tag) => tag.includes('-'),
+ },
+ },
+ }),
+ vueDevTools(),
+ dts({
+ rollupTypes: true,
+ tsconfigPath: './src/tsconfig.json',
+ }),
+ ...(mode === 'development'
+ ? [
+ checker({
+ vueTsc: true,
+ eslint: {
+ lintCommand: 'eslint .',
+ useFlatConfig: true,
+ watchPath: [
+ './src',
+ './snowbox',
+ './scripts',
+ './vite.config.ts',
+ ],
+ },
+ }),
+ ]
+ : []),
+ kernExtraIcons({
+ cssLayer: 'kern-ux-icons',
+ }),
+ enrichedConsole(),
+ ],
+ build: {
+ lib: {
+ name: '@polar/polar',
+ formats: ['es'],
+ entry: {
+ polar: 'src/core/index.ts',
+ store: 'src/core/stores/export.ts',
+ ...Object.fromEntries(
+ globSync('src/plugins/*/').flatMap((path) => [
+ [`plugin-${basename(path)}`, [path, 'index.ts'].join(sep)],
+ [`plugin-${basename(path)}-store`, [path, 'store.ts'].join(sep)],
+ ])
+ ),
+ },
+ },
+ sourcemap: true,
+ target: 'esnext',
+ },
+ server: {
+ port: 1234,
+ },
+ optimizeDeps: {
+ entries: ['!vue2'],
+ exclude: ['geojson'],
+ },
+ resolve: {
+ alias: {
+ /* eslint-disable @typescript-eslint/naming-convention */
+ ...(mode === 'development'
+ ? {
+ // The order matters! Most specific paths need to be on the top.
+ ...Object.fromEntries(
+ globSync('src/plugins/*/').flatMap((path) => [
+ [
+ `@polar/polar/plugins/${basename(path)}/store`,
+ resolve(path, 'store.ts'),
+ ],
+ [
+ `@polar/polar/plugins/${basename(path)}`,
+ resolve(path, 'index.ts'),
+ ],
+ ])
+ ),
+ '@polar/polar/store': resolve(
+ __dirname,
+ 'src',
+ 'core',
+ 'stores',
+ 'export.ts'
+ ),
+ '@polar/polar/polar.css': resolve(
+ __dirname,
+ 'src',
+ 'core',
+ '.polar-dev.css'
+ ),
+ '@polar/polar': resolve(__dirname, 'src', 'core', 'index.ts'),
+ }
+ : {}),
+ '@': resolve(__dirname, 'src'),
+ stream: require.resolve('stream-browserify'),
+ timers: require.resolve('timers-browserify'),
+ /* eslint-enable @typescript-eslint/naming-convention */
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.spec.ts'],
+ includeSource: ['src/**/*.ts'],
+ },
+}))
diff --git a/vitePlugins/enrichedConsole.ts b/vitePlugins/enrichedConsole.ts
new file mode 100644
index 000000000..d25c550c6
--- /dev/null
+++ b/vitePlugins/enrichedConsole.ts
@@ -0,0 +1,62 @@
+import { resolve } from 'node:path'
+import MagicString from 'magic-string'
+
+const fileRegex = /\.(ts|js|vue)$/
+const consoleRegex = /console\.(log|warn|error|info)\(/g
+
+function stripId(id: string): string | null {
+ const root = resolve(__dirname, '..', 'src')
+ if (!id.startsWith(root)) {
+ return null
+ }
+ id = id.slice(root.length + 1)
+ if (id.endsWith('.ts')) {
+ id = id.slice(0, id.length - 3)
+ } else if (id.endsWith('.js')) {
+ id = id.slice(0, id.length - 3)
+ } else if (id.endsWith('.vue')) {
+ id = id.slice(0, id.length - 4)
+ }
+ return id
+}
+
+type ConsoleType = 'log' | 'info' | 'warn' | 'error'
+function generateConsolePrefix(info: {
+ type: ConsoleType
+ id: string
+ line: number
+ col: number
+}): string {
+ return `@polar/polar(${info.id}:${info.line}:${info.col})\n`
+}
+
+export default function enrichedConsole() {
+ return {
+ name: 'enriched-console',
+ enforce: 'pre',
+ transform(code: string, id: string) {
+ const shortId = stripId(id)
+ if (fileRegex.exec(id) && shortId !== null) {
+ const s = new MagicString(code)
+ let match: RegExpExecArray | null
+ while ((match = consoleRegex.exec(code)) !== null) {
+ const linebreaks = [...code.slice(0, match.index).matchAll(/\n/g)]
+ const hint = generateConsolePrefix({
+ type: match[1] as ConsoleType,
+ id: shortId,
+ line: linebreaks.length + 1,
+ col: match.index - linebreaks[linebreaks.length - 1].index,
+ })
+ const hintJs = `${JSON.stringify(hint)}, `
+ const index = match.index + match[0].length
+ s.appendLeft(index, hintJs)
+ }
+ return {
+ code: s.toString(),
+ map: s.generateMap(),
+ }
+ }
+ return { code, map: null }
+ },
+ }
+}
diff --git a/.eslintrc.json b/vue2/.eslintrc.json
similarity index 100%
rename from .eslintrc.json
rename to vue2/.eslintrc.json
diff --git a/vue2/.gitignore b/vue2/.gitignore
new file mode 100644
index 000000000..a621e3f33
--- /dev/null
+++ b/vue2/.gitignore
@@ -0,0 +1,19 @@
+**/.eslintcache
+**/*.tgz
+**/.cache
+**/coverage
+**/dist
+**/dist-test
+**/docs
+**/node_modules
+**/tests_output
+**/test-results/
+**/playwright-report/
+**/blob-report/
+**/playwright/.cache/
+/*.log
+/.idea/
+/.nx/
+/public
+logs/*.log
+logs
diff --git a/.npmrc b/vue2/.npmrc
similarity index 100%
rename from .npmrc
rename to vue2/.npmrc
diff --git a/@types/i18next.d.ts b/vue2/@types/i18next.d.ts
similarity index 100%
rename from @types/i18next.d.ts
rename to vue2/@types/i18next.d.ts
diff --git a/@types/vue-shims/index.d.ts b/vue2/@types/vue-shims/index.d.ts
similarity index 100%
rename from @types/vue-shims/index.d.ts
rename to vue2/@types/vue-shims/index.d.ts
diff --git a/@types/vue-shims/json-loader.d.ts b/vue2/@types/vue-shims/json-loader.d.ts
similarity index 100%
rename from @types/vue-shims/json-loader.d.ts
rename to vue2/@types/vue-shims/json-loader.d.ts
diff --git a/@types/vue-shims/png-loader.d.ts b/vue2/@types/vue-shims/png-loader.d.ts
similarity index 100%
rename from @types/vue-shims/png-loader.d.ts
rename to vue2/@types/vue-shims/png-loader.d.ts
diff --git a/@types/vue-shims/shims-tsx.d.ts b/vue2/@types/vue-shims/shims-tsx.d.ts
similarity index 100%
rename from @types/vue-shims/shims-tsx.d.ts
rename to vue2/@types/vue-shims/shims-tsx.d.ts
diff --git a/@types/vue-shims/shims-vue.d.ts b/vue2/@types/vue-shims/shims-vue.d.ts
similarity index 100%
rename from @types/vue-shims/shims-vue.d.ts
rename to vue2/@types/vue-shims/shims-vue.d.ts
diff --git a/@types/vue-shims/tsconfig.json b/vue2/@types/vue-shims/tsconfig.json
similarity index 100%
rename from @types/vue-shims/tsconfig.json
rename to vue2/@types/vue-shims/tsconfig.json
diff --git a/vue2/README.md b/vue2/README.md
new file mode 100644
index 000000000..e7a6278c6
--- /dev/null
+++ b/vue2/README.md
@@ -0,0 +1,132 @@
+
+[](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12)
+[](https://www.npmjs.com/search?q=%40polar)
+
+
+
+**Plugins for OpenLAyeRs** is based on the [masterportalAPI](https://bitbucket.org/geowerkstatt-hamburg/masterportalapi) and [OpenLayers](https://openlayers.org/).
+
+POLAR is ...
+
+* ... a configurable map client package.
+* ... a flexible map client factory.
+* ... an extensible library.
+
+## Quick Start
+
+Usage without NPM is documented [here](#getting-started-for-developers).
+
+### Installation (via NPM)
+
+```bash
+npm i @polar/client-generic
+```
+
+### Embedding POLAR
+#### .js
+```js
+import polar from '@polar/client-generic'
+
+polar.createMap({
+ // a div must have this id
+ containerId: 'polarstern',
+ // any service register – this is Hamburg's
+ services: 'https://geodienste.hamburg.de/services-internet.json',
+ mapConfiguration: {
+ // this initially shows Hamburg's city plan
+ layers: [{
+ id: '453',
+ visibility: true,
+ type: 'background',
+ }]
+ }
+})
+```
+
+#### .html
+```html
+
+```
+
+See our [documentation page](https://dataport.github.io/polar/) for all features and configuration options included in this modulith client, with running examples.
+
+## Example clients
+
+The most common use case for this client is in citizen's application processes regarding public service.
+
+Other clients with more specific code include the [Denkmalkarte Schleswig-Holstein](https://efi2.schleswig-holstein.de/dish/dish_client/index.html), a memorial map, and the [Meldemichel Hamburg](https://static.hamburg.de/kartenclient/prod/), a map to inspect and create reports regarding damages to public infrastructure. The latter is currently being migrated to the version seen in this repository.
+
+A more abstract example is the "Snowbox", which is a test environment for developers with many plugins active:
+
+
+
+
+
+## Backers and users
+
+### States of Germany
+
+