diff --git a/.editorconfig b/.editorconfig index b765a32789..7cac9019d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,15 @@ -[*.{js,ts,vue,json}] +[*.{js,ts,vue,json,html,css}] +indent_style = tab +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yaml] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[vue2/*.{js,ts,vue,json}] indent_style = space indent_size = 2 trim_trailing_whitespace = true diff --git a/.env b/.env new file mode 100644 index 0000000000..e515439aaf --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_DEV_LANDING="" diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index af5fd69a4a..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -**/build -**/.cache -**/coverage -**/dist -**/dist-test -**/docs -**/node_modules -**/tests_output -*.d.ts -/.nx/ diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index e40cc91b3b..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "root": true, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:vue/recommended", - "standard", - "plugin:prettier/recommended" - ], - "parser": "vue-eslint-parser", - "parserOptions": { - "parser": "@typescript-eslint/parser", - "ecmaVersion": 2021, - "sourceType": "module", - "extraFileExtensions": [".vue"] - }, - "plugins": ["import"], - "rules": { - "no-console": ["error", { "allow": ["warn", "error"] }], - "no-debugger": "error", - "object-curly-spacing": "error", - "array-bracket-spacing": "error", - "no-multi-spaces": "error", - "vue/multi-word-component-names": "warn", - "vue/order-in-components": "error", - "import/order": "error", - "curly": "error", - "brace-style": "error", - "no-else-return": "error", - "no-lonely-if": "error", - "require-await": "error", - "no-extra-parens": "error", - "func-style": ["error", "declaration", { "allowArrowFunctions": true }], - "eol-last": "error", - "no-eval": "error", - "no-implied-eval": "error", - "complexity": "warn", - "max-depth": "warn" - }, - "ignorePatterns": ["**/node_modules/**/*", "**/dist/**/*"], - "settings": { - "import/parsers": { - "@typescript-eslint/parser": [".ts", ".tsx"] - }, - "import/resolver": { - "typescript": { - "alwaysTryTypes": true - } - } - }, - "overrides": [ - { - "files": ["**/__tests__/*.{j,t}s?(x)", "**/tests/**/*.spec.{j,t}s?(x)"], - "env": { - "jest": true - }, - "plugins": ["jest"] - }, - { - "files": ["packages/**/*.js", "packages/**/*.vue"], - "env": { - "browser": true - }, - "plugins": ["eslint-plugin-vue"] - }, - { - "files": ["packages/**/*.ts"], - "env": { - "browser": true - }, - "parserOptions": { - "parser": "@typescript-eslint/parser", - "ecmaVersion": 2021, - "sourceType": "module", - "project": "./tsconfig.json" - }, - "plugins": ["eslint-plugin-tsdoc"], - "rules": { - "tsdoc/syntax": "warn", - "no-use-before-define": "off", - "@typescript-eslint/promise-function-async": "off", - "@typescript-eslint/naming-convention": [ - "error", - { - "selector": ["typeLike"], - "format": ["PascalCase"] - }, - { - "selector": ["memberLike"], - "format": ["camelCase", "UPPER_CASE", "snake_case"] - } - ], - "@typescript-eslint/consistent-type-definitions": ["error", "interface"] - } - } - ] -} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a5d78339b8..fe7254e199 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -/packages @warm-coolguy @dopenguin @oeninghe-dataport +* @warm-coolguy @dopenguin @oeninghe-dataport @czirkelbach @raschju diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..274455df80 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + target-branch: next + schedule: + interval: weekly + open-pull-requests-limit: 20 + cooldown: + default-days: 7 + commit-message: + prefix: chore + include: scope + + - package-ecosystem: npm + directory: / + target-branch: next + schedule: + interval: weekly + open-pull-requests-limit: 20 + cooldown: + default-days: 3 + exclude: + - "@dataport/eslint-config-geodev" + - vite-plugin-kern-extra-icons + commit-message: + prefix: chore + include: scope + versioning-strategy: increase diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 0000000000..89181f96f8 --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,61 @@ +name: Deploy PR previews + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +permissions: + contents: write + pull-requests: write + +concurrency: preview-${{ github.ref }} + +jobs: + deploy-preview: + name: Create deployment preview + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + if: github.event.action != 'closed' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + cache: 'npm' + + - name: Install documentation build dependencies + if: github.event.action != 'closed' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends mkdocs mkdocs-material + + - name: Install NPM dependencies + if: github.event.action != 'closed' + run: npm ci + + - name: Build preview + if: github.event.action != 'closed' + run: | + npm run build:ci + npm run preview:build:ci -- --base="./" + + - name: Build documentation + if: github.event.action != 'closed' + run: | + npm run docs:ci + mv docs-html ./.dist.preview/docs-html + + - name: Deploy preview to GitHub Pages + uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 + with: + source-dir: ./.dist.preview/ + preview-branch: gh-pages + qr-code: true + wait-for-pages-deployment: false diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 18d7467924..de42858f39 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -1,45 +1,40 @@ -name: Publish packages with updated changelog to npmjs.org +name: Publish @polar/polar to NPM registry on: push: - branches: - - main + tags: + - v* + +permissions: + id-token: write + contents: write jobs: publish: + environment: npm-publish + name: Publish package @polar/polar runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.matrix.outputs.TAGS }} steps: - - uses: actions/checkout@v4 - with: - ssh-key: ${{ secrets.MAIN_BOT_KEY_PRIVATE }} - - uses: actions/setup-node@v4 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20.16.0 + node-version: 24.15.0 registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: | - RETURN_TAGS=$(node ./scripts/versionPackages) - echo "TAGS=$RETURN_TAGS" >> $GITHUB_ENV - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - uses: EndBug/add-and-commit@v9 - with: - author_name: Dataport Geo Bot - author_email: polar@dataport.de - - run: | - for TAG in ${{ env.TAGS }} - do - git config user.name "Dataport Geo Bot" - git config user.email "polar@dataport.de" - git tag $TAG - git push origin $TAG - echo "Released and tagged $TAG" - done - - run: node ./scripts/publishPackages ${{ env.TAGS }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - run: node ./scripts/createRelease ${{ env.TAGS }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build package + run: npm run build:ci + + - name: Publish to NPM + run: npm publish --access public --tag latest + + - name: Create GitHub release + run: node scripts/create-github-release.ts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-pages-next.yml b/.github/workflows/publish-pages-next.yml new file mode 100644 index 0000000000..180022d653 --- /dev/null +++ b/.github/workflows/publish-pages-next.yml @@ -0,0 +1,45 @@ +name: Publish bleeding-edge documentation to gh-pages/next + +on: + push: + branches: + - next + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + deploy: + name: Deploy documentation + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + cache: 'npm' + + - name: Install documentation build dependencies + run: | + sudo apt update + sudo apt install mkdocs mkdocs-material + + - name: Install NPM dependencies + run: npm ci + + - name: Build documentation + run: npm run docs:ci + + - name: Deploy documentation to gh-pages/next + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 + with: + folder: docs-html + target-folder: next diff --git a/.github/workflows/publish-pages.yml b/.github/workflows/publish-pages.yml deleted file mode 100644 index b05866e805..0000000000 --- a/.github/workflows/publish-pages.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Publish pages folder to gh-pages - -on: - push: - branches: - - main - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.16.0 - registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: npm run pages:build - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./pages diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 0000000000..ce4711c394 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,154 @@ +name: Run automated tests +on: push + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + setup: + name: Install dependencies + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + cache: 'npm' + + - name: Cache node_modules + id: node-modules-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24.15.0-${{ hashFiles('package-lock.json', 'patches/**') }} + + - name: Install NPM dependencies + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit --no-fund + + lint: + name: Linting + needs: setup + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24.15.0-${{ hashFiles('package-lock.json', 'patches/**') }} + + - name: Install NPM dependencies (cache miss fallback) + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Restore ESLint cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .eslintcache + key: eslint-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('eslint.config.ts') }}-${{ github.sha }} + restore-keys: | + eslint-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('eslint.config.ts') }}- + eslint-${{ runner.os }}-${{ github.ref }}- + eslint-${{ runner.os }}- + + - name: Run linter + run: npm run lint + + lintcss: + name: CSS linting + needs: setup + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24.15.0-${{ hashFiles('package-lock.json', 'patches/**') }} + + - name: Install NPM dependencies (cache miss fallback) + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run CSS linter + run: npm run lint:css + + test: + name: Unit tests and e2e tests + needs: setup + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24.15.0-${{ hashFiles('package-lock.json', 'patches/**') }} + + - name: Install NPM dependencies (cache miss fallback) + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run tests + run: npm run test:ci + + type-check: + name: Type-checking + needs: setup + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.15.0 + registry-url: https://registry.npmjs.org/ + + - name: Restore node_modules + id: node-modules-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: node_modules + key: node-modules-${{ runner.os }}-node24.15.0-${{ hashFiles('package-lock.json', 'patches/**') }} + + - name: Install NPM dependencies (cache miss fallback) + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Run type-checking + run: npm run tsc:ci diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 5b82f7bd77..0000000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Run linter, tests and type check - -on: push - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.16.0 - registry-url: https://registry.npmjs.org/ - - run: | - npm ci - npm run lint:ci - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v3 - with: - node-version: 20.16.0 - registry-url: https://registry.npmjs.org/ - - run: | - npm ci - npm run test - teste2e: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.16.0 - - name: Install dependencies - run: npm ci - - name: Build snowbox (started by playwright by itself) - run: npm run snowbox:build - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests (common) - run: npm run test:e2e - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 - type-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v3 - with: - node-version: 20.16.0 - registry-url: https://registry.npmjs.org/ - - run: | - npm ci - npm run tsc:ci diff --git a/.gitignore b/.gitignore index a621e3f338..65c2194693 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ +node_modules +.eslintcache + +git-hooks.config.json + +/docs-html +.dist.preview +.vscode/settings.json + +# Old gitignore, maybe cleanup when migrated **/.eslintcache **/*.tgz **/.cache **/coverage **/dist **/dist-test -**/docs **/node_modules **/tests_output **/test-results/ @@ -17,3 +26,6 @@ /public logs/*.log logs +*.patch +!/patches/**/*.patch +.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000000..4e831e3bbc --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,20 @@ +#!/bin/sh +set -e +eval `node .husky/prepareConfig.js` +if [ "$POLAR_SKIP_COMMIT_MSG" = "yes" ] +then + exit 0 +fi + +if [ "$POLAR_LINT_COMMIT_MESSAGE" = "yes" ] +then + if ! npx --no -- commitlint --edit $1 + then + echo "Cannot commit: Invalid commit message." >&2 + echo "Please edit your commit message appropriately." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_LINT_COMMIT_MESSAGE=no" >&2 + echo "If you want to skip all commit-msg checks, set POLAR_SKIP_COMMIT_MSG=yes" >&2 + exit 1 + fi +fi diff --git a/.husky/defaults.json b/.husky/defaults.json new file mode 100644 index 0000000000..f6cd8c5977 --- /dev/null +++ b/.husky/defaults.json @@ -0,0 +1,11 @@ +{ + "skipPreCommit": false, + "allowDirtyCommit": true, + "lintOnCommit": false, + "lintCssOnCommit": false, + "typecheckOnCommit": false, + "testOnCommit": false, + + "skipCommitMsg": false, + "lintCommitMessage": true +} \ No newline at end of file diff --git a/.husky/install.js b/.husky/install.js new file mode 100644 index 0000000000..5d9017a822 --- /dev/null +++ b/.husky/install.js @@ -0,0 +1,3 @@ +/* eslint-disable no-console */ +const { default: husky } = await import('husky') +console.info(husky()) diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..8f0af0b549 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,75 @@ +#!/bin/sh +set -e +eval `node .husky/prepareConfig.js` +if [ "$POLAR_SKIP_PRE_COMMIT" = "yes" ] +then + exit 0 +fi + +if [ "$POLAR_ALLOW_DIRTY_COMMIT" = "no" ] +then + git rev-parse --verify HEAD >/dev/null || exit 1 + git update-index -q --ignore-submodules --refresh + + if ! git diff-files --quiet --ignore-submodules + then + echo "Cannot commit: You have unstaged changes." >&2 + echo "Please stage, stash or drop these changes." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_ALLOW_DIRTY_COMMIT=yes" >&2 + echo "If you want to skip all pre-commit checks, set POLAR_SKIP_PRE_COMMIT=yes" >&2 + exit 1 + fi +fi + +if [ "$POLAR_LINT_ON_COMMIT" = "yes" ] +then + if ! npm run lint + then + echo "Cannot commit: Linting failed." >&2 + echo "Please fix the above error(s)." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_LINT_ON_COMMIT=no" >&2 + echo "If you want to skip all pre-commit checks, set POLAR_SKIP_PRE_COMMIT=yes" >&2 + exit 1 + fi +fi + +if [ "$POLAR_LINT_CSS_ON_COMMIT" = "yes" ] +then + if ! npm run lint:css + then + echo "Cannot commit: CSS linting failed." >&2 + echo "Please fix the above error(s)." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_LINT_CSS_ON_COMMIT=no" >&2 + echo "If you want to skip all pre-commit checks, set POLAR_SKIP_PRE_COMMIT=yes" >&2 + exit 1 + fi +fi + +if [ "$POLAR_TYPECHECK_ON_COMMIT" = "yes" ] +then + if ! npm run tsc + then + echo "Cannot commit: Type checking failed." >&2 + echo "Please fix the above error(s)." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_TYPECHECK_ON_COMMIT=no" >&2 + echo "If you want to skip all pre-commit checks, set POLAR_SKIP_PRE_COMMIT=yes" >&2 + exit 1 + fi +fi + +if [ "$POLAR_TEST_ON_COMMIT" = "yes" ] +then + if ! npm run test:ci + then + echo "Cannot commit: Unit testing failed." >&2 + echo "Please fix the above error(s)." >&2 + echo >&2 + echo "If you want to commit anyway, set POLAR_TEST_ON_COMMIT=no" >&2 + echo "If you want to skip all pre-commit checks, set POLAR_SKIP_PRE_COMMIT=yes" >&2 + exit 1 + fi +fi diff --git a/.husky/prepareConfig.js b/.husky/prepareConfig.js new file mode 100644 index 0000000000..2736be2893 --- /dev/null +++ b/.husky/prepareConfig.js @@ -0,0 +1,39 @@ +import { existsSync, readFileSync } from 'node:fs' + +let config = JSON.parse(readFileSync('.husky/defaults.json').toString()) +if (existsSync('git-hooks.config.json')) { + config = { + ...config, + ...JSON.parse(readFileSync('git-hooks.config.json').toString()), + } +} + +function formatKey(key) { + return ( + 'POLAR_' + key.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase()).toUpperCase() + ) +} + +function getEnvValue(key) { + if (!process.env[key]) { + return null + } + const envValue = process.env[key] + if (['yes', 'YES', 'true', 'TRUE', '1', 'on', 'ON'].includes(envValue)) { + return true + } else if ( + ['no', 'NO', 'false', 'FALSE', '0', 'off', 'OFF'].includes(envValue) + ) { + return false + } + process.stderr.write( + `Expected either "yes" or "no" for ${key}, got ${JSON.stringify(envValue)}` + ) + process.exit(1) +} + +Object.entries(config).forEach(([key, value]) => { + const formattedKey = formatKey(key) + const effectiveValue = getEnvValue(formattedKey) ?? value + process.stdout.write(`${formattedKey}=${effectiveValue ? 'yes' : 'no'}\n`) +}) diff --git a/.npmrc b/.npmrc index 2d610958c4..ec9e05d8a7 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ -legacy-peer-deps=true -registry=https://registry.npmjs.org +min-release-age=3 diff --git a/.prettierrc b/.prettierrc index f95f035357..7173af6d3d 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,7 +1,8 @@ { - "semi": false, - "trailingComma": "es5", - "singleQuote": true, - "printWidth": 80, - "tabWidth": 2 + "semi": false, + "trailingComma": "es5", + "singleQuote": true, + "printWidth": 80, + "tabWidth": 2, + "useTabs": true } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..c860112bdb --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run development server", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"] + }, + { + "name": "Run production server", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "preview"] + }, + { + "name": "Generate docs", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "docs"] + } + ] +} \ No newline at end of file diff --git a/@types/i18next.d.ts b/@types/i18next.d.ts deleted file mode 100644 index 858cf654b7..0000000000 --- a/@types/i18next.d.ts +++ /dev/null @@ -1,88 +0,0 @@ -import 'i18next' - -// // // core/plugins // // // - -import { resourcesDe as resourcesDeCore } from '@polar/core/src/locales' -import { resourcesDe as resourcesDeAddressSearch } from '@polar/plugin-address-search/src/locales' -import { resourcesDe as resourcesDeAttributions } from '@polar/plugin-attributions/src/locales' -import { resourcesDe as resourcesDeDraw } from '@polar/plugin-draw/src/locales' -import { resourcesDe as resourcesDeExport } from '@polar/plugin-export/src/locales' -import { resourcesDe as resourcesDeFilter } from '@polar/plugin-filter/src/locales' -import { resourcesDe as resourcesDeFullscreen } from '@polar/plugin-fullscreen/src/locales' -import { resourcesDe as resourcesDeGeoLocation } from '@polar/plugin-geo-location/src/locales' -import { resourcesDe as resourcesDeGfi } from '@polar/plugin-gfi/src/locales' -import { resourcesDe as resourcesDeIconMenu } from '@polar/plugin-icon-menu/src/locales' -import { resourcesDe as resourcesDeLayerChooser } from '@polar/plugin-layer-chooser/src/locales' -import { resourcesDe as resourcesDeLegend } from '@polar/plugin-legend/src/locales' -import { resourcesDe as resourcesDeLoadingIndicator } from '@polar/plugin-loading-indicator/src/locales' -import { resourcesDe as resourcesDePointerPosition } from '@polar/plugin-pointer-position/src/locales' -import { resourcesDe as resourcesDePins } from '@polar/plugin-pins/src/locales' -import { resourcesDe as resourcesDeScale } from '@polar/plugin-scale/src/locales' -import { resourcesDe as resourcesDeToast } from '@polar/plugin-toast/src/locales' -import { resourcesDe as resourcesDeZoom } from '@polar/plugin-zoom/src/locales' - -// // // clients // // // - -import { dishDe } from '@polar/client-dish/src/locales' -import { dishExportMapDe } from '@polar/client-dish/src/plugins/DishExportMap/locales' -import { dishHeaderDe } from '@polar/client-dish/src/plugins/Header/locales' -import { dishModalDe } from '@polar/client-dish/src/plugins/Modal/locales' -import { meldemichelDe } from '@polar/client-meldemichel/src/locales' -import { meldemichelDe as meldemichelAfmButtonDe } from '@polar/client-meldemichel/src/plugins/AfmButton/locales' -import { snowboxDe } from '@polar/client-snowbox/src/locales' -import { textLocatorDe } from '@polar/client-text-locator/src/locales' -import { textLocatorDe as textLocatorHeaderDe } from '@polar/client-text-locator/src/plugins/Header/locales' -import { geometrySearchDe } from '@polar/client-text-locator/src/plugins/GeometrySearch/locales' - -// // // resources // // // - -const resources = { - common: { - ...resourcesDeCore, - dish: dishDe, - meldemichel: meldemichelDe, - snowbox: snowboxDe, - textLocator: textLocatorDe, - plugins: { - addressSearch: resourcesDeAddressSearch.plugins.addressSearch, - attributions: resourcesDeAttributions.plugins.attributions, - dish: { - ...dishExportMapDe, - ...dishHeaderDe, - ...dishModalDe - }, - draw: resourcesDeDraw.plugins.draw, - export: resourcesDeExport.plugins.export, - filter: resourcesDeFilter.plugins.filter, - fullscreen: resourcesDeFullscreen.plugins.fullscreen, - geoLocation: resourcesDeGeoLocation.plugins.geoLocation, - // geometrySearch is from textLocator - geometrySearch: { - ...geometrySearchDe - }, - gfi: resourcesDeGfi.plugins.gfi, - iconMenu: resourcesDeIconMenu.plugins.iconMenu, - layerChooser: resourcesDeLayerChooser.plugins.layerChooser, - legend: resourcesDeLegend.plugins.legend, - loadingIndicator: resourcesDeLoadingIndicator.plugins.loadingIndicator, - meldemichel: { - ...meldemichelAfmButtonDe - }, - pointerPosition: resourcesDePointerPosition.plugins.pointerPosition, - pins: resourcesDePins.plugins.pins, - scale: resourcesDeScale.plugins.scale, - textLocator: { - ...textLocatorHeaderDe - }, - toast: resourcesDeToast.plugins.toast, - zoom: resourcesDeZoom.plugins.zoom, - } - } -} as const - -declare module 'i18next' { - interface CustomTypeOptions { - defaultNS: "common" - resources: typeof resources - } -} diff --git a/@types/vue-shims/index.d.ts b/@types/vue-shims/index.d.ts deleted file mode 100644 index c57cce794e..0000000000 --- a/@types/vue-shims/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -/// -/// -/// -/// diff --git a/@types/vue-shims/json-loader.d.ts b/@types/vue-shims/json-loader.d.ts deleted file mode 100644 index 6568666452..0000000000 --- a/@types/vue-shims/json-loader.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.json' { - const value: any - export default value -} diff --git a/@types/vue-shims/png-loader.d.ts b/@types/vue-shims/png-loader.d.ts deleted file mode 100644 index bef1b91599..0000000000 --- a/@types/vue-shims/png-loader.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.png' { - const value: any - export default value -} diff --git a/@types/vue-shims/shims-tsx.d.ts b/@types/vue-shims/shims-tsx.d.ts deleted file mode 100644 index a53c002225..0000000000 --- a/@types/vue-shims/shims-tsx.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/// -/* eslint-disable */ -import Vue, { VNode } from 'vue' - -declare global { - namespace JSX { - interface Element extends VNode {} - interface ElementClass extends Vue {} - interface IntrinsicElements { - [elem: string]: any - } - } -} diff --git a/@types/vue-shims/shims-vue.d.ts b/@types/vue-shims/shims-vue.d.ts deleted file mode 100644 index d9f24faa42..0000000000 --- a/@types/vue-shims/shims-vue.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.vue' { - import Vue from 'vue' - export default Vue -} diff --git a/@types/vue-shims/tsconfig.json b/@types/vue-shims/tsconfig.json deleted file mode 100644 index 4082f16a5d..0000000000 --- a/@types/vue-shims/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "../../tsconfig.json" -} diff --git a/LEGALNOTICE.md b/LEGALNOTICE.md index 3c486a5117..08721432c1 100644 --- a/LEGALNOTICE.md +++ b/LEGALNOTICE.md @@ -1,3 +1,7 @@ +--- +title: Legal Notice +--- + # Dataport Altenholzer Straße 10-14 @@ -13,5 +17,6 @@ Dr. Johann Bizer (Vorsitzender) Silke Tessmann-Storch Andreas Reichel Torsten Koß +Cristina Tuik USt-IdNr. gemäß § 27a Umsatzsteuergesetz: DE813840400 diff --git a/README.md b/README.md index e7a6278c61..67597db470 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,4 @@ ![Public Money, Public Value](https://img.shields.io/badge/Public%20Money-Public%20Value-red) [![License: EUPL v1.2](https://img.shields.io/badge/License-EUPL%20v1.2-blue)](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12) -[![We're on NPM!](https://img.shields.io/badge/npm-%F0%9F%9A%80-green)](https://www.npmjs.com/search?q=%40polar) -

POLAR

- -**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: - -

-Screenshot example of a possible POLAR client -

- -## Backers and users - -### States of Germany - - - - - - - - - - -
Bremer Wappenzeichen
Freie Hansestadt Bremen
Hamburg-Symbol
Freie und Hansestadt Hamburg
Landessymbol Sachsen-Anhalt
Sachsen-Anhalt
Landessymbol Schleswig-Holstein
Schleswig-Holstein
- -### Government agencies - -* [Senatskanzlei Hamburg](https://www.hamburg.de/senatskanzlei/) -* [Landesamt für Denkmalpflege Schleswig-Holstein](https://www.schleswig-holstein.de/DE/landesregierung/ministerien-behoerden/LD/ld_node.html) -* [Dataport AöR](https://www.dataport.de/) - -## Technical concepts - -### Reusability *and* adaptability - -POLAR is built to ease the creation of new map clients. A lot of feature requests in map clients are recurring and can be fulfilled with reusable parts. Then again, many map clients require a _little extra_. - -POLAR is built to serve both worlds. For generic use cases, generic clients are ready-made and usable by configuration. More specific use cases can be matched with special clients that still make use of the plugins and fill in the missing parts. - -POLAR runs both as full page application and as component. The most common usage is as component: Think of it as a form input where the input data is geospatial. - -### Plugin-based approach - -To see our plugins in action, please visit our [documentation page](https://dataport.github.io/polar/) to see running examples. Plugins are designed to be configurable, optional, and replacable. - -|Name|Details| -|-|-| -|[AddressSearch](https://github.com/Dataport/polar/tree/main/packages/plugins/AddressSearch)|Offers a search field and standard search service implementations with API for your own configurable custom search services. For already usable search services, see the documentation of the package. Integration with Reverse Geocoder and Pins possible, or usable as a data source for further processing.| -|[Attributions](https://github.com/Dataport/polar/tree/main/packages/plugins/Attributions)|Shows layer copyright information of visible layers and client.| -|[Draw](https://github.com/Dataport/polar/tree/main/packages/plugins/Draw)|Allows the user to draw various geometries onto the map. The resulting GeoJSON can be forwarded to later processing steps, or be used by the Export plugin to generate screenshots.| -|[Export](https://github.com/Dataport/polar/tree/main/packages/plugins/Export)|Offers screenshot functionality for the user or further processing.| -|[Filter](https://github.com/Dataport/polar/tree/main/packages/plugins/Filter)|Allows users to filter vector layers to content relevant to their interests.| -|[Fullscreen](https://github.com/Dataport/polar/tree/main/packages/plugins/Fullscreen)|User can toggle between integrated and fullscreen view with this plugin.| -|[GeoLocation](https://github.com/Dataport/polar/tree/main/packages/plugins/GeoLocation)|Geolocalizes the user either on user demand or as a background procedure. An icon is shown on the user position on the map.| -|[Gfi](https://github.com/Dataport/polar/tree/main/packages/plugins/Gfi)|Short for "Get Feature Information". Retrieves feature information from a WMS or WFS layer for display or usage by further processing steps. Can be used as feature list viewer for vector layers.| -|[IconMenu](https://github.com/Dataport/polar/tree/main/packages/plugins/IconMenu)|Handles display of visible plugin buttons. Only relevant for programming clients, no direct user feature.| -|[LayerChooser](https://github.com/Dataport/polar/tree/main/packages/plugins/LayerChooser)|Allows choosing a background layer and an arbitrary amount of feature or overlay layers. WMS layers can optionally be filtered by sub-layers by the user.| -|[Legend](https://github.com/Dataport/polar/tree/main/packages/plugins/Legend)|Displays an overview of layer legend images as delivered by the used WMS services. Images can be clicked for large view.| -|[LoadingIndicator](https://github.com/Dataport/polar/tree/main/packages/plugins/LoadingIndicator)|Loading spinner. Only relevant for programming clients, no direct user feature.| -|[PointerPosition](https://github.com/Dataport/polar/tree/main/packages/plugins/PointerPosition)|Displays the current/last pointer position in a coordinate reference system chosen by the user.| -|[Pins](https://github.com/Dataport/polar/tree/main/packages/plugins/Pins)|Pin feature that allows users to set and move pins to indicate a position. Integration with AddressSearch and ReverseGeocoder configurable.| -|[ReverseGeocoder](https://github.com/Dataport/polar/tree/main/packages/plugins/ReverseGeocoder)|Configurable to translate an arbitrary coordinate to an address. Integration with AddressSearch and Pins configurable.| -|[Scale](https://github.com/Dataport/polar/tree/main/packages/plugins/Scale)|Shows current scale as ratio and size indicator.| -|[Toast](https://github.com/Dataport/polar/tree/main/packages/plugins/Toast)|Shows information to the user. Configurable in many plugins to communicate status updates or procedural advice.| -|[Zoom](https://github.com/Dataport/polar/tree/main/packages/plugins/Zoom)|Allows zooming in and out of the client with buttons.| - -## Getting started (for developers) - -For a detailed step-by-step guide, please refer to our comprehensive [Getting Started guide](https://github.com/Dataport/polar/tree/main/gettingStarted.md). - -## Stay In Touch - -- [Contact us via email 📧](mailto:polar@dataport.de) - -made by [Dataport](https://www.dataport.de/) with ❤️ +# POLAR \ No newline at end of file diff --git a/babel.config.js b/babel.config.js deleted file mode 100644 index 487cbc3e43..0000000000 --- a/babel.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-env node */ - -module.exports = { - presets: [['@babel/preset-env', { targets: { node: 'current' } }]], - env: { - /* - * babel needed for jesting: Node does not support import/export; - * .babelrc does not work here since it does not affect node_modules/ol (babel.config.js does) - */ - test: { - plugins: ['@babel/plugin-transform-modules-commonjs'], - }, - }, -} diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000000..586d4ab114 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,19 @@ +import { globSync } from 'node:fs' +import { basename } from 'node:path' + +export default { + extends: ['@commitlint/config-conventional'], + rules: { + 'scope-enum': [ + 2, + 'always', + [ + 'arch', + 'release', + 'core', + ...globSync('src/plugins/*/').map((path) => basename(path)), + ...globSync('examples/*/').map((path) => basename(path)), + ], + ], + }, +} diff --git a/docs/architecture/decisions/ADR-0001.md b/docs/architecture/decisions/ADR-0001.md new file mode 100644 index 0000000000..8166228b14 --- /dev/null +++ b/docs/architecture/decisions/ADR-0001.md @@ -0,0 +1,21 @@ +# We write ADRs from now on + +## Status + +Accepted. + +## Context + +As time goes by, it becomes unclear whether architectural decisions have been made accidentally or on purpose, and, if on purpose, what the motivations were. + +## Decision + +From now on, all greater or debatable architectural decisions shall be denoted as an ADR within this document, like the "example ADR" you are currently reading. The used template is from [here](https://github.com/joelparkerhenderson/architecture-decision-record/tree/main/locales/en/templates/decision-record-template-by-michael-nygard). Also, questions arising about architecture shall be answered in such an ADR format to procude future references. + +If certain points change while not obsoleting the ADR as such, ADRs may be modified later. + +## Consequences + +* (+) Architecture decisions will become more transparent and understandable. +* (+) A truth base is defined and referencable. +* (-) Time needed for documentation. diff --git a/docs/architecture/decisions/ADR-0002.md b/docs/architecture/decisions/ADR-0002.md new file mode 100644 index 0000000000..9dec6f4cbe --- /dev/null +++ b/docs/architecture/decisions/ADR-0002.md @@ -0,0 +1,22 @@ +# Plugin-based architecture + +## Status + +Revoked by ADR 0009. + +## Context + +With recurring requirements, a desire grows to avoid repetition and reuse components. This can be implemented with a plugin-based architecture to create new map clients and have the most common features already done. + +## Decision + +An architecture for the client has been designed that models how we get to re-use functionality without re-writing it while still being open for extensions. The following graphic explains the architecture in further detail. + +![polar-2-architecture](https://github.com/Dataport/polar/assets/108349707/70090841-051c-44a7-8fde-2a9252a5d2ef) + +## Consequences + +* (+) Higher quality of features since multiple parties use them. +* (+) Implement once, use multiple times. +* (+) Parts are easier to exchange/develop, and not all clients are required to update immediately. +* (-) More difficult to understand the codebase. diff --git a/docs/architecture/decisions/ADR-0003.md b/docs/architecture/decisions/ADR-0003.md new file mode 100644 index 0000000000..6a0b51c9e2 --- /dev/null +++ b/docs/architecture/decisions/ADR-0003.md @@ -0,0 +1,17 @@ +# Error toasts have to be dismissed manually + +## Status + +Accepted. + +## Context + +Information relevant for the user is displayed in toasts which close automatically after a custom timeout. + +## Decision + +A timeout set for toasts that contain error messages will be ignored. Such toasts can only be closed manually by clicking the close button. + +## Consequences + +* (+) The user is forced to handle error messages and thus is more aware of errors that occur. diff --git a/docs/architecture/decisions/ADR-0004.md b/docs/architecture/decisions/ADR-0004.md new file mode 100644 index 0000000000..95a9eb0d76 --- /dev/null +++ b/docs/architecture/decisions/ADR-0004.md @@ -0,0 +1,19 @@ +# Vuex mutations have no map side effects + +## Status + +Obsoleted (Vuex is replaced with Pinia). + +## Context + +OL Map interactions are usually side effects by nature, but are not asynchronous. It was unclear whether such changes belong to actions or mutations. + +## Decision + +It has been decided that map side effects do not belong to mutations, but to actions. + +## Consequences + +* (+) Mutations stay clean of side effects. +* (+) On potential extension of such map calls, asynchronous behaviour may be required; in that case, actions are already the correct position. +* (-) This restriction must be manually enforced. diff --git a/docs/architecture/decisions/ADR-0005.md b/docs/architecture/decisions/ADR-0005.md new file mode 100644 index 0000000000..af9d6cccba --- /dev/null +++ b/docs/architecture/decisions/ADR-0005.md @@ -0,0 +1,24 @@ +# Difference between actions, utils and lib-packages + +## Status + +Accepted. + +## Context + +`actions`, `utils` and `lib`-packages can often consist of very similar code. It was not always clear enough where to place certain functionality, which was ultimately up to each developers own preference. + +## Decision + +When deciding on where to place code (when writing or refactoring), the following ordered list should be followed: + +* Does the functionality also change some part of the state? `action` +* Should the functionality be usable outside of an integrated client? `action` +* Does the functionality **not** have state changes, but belongs to a certain `action`? Either locally in the same file as the `action` or in a folder named after the `action` in the path `store/ACTIONNAME` +* Does the functionality **not** have state changes, but be reusable in the plugin / core? `utils` +* Should the functionality be reusable for multiple plugins / the core? `lib`-package + +## Consequences + +* (+) Gives clarity on where specific code fragments should reside. +* (-) This restriction must be manually enforced. diff --git a/docs/architecture/decisions/ADR-0006.md b/docs/architecture/decisions/ADR-0006.md new file mode 100644 index 0000000000..44a58b73e9 --- /dev/null +++ b/docs/architecture/decisions/ADR-0006.md @@ -0,0 +1,20 @@ +# `console` statement standardization + +## Status + +Accepted. + +## Context + +In production environments it may seem unclear if an error or warning message is shown in the console from which part of the application they occurred. + +## Decision + +All `console.warn` and `console.error` messages have to show the application's part in which they are invoked. +We add the location to the console messages at compile-time using Vite. + +## Consequences + +* (+) Errors and warnings can more easily be tracked back to the place in which they occur. +* (+) This restriction is enforced automatically. +* (-) Console messages are more verbose. diff --git a/docs/architecture/decisions/ADR-0007.md b/docs/architecture/decisions/ADR-0007.md new file mode 100644 index 0000000000..ebeb4eb370 --- /dev/null +++ b/docs/architecture/decisions/ADR-0007.md @@ -0,0 +1,19 @@ +# How to expose additional exports of a package + +## Status + +Accepted. + +## Context + +There are multiple ways of exposing additional exports of a package. They can either be exposed in the main file or configured as additional export nodes via rollup and the package.json. + +## Decision + +All exports should be exposed through the main file of the package as additional named exports. + +## Consequences + +* (+) A package consumer does not need to know additional paths to import. +* (+) This pattern is established by most major frameworks. +* (-) This restriction must be manually enforced. diff --git a/docs/architecture/decisions/ADR-0008.md b/docs/architecture/decisions/ADR-0008.md new file mode 100644 index 0000000000..d95510a4dc --- /dev/null +++ b/docs/architecture/decisions/ADR-0008.md @@ -0,0 +1,19 @@ +# Configuration parameters in tables have to be ordered by a) required and b) alphabetically. + +## Status + +Accepted. + +## Context + +If a developer is reading the docs, having the configuration parameters order first by required values then alphabetically makes it easier to find relevant parameters. + +## Decision + +All docs shall be sorted as proposed. + +## Consequences + +* (+) Better readability of documentation. +* (+) Clear placement of new parameters. +* (-) This restriction must be manually enforced. diff --git a/docs/architecture/decisions/ADR-0009.md b/docs/architecture/decisions/ADR-0009.md new file mode 100644 index 0000000000..32de144d24 --- /dev/null +++ b/docs/architecture/decisions/ADR-0009.md @@ -0,0 +1,23 @@ +# Revoke "ADR 0002: Plugin-based architecture" regarding packaging + +## Status + +Accepted. + +## Context + +The current structure uses NPM packages so segment the codebase into reusable parts. These packages have no known outside usage and slow down development in various positions as well as make documentation and changelogs a burden. Instead of a differentiation of core, plugins, and libs, all of these parts shall reside in a single package whilst maintaining the current pluginability feature. This single package shall also offer a default modulith client with all parts readymade for instantiating that can optionally be used. + +If accepted, the original ADR shall gain an additional sentence linking to this ADR regarding this future change, as this won't be executed easily, in a short time, or in a single step. + +## Decision + +We will restructure the architecture as shown in the next big major version. + +## Consequences + +* (+) Easier maintenance (no superfluous changelogs, easier type access, less boilerplate, faster releases). +* (+) Easier to understand the codebase. +* (-) It's not possible to use different versions of packages in the same client, especially old versions. + * (+) We never did this anyway and it may have produced complex fix scenarios (LTS for majors?) that no longer may occur. +* (-) We'll have to introduce technical limitations (architecture checks) regarding imports to prevent the codebase structure from degrading to spaghetti. diff --git a/docs/architecture/decisions/ADR-0010.md b/docs/architecture/decisions/ADR-0010.md new file mode 100644 index 0000000000..d21bbd87be --- /dev/null +++ b/docs/architecture/decisions/ADR-0010.md @@ -0,0 +1,18 @@ +# Manage ADRs with Git + +## Status + +Accepted. + +## Context + +Currently, ADRs are managed in a single GitHub wiki page of POLAR. + +## Decision + +We move the ADRs to the repository in a documentation folder. We write one file per ADR. + +## Consequences + +- (+) Changes to ADRs can more comfortably be tracked via Git. +- (o) There is more overhead in creating and updating ADRs, which may lead to writing less of them. diff --git a/docs/architecture/decisions/ADR-0011.md b/docs/architecture/decisions/ADR-0011.md new file mode 100644 index 0000000000..3fffb35317 --- /dev/null +++ b/docs/architecture/decisions/ADR-0011.md @@ -0,0 +1,28 @@ +# Split customer-specific clients into separate repositories + +## Status + +Accepted. + +## Context + +The new architecture, as introduced by ADR 0009, has a generic NPM package (@polar/polar, which replaces the packages @polar/core, @polar/lib-X, @polar/plugin-X and @polar/client-generic) and several customer-specific clients (@polar/client-X). + +The customer-specific clients are developed because of individual contracts and are (usually) not of major interest for other users. The maintenance of these clients is done primarily for the customers and does not contribute to the project's vision. + +## Decision + +Customer-specific clients (i.e., clients that are not the snowbox or the generic client) are moved to separate repositories (one repository per client). + +The new structure shall ensure that core changes can still be developed against a customer-specific client using HMR. + +## Consequences + +- (+) The repository structure is easier to understand (no monorepo). +- (+) Rules for contributions can be different between core and clients. +- (+) Contributors do not have to deal with customer-specific clients. +- (+) Real-world examples for implementing your own client in your own repository are provided. +- (+) Generating SBOMs is easier. +- (-) Following up with updates needs to be done in different repositories. + - (+) However, SemVer is used and helpers such as renovate exist. +- (o) Documentation of breaking changes including a migration guide is necessary. diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000000..456d50f5b6 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,46 @@ +--- +title: Architecture +--- + +# Architecture documentation + +## User perspective +When using POLAR, it behaves a simple fragment that can be used in any web-based setting. +It may either work standalone, in which case there are only inputs for configuration, or as a part of a process, in which case there are both inputs and outputs for further processing. + +The purpose of POLAR is to handle all geospatial interactions of a user and utilize the decentralized geospatial infrastructure for that end. + +![POLAR architecture as viewn from a user perspective](../assets/polar-outer-architecture.png) + +*Viewn from the outside, POLAR is just a component* + +## Usage examples +POLAR is designed to increase *application efficiency* and *correctness* for the public sector, but may be used in any form process or as a standalone map client. +The provided _visualisations_ ease communication between citizens and administrative staff, allowing them to effectively share the *where*. + +POLAR is already in use for ... + +- ... **citizens** to ... + - communicate parcel data in applications. + - mark their current position for reports. + - read information on water levels, bathing spots, and much other public information. +- ... **officials in charge** to ... + - coordinate city services regarding reports. + - present governmental data to the public. + - manage and update department geospatial data. +- ... **developers** to ... + - heavily reduce implementation time. + - easily use geospatial systems without domain expertise. + - use POLAR as component in low code platforms. + +## Inner architecture +On the inside, POLAR is constructed from many smaller and isolated packages that each encapsulate a specific part of the business logic. +These parts can be mixed and matched, and are easily replacable for situations where further extension would make them overly complicated. + +For client-specific business logic, this can be placed in the very client itself to prevent bloat in other parts of the product. + +All in all, this makes POLAR a versatile map client factory. + +![POLAR architecture of the software itself](../assets/polar-architecture.png) + +*Viewn from the inside, POLAR is a map client factory* diff --git a/pages/assets/iframe-resizer/LICENSE b/docs/assets/iframe-resizer/LICENSE similarity index 100% rename from pages/assets/iframe-resizer/LICENSE rename to docs/assets/iframe-resizer/LICENSE diff --git a/pages/assets/iframe-resizer/README.md b/docs/assets/iframe-resizer/README.md similarity index 100% rename from pages/assets/iframe-resizer/README.md rename to docs/assets/iframe-resizer/README.md diff --git a/pages/assets/iframe-resizer/js/iframeResizer.contentWindow.js b/docs/assets/iframe-resizer/js/iframeResizer.contentWindow.js similarity index 100% rename from pages/assets/iframe-resizer/js/iframeResizer.contentWindow.js rename to docs/assets/iframe-resizer/js/iframeResizer.contentWindow.js diff --git a/pages/assets/iframe-resizer/js/iframeResizer.js b/docs/assets/iframe-resizer/js/iframeResizer.js similarity index 100% rename from pages/assets/iframe-resizer/js/iframeResizer.js rename to docs/assets/iframe-resizer/js/iframeResizer.js diff --git a/pages/assets/landessymbole/bremen.svg b/docs/assets/landessymbole/bremen.svg similarity index 100% rename from pages/assets/landessymbole/bremen.svg rename to docs/assets/landessymbole/bremen.svg diff --git a/pages/assets/landessymbole/hamburg.svg b/docs/assets/landessymbole/hamburg.svg similarity index 100% rename from pages/assets/landessymbole/hamburg.svg rename to docs/assets/landessymbole/hamburg.svg diff --git a/pages/assets/landessymbole/sachsen-anhalt.svg b/docs/assets/landessymbole/sachsen-anhalt.svg similarity index 100% rename from pages/assets/landessymbole/sachsen-anhalt.svg rename to docs/assets/landessymbole/sachsen-anhalt.svg diff --git a/pages/assets/landessymbole/schleswig-holstein.svg b/docs/assets/landessymbole/schleswig-holstein.svg similarity index 100% rename from pages/assets/landessymbole/schleswig-holstein.svg rename to docs/assets/landessymbole/schleswig-holstein.svg diff --git a/pages/assets/landessymbole/sources.md b/docs/assets/landessymbole/sources.md similarity index 100% rename from pages/assets/landessymbole/sources.md rename to docs/assets/landessymbole/sources.md diff --git a/docs/assets/logo-polar--horizontal--dark.svg b/docs/assets/logo-polar--horizontal--dark.svg new file mode 100644 index 0000000000..75d2cdc3a3 --- /dev/null +++ b/docs/assets/logo-polar--horizontal--dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/logo-polar--horizontal.svg b/docs/assets/logo-polar--horizontal.svg new file mode 100644 index 0000000000..64f8e0879d --- /dev/null +++ b/docs/assets/logo-polar--horizontal.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/logo-polar.svg b/docs/assets/logo-polar.svg new file mode 100644 index 0000000000..0c8a1effb1 --- /dev/null +++ b/docs/assets/logo-polar.svg @@ -0,0 +1,41 @@ + + + + + + diff --git a/pages/assets/manypixels-decentralized.svg b/docs/assets/manypixels-decentralized.svg similarity index 100% rename from pages/assets/manypixels-decentralized.svg rename to docs/assets/manypixels-decentralized.svg diff --git a/pages/assets/manypixels-legal.svg b/docs/assets/manypixels-legal.svg similarity index 100% rename from pages/assets/manypixels-legal.svg rename to docs/assets/manypixels-legal.svg diff --git a/pages/assets/manypixels-map.svg b/docs/assets/manypixels-map.svg similarity index 100% rename from pages/assets/manypixels-map.svg rename to docs/assets/manypixels-map.svg diff --git a/pages/assets/manypixels-mobile.svg b/docs/assets/manypixels-mobile.svg similarity index 100% rename from pages/assets/manypixels-mobile.svg rename to docs/assets/manypixels-mobile.svg diff --git a/pages/assets/manypixels-puzzle.svg b/docs/assets/manypixels-puzzle.svg similarity index 100% rename from pages/assets/manypixels-puzzle.svg rename to docs/assets/manypixels-puzzle.svg diff --git a/pages/assets/maps_pin.jpg b/docs/assets/maps_pin.jpg similarity index 100% rename from pages/assets/maps_pin.jpg rename to docs/assets/maps_pin.jpg diff --git a/pages/assets/polar-architecture.png b/docs/assets/polar-architecture.png similarity index 100% rename from pages/assets/polar-architecture.png rename to docs/assets/polar-architecture.png diff --git a/pages/assets/polar-outer-architecture.png b/docs/assets/polar-outer-architecture.png similarity index 100% rename from pages/assets/polar-outer-architecture.png rename to docs/assets/polar-outer-architecture.png diff --git a/pages/assets/polar_example_screenshot.png b/docs/assets/polar_example_screenshot.png similarity index 100% rename from pages/assets/polar_example_screenshot.png rename to docs/assets/polar_example_screenshot.png diff --git a/pages/assets/productive-users/dataport-logo.svg b/docs/assets/productive-users/dataport-logo.svg similarity index 100% rename from pages/assets/productive-users/dataport-logo.svg rename to docs/assets/productive-users/dataport-logo.svg diff --git a/pages/assets/productive-users/hamburg-logo.svg b/docs/assets/productive-users/hamburg-logo.svg similarity index 100% rename from pages/assets/productive-users/hamburg-logo.svg rename to docs/assets/productive-users/hamburg-logo.svg diff --git a/pages/assets/productive-users/schleswig-holstein-logo.svg b/docs/assets/productive-users/schleswig-holstein-logo.svg similarity index 100% rename from pages/assets/productive-users/schleswig-holstein-logo.svg rename to docs/assets/productive-users/schleswig-holstein-logo.svg diff --git a/pages/assets/productive-users/sources.md b/docs/assets/productive-users/sources.md similarity index 100% rename from pages/assets/productive-users/sources.md rename to docs/assets/productive-users/sources.md diff --git a/docs/assets/sources.md b/docs/assets/sources.md new file mode 100644 index 0000000000..fe18c53140 --- /dev/null +++ b/docs/assets/sources.md @@ -0,0 +1,6 @@ +| File | Source | +| --------------------------- | ------------------------------------------------------------------------------------------- | +| logo-polar.svg and variants | designed by POLAR UI/UX | +| manypixels-\*.svg | https://www.manypixels.co/ | +| maps_pin.jpg | https://unsplash.com/de/fotos/person-die-eine-rote-runde-medikamentenpille-halt-Z8UgB80_46w | +| polar-\*-architecture.png | self-made | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000000..d022a90563 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,21 @@ +--- +title: Configuration +--- + +# Configuration +About configuration, integration, and common use cases. + +## Client documentation +For special usecases, there are specialized clients based on POLAR. + +The following specialized clients are managed by the POLAR core team: +- [Style preview documentation ↗](https://dataport.github.io/polar-client-style-preview) + +Not sure where to start? +Use the package @polar/polar and its documentation for an unspecialized client _including all plugins_. + +## Usage pattern +All clients come with instructions documented above. However, they all mostly share how their integration works. Overall, these parts are required: + +- *TODO* + diff --git a/docs/contact.md b/docs/contact.md new file mode 100644 index 0000000000..138bb4415b --- /dev/null +++ b/docs/contact.md @@ -0,0 +1,6 @@ +--- +title: Contact +--- + +# Contact +Mail us at polar@dataport.de diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000000..c023334849 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,35 @@ +--- +title: Development +--- + +# Development + +Hint: +Developing yourself is optional. +POLAR supplies ready-made clients for many use cases, and you may commission us to write additional features. + +## Where to code +POLAR clients run everywhere. +To develop plugins and clients anew, a certain setup is required. +To avoid redoing it, it is advised to create additional plugins and clients in a fork of the project. + +There are no further requirements. +If you aim to merge back, please contact us before starting to put in work. + +TODO: Update this section, especially for clients + +## Required skills +Depending on what exactly you plan to write anew, the required skills may vary. +POLAR is a purely front-end solution and as such general knowledge about web development is advisable. + +We are especially writing the client with the following libraries, to which additional knowledge is helpful for contributions. + +100 *OpenLayers* + +90 *Vue* + +80 *Vuex* + +80 *TypeScript* + +10 *SCSS* diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..5aad09f527 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,11 @@ +--- +title: Introduction +--- + +# What is POLAR? + +POLAR is ... + +* ... a configurable map client package. +* ... a flexible map client factory. +* ... an extensible library. diff --git a/docs/legal-notice.md b/docs/legal-notice.md new file mode 120000 index 0000000000..a4ef2b2e5a --- /dev/null +++ b/docs/legal-notice.md @@ -0,0 +1 @@ +../LEGALNOTICE.md \ No newline at end of file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000000..d784b755f5 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,9 @@ +--- +title: Troubleshooting +--- + +## Common pitfalls + +### Map is not displayed + +- For Safari on iPadOS: Are the options "Advanced tracking protections" and "blocking of all cookies" enabled? If so, disable them and try again. \ No newline at end of file diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000000..edd4ccbdd7 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,243 @@ +import mainConfig from '@dataport/eslint-config-geodev' +import browserConfig from '@dataport/eslint-config-geodev/browser' +import htmlConfig from '@dataport/eslint-config-geodev/html' +import jsonConfig from '@dataport/eslint-config-geodev/json' +import markdownConfig from '@dataport/eslint-config-geodev/markdown' +import tsConfig from '@dataport/eslint-config-geodev/typescript' +import vueConfig from '@dataport/eslint-config-geodev/vue' +import importPlugin from 'eslint-plugin-import' +import perfectionist from 'eslint-plugin-perfectionist' +import prettierConfig from 'eslint-plugin-prettier/recommended' +import vue from 'eslint-plugin-vue' +import { defineConfig } from 'eslint/config' + +import local from './eslintRules/index.js' + +/** + * POLAR-specific ESLint configuration + */ +const polarConfig = defineConfig({ + plugins: { + import: importPlugin, + perfectionist, + vue, + local, + }, + rules: { + 'prettier/prettier': 'error', + + // Re-enable rules that are disabled by prettier but do not collide + curly: ['error', 'all'], + + // POLAR-specific rules + 'no-warning-comments': 'warn', + 'no-void': 'off', + '@stylistic/lines-around-comment': [ + 'error', + { + beforeBlockComment: true, + allowBlockStart: true, + allowObjectStart: true, + allowArrayStart: true, + allowClassStart: true, + allowEnumStart: true, + allowInterfaceStart: true, + allowModuleStart: true, + allowTypeStart: true, + }, + ], + 'import-x/order': 'off', + 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], + 'perfectionist/sort-imports': [ + 'error', + { + groups: [ + 'type-import', + { newlinesBetween: 0 }, + 'type-internal', + { newlinesBetween: 0 }, + 'type-parent', + { newlinesBetween: 0 }, + 'type-sibling', + { newlinesBetween: 0 }, + 'type-index', + ['value-builtin', 'value-external'], + 'value-internal', + ['value-parent', 'value-sibling', 'value-index'], + 'ts-equals-import', + 'unknown', + ], + }, + ], + 'perfectionist/sort-named-imports': 'error', + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + }, + }, + ], + 'local/import-style': 'error', + }, +}) + +/** + * POLAR-specific TypeScript ESLint configuration + */ +const polarTsConfig = defineConfig({ + rules: { + // Relaxed rules + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { + allowAny: true, + allowNumber: true, + }, + ], + + // POLAR-specific rules + 'perfectionist/sort-interfaces': [ + 'error', + { + type: 'natural', + groups: ['required-member', 'unknown'], + }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + disallowTypeAnnotations: true, + fixStyle: 'separate-type-imports', + prefer: 'type-imports', + }, + ], + }, +}) + +/** + * POLAR-specific Vue ESLint configuration + */ +const polarVueConfig = defineConfig({ + rules: { + // POLAR-specific rules + 'vue/no-empty-component-block': 'error', + 'vue/block-order': [ + 'error', + { + order: ['template', 'script', 'style'], + }, + ], + 'vue/block-lang': [ + 'error', + { + template: { + allowNoLang: true, + }, + script: { + lang: 'ts', + }, + style: { + allowNoLang: true, + }, + }, + ], + 'vue/component-api-style': ['error', ['script-setup', 'composition']], + 'vue/require-default-export': 'error', + 'vue/enforce-style-attribute': ['error', { allow: ['scoped'] }], + }, +}) + +/** + * POLAR-specific HTML ESLint configuration + */ +const polarHtmlConfig = defineConfig({ + rules: { + // POLAR-specific rules + '@html-eslint/require-closing-tags': ['error', { selfClosing: 'always' }], + '@html-eslint/no-extra-spacing-attrs': [ + 'error', + { enforceBeforeSelfClose: true }, + ], + }, +}) + +export default defineConfig([ + { + ignores: [ + 'vue2/', + 'node_modules/', + 'docs/assets/', + 'docs-html/', + '.vscode/', + '**/dist/**', + '**/.dist.preview/**', + + // Legacy list + '**/build', + '**/.cache', + '**/coverage', + '**/tests_output', + '*.d.ts', + '.nx/', + ], + }, + { + files: ['**/*.js', '**/*.mjs', '**/*.cjs'], + extends: [mainConfig, browserConfig, prettierConfig, polarConfig], + }, + { + files: ['**/*.ts'], + extends: [ + mainConfig, + browserConfig, + tsConfig, + prettierConfig, + polarConfig, + polarTsConfig, + ], + }, + { + files: ['**/eslint.config.ts'], + rules: { + '@typescript-eslint/naming-convention': 'off', + }, + }, + { + files: ['**/*.vue'], + extends: [ + mainConfig, + browserConfig, + tsConfig, + vueConfig, + prettierConfig, + polarConfig, + polarTsConfig, + polarVueConfig, + ], + }, + { + files: ['**/examples/**/*.vue'], + rules: { + 'vue/enforce-style-attribute': ['error', { allow: ['scoped', 'module'] }], + }, + }, + { + files: ['**/*.json'], + ignores: ['package-lock.json'], + extends: [jsonConfig], + }, + { + files: ['**/*.md'], + extends: [markdownConfig], + }, + { + files: ['**/*.html'], + extends: [htmlConfig, polarHtmlConfig], + }, +]) diff --git a/eslintRules/import-style.ts b/eslintRules/import-style.ts new file mode 100644 index 0000000000..a376175c2d --- /dev/null +++ b/eslintRules/import-style.ts @@ -0,0 +1,201 @@ +import type { Rule } from 'eslint' + +import path from 'node:path' + +/** + * Returns the context key for a path that is already relative to srcRoot. + * + * Contexts: + * - "core" → src/core/** + * - "plugins/" → src/plugins//** + * - "lib/" → src/lib//** + * - "components/" → src/components//** + * - null → everything else (no context) + */ +function getContext(srcRelPath: string): string | null { + const parts = srcRelPath.split('/') + if (parts[0] === 'core') { + return 'core' + } + if (parts[0] === 'plugins' && parts.length > 1) { + return `plugins/${parts[1]}` + } + if (parts[0] === 'lib' && parts.length > 1) { + return `lib/${parts[1]}` + } + if (parts[0] === 'components' && parts.length > 1) { + return `components/${parts[1]}` + } + return null +} + +/** + * Resolves an import specifier to a path that is relative to srcRoot. + * Handles both `@/…` (alias) and relative (`./`, `../`) specifiers. + * Returns null when the specifier is external or resolves outside srcRoot. + */ +function resolveToSrcRel( + importPath: string, + currentSrcRel: string, + srcRoot: string +): string | null { + let abs: string + if (importPath.startsWith('@/')) { + abs = path.join(srcRoot, importPath.slice(2)) + } else if (importPath.startsWith('.')) { + abs = path.resolve( + path.join(srcRoot, path.dirname(currentSrcRel)), + importPath + ) + } else { + return null + } + const rel = path.relative(srcRoot, abs).replace(/\\/g, '/') + return rel.startsWith('..') ? null : rel +} + +/** + * Enforces POLAR import style conventions: + * + * 1. `.ts` extensions must be omitted in import/export sources. + * 2. Same-context imports must use relative paths (`./` or `../`). + * 3. Cross-context or context-less imports must use the `@/…` alias. + * + * A "context" is one of: + * - `core` for every file inside `src/core/` + * - `plugins/` for every file inside `src/plugins//` + * - `lib/` for every file inside `src/lib//` + * - `components/` for every file inside `src/components//` + * - (none) for all other files + * + * Files outside `srcDir` (default `"src"`) are ignored. + * + * @example rule options { srcDir: "src" } + */ +const importStyle: Rule.RuleModule = { + meta: { + type: 'suggestion', + fixable: 'code', + schema: [ + { + type: 'object', + properties: { + srcDir: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + messages: { + wrongPath: 'Import path "{{ actual }}" should be "{{ expected }}".', + }, + }, + + create(context) { + const srcDirOption: string = context.options[0]?.srcDir ?? 'src' + const srcRoot = path.resolve(context.cwd, srcDirOption) + + const currentSrcRel = path + .relative(srcRoot, path.resolve(context.filename)) + .replace(/\\/g, '/') + + // File is outside srcRoot — skip entirely + if (currentSrcRel.startsWith('..')) { + return {} + } + + const currentCtx = getContext(currentSrcRel) + + function check(sourceNode: { value?: unknown; raw?: string }) { + if (typeof sourceNode.value !== 'string') { + return + } + + const raw = sourceNode.value + + // Only handle project-internal specifiers + if (!raw.startsWith('@/') && !raw.startsWith('.')) { + return + } + + // Always strip the .ts extension + const withoutExt = raw.endsWith('.ts') ? raw.slice(0, -3) : raw + + // Resolve to a srcRoot-relative path for context comparison + const targetSrcRel = resolveToSrcRel(withoutExt, currentSrcRel, srcRoot) + + if (targetSrcRel === null) { + // Cannot resolve within src (edge case) — only fix extension + if (withoutExt !== raw) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const q = sourceNode.raw![0] + context.report({ + node: sourceNode as Rule.Node, + messageId: 'wrongPath', + data: { actual: raw, expected: withoutExt }, + fix: (fixer) => + fixer.replaceText( + sourceNode as Rule.Node, + `${q}${withoutExt}${q}` + ), + }) + } + return + } + + const targetCtx = getContext(targetSrcRel) + + // Determine the canonical form of this import + let expected: string + + if (currentCtx !== null && currentCtx === targetCtx) { + // Same context → relative path (no @/ alias) + const currentDir = path.join(srcRoot, path.dirname(currentSrcRel)) + const targetAbs = path.join(srcRoot, targetSrcRel) + let rel = path.relative(currentDir, targetAbs).replace(/\\/g, '/') + if (rel === '') { + // The target file's extension-stripped path is identical to the + // current file's directory. This happens when a file imports a + // sibling file whose name matches its own folder, e.g. importing + // `../types.ts` from a file inside a folder named `types`. + rel = `../${path.basename(targetAbs)}` + } else if (!rel.startsWith('.')) { + rel = `./${rel}` + } + expected = rel + } else { + // Cross-context or no context → alias + expected = `@/${targetSrcRel}` + } + + if (raw !== expected) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const q = sourceNode.raw![0] + context.report({ + node: sourceNode as Rule.Node, + messageId: 'wrongPath', + data: { actual: raw, expected }, + fix: (fixer) => + fixer.replaceText(sourceNode as Rule.Node, `${q}${expected}${q}`), + }) + } + } + + return { + /* eslint-disable @typescript-eslint/naming-convention */ + ImportDeclaration(node) { + check(node.source) + }, + ExportAllDeclaration(node) { + check(node.source) + }, + ExportNamedDeclaration(node) { + if (node.source) { + check(node.source) + } + }, + /* eslint-enable @typescript-eslint/naming-convention */ + } + }, +} + +export default importStyle diff --git a/eslintRules/index.ts b/eslintRules/index.ts new file mode 100644 index 0000000000..427b1eac92 --- /dev/null +++ b/eslintRules/index.ts @@ -0,0 +1,9 @@ +import importStyle from './import-style.js' + +export default { + rules: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'import-style': importStyle, + /* eslint-enable @typescript-eslint/naming-convention */ + }, +} diff --git a/examples/generic/index.html b/examples/generic/index.html new file mode 100644 index 0000000000..9550af4d23 --- /dev/null +++ b/examples/generic/index.html @@ -0,0 +1,66 @@ + + + + Generic POLAR client + + + + + + + + + +

POLAR map client

+ + +

Demo application

+
+
+
+ + diff --git a/examples/generic/index.js b/examples/generic/index.js new file mode 100644 index 0000000000..cb3edfba1d --- /dev/null +++ b/examples/generic/index.js @@ -0,0 +1,165 @@ +import { updateState } from '@polar/polar' +import { createMap } from '@polar/polar/client' +import { toMerged } from 'es-toolkit' + +const basemapId = '23420' +const basemapGreyId = '23421' +const reports = '6059' +const hamburgBorder = '1693' + +let colorScheme = 'light' + +// arbitrary condition for testing +const isEvenId = (mmlid) => Number(mmlid.slice(-1)) % 2 === 0 + +// NOTE: This function is only usable if the layer is clustered +const isReportSelectable = (feature) => + feature + .get('features') + .reduce( + (accumulator, current) => isEvenId(current.get('mmlid')) || accumulator, + false + ) + +const map = await createMap( + 'polarstern', + 'https://geoportal-hamburg.de/lgv-config/services-internet.json', + { + colorScheme, + startCenter: [565874, 5934140], + layers: [ + { + id: basemapId, + visibility: true, + type: 'background', + name: 'Basemap.de (Farbe)', + }, + { + id: basemapGreyId, + type: 'background', + name: 'Basemap.de (Grau)', + maxZoom: 6, + }, + { + id: hamburgBorder, + visibility: true, + hideInMenu: true, + type: 'mask', + name: 'Stadtgrenze Hamburg', + }, + { + id: reports, + type: 'mask', + name: 'Anliegen (MML)', + visibility: false, + }, + ], + layout: 'nineRegions', + checkServiceAvailability: true, + markers: { + layers: [ + { + id: reports, + defaultStyle: { + stroke: '#FFFFFF', + fill: '#005CA9', + }, + hoverStyle: { + stroke: '#46688E', + fill: '#8BA1B8', + }, + selectionStyle: { + stroke: '#FFFFFF', + fill: '#E10019', + }, + unselectableStyle: { + stroke: '#FFFFFF', + fill: '#333333', + }, + isSelectable: isReportSelectable, + }, + ], + clusterClickZoom: true, + }, + scale: { + showScaleSwitcher: true, + }, + addressSearch: { + searchMethods: [ + { + queryParameters: { + searchStreets: true, + searchHouseNumbers: true, + }, + type: 'mpapi', + url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + }, + ], + minLength: 3, + waitMs: 300, + focusAfterSearch: true, + groupProperties: { + defaultGroup: { + limitResults: 5, + }, + }, + }, + pins: { + coordinateSources: [{ plugin: 'addressSearch', key: 'chosenAddress' }], + boundary: { + layerId: hamburgBorder, + }, + movable: 'drag', + style: { + fill: '#FF0019', + }, + toZoomLevel: 7, + }, + reverseGeocoder: { + url: 'https://geodienste.hamburg.de/HH_WPS', + coordinateSources: [ + { + plugin: 'pins', + key: 'coordinate', + }, + ], + addressTarget: { + plugin: 'addressSearch', + key: 'selectResult', + }, + zoomTo: 7, + }, + geoLocation: { + checkLocationInitially: false, + keepCentered: false, + showTooltip: true, + zoomLevel: 7, + }, + fullscreen: {}, + }, + (serviceRegister) => + serviceRegister.map((entry) => + entry.id === reports ? toMerged(entry, { clusterDistance: 20 }) : entry + ) +) + +/* simple language switcher attached for demo purposes; + * language switching is considered a global concern and + * should be handled by the leading application */ +document + .getElementById('language-switcher') + ?.addEventListener('change', (event) => { + const target = event.target + const { value } = target + updateState(map, 'core', 'language', value) + target[0].innerHTML = value === 'en' ? 'English' : 'Englisch' + target[1].innerHTML = value === 'en' ? 'German' : 'Deutsch' + }) + +document + .getElementById('color-scheme-switcher') + ?.addEventListener('click', ({ target }) => { + target.innerHTML = `Switch to ${colorScheme} mode` + colorScheme = colorScheme === 'light' ? 'dark' : 'light' + updateState(map, 'core', 'colorScheme', colorScheme) + }) diff --git a/examples/generic/tsconfig.json b/examples/generic/tsconfig.json new file mode 100644 index 0000000000..4f7e3eb9b4 --- /dev/null +++ b/examples/generic/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", + "../../src/@types/vite-env.d.ts", + "../../src/@types/i18next.d.ts", + "../../src/@types/pinia.d.ts", + "../../src/@types/shims-masterportalapi.d.ts", + "../../src/@types/virtual-kern-extra-icons.d.ts" + ], + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "paths": { + "@polar/polar": ["../../src/core/index.ts"], + "@polar/polar/client": ["../../src/client.ts"] + } + } +} diff --git a/examples/github-io/App.vue b/examples/github-io/App.vue new file mode 100644 index 0000000000..1f2839e061 --- /dev/null +++ b/examples/github-io/App.vue @@ -0,0 +1,158 @@ + + + + + + + diff --git a/examples/github-io/components/CtaSection.vue b/examples/github-io/components/CtaSection.vue new file mode 100644 index 0000000000..605cc8d527 --- /dev/null +++ b/examples/github-io/components/CtaSection.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/examples/github-io/components/DevExSection.vue b/examples/github-io/components/DevExSection.vue new file mode 100644 index 0000000000..36b76c0d03 --- /dev/null +++ b/examples/github-io/components/DevExSection.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/examples/github-io/components/FeaturesSection.vue b/examples/github-io/components/FeaturesSection.vue new file mode 100644 index 0000000000..9b6712d030 --- /dev/null +++ b/examples/github-io/components/FeaturesSection.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/examples/github-io/components/HeroPolarMap.vue b/examples/github-io/components/HeroPolarMap.vue new file mode 100644 index 0000000000..7ee52c1526 --- /dev/null +++ b/examples/github-io/components/HeroPolarMap.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/examples/github-io/components/HeroSection.vue b/examples/github-io/components/HeroSection.vue new file mode 100644 index 0000000000..cc6e02aa0b --- /dev/null +++ b/examples/github-io/components/HeroSection.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/examples/github-io/components/RoadmapCard.vue b/examples/github-io/components/RoadmapCard.vue new file mode 100644 index 0000000000..ef470250ee --- /dev/null +++ b/examples/github-io/components/RoadmapCard.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/examples/github-io/components/RoadmapPhaseLabel.vue b/examples/github-io/components/RoadmapPhaseLabel.vue new file mode 100644 index 0000000000..2af1ff85e2 --- /dev/null +++ b/examples/github-io/components/RoadmapPhaseLabel.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/examples/github-io/components/RoadmapSection.vue b/examples/github-io/components/RoadmapSection.vue new file mode 100644 index 0000000000..d7facbe9b4 --- /dev/null +++ b/examples/github-io/components/RoadmapSection.vue @@ -0,0 +1,401 @@ + + + + + diff --git a/examples/github-io/components/TheBadge.vue b/examples/github-io/components/TheBadge.vue new file mode 100644 index 0000000000..bc4dd162cb --- /dev/null +++ b/examples/github-io/components/TheBadge.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/examples/github-io/components/TheFooter.vue b/examples/github-io/components/TheFooter.vue new file mode 100644 index 0000000000..8b15fddaf0 --- /dev/null +++ b/examples/github-io/components/TheFooter.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/examples/github-io/components/TheHeader.vue b/examples/github-io/components/TheHeader.vue new file mode 100644 index 0000000000..778839133a --- /dev/null +++ b/examples/github-io/components/TheHeader.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/examples/github-io/components/UsedBySection.vue b/examples/github-io/components/UsedBySection.vue new file mode 100644 index 0000000000..39b13087e6 --- /dev/null +++ b/examples/github-io/components/UsedBySection.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/examples/github-io/components/UxSection.vue b/examples/github-io/components/UxSection.vue new file mode 100644 index 0000000000..8547c5e9da --- /dev/null +++ b/examples/github-io/components/UxSection.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/examples/github-io/components/VideoSection.vue b/examples/github-io/components/VideoSection.vue new file mode 100644 index 0000000000..60317b1944 --- /dev/null +++ b/examples/github-io/components/VideoSection.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/plugins/addressSearch/components/SearchResults.ce.vue b/src/plugins/addressSearch/components/SearchResults.ce.vue new file mode 100644 index 0000000000..6dd661dd24 --- /dev/null +++ b/src/plugins/addressSearch/components/SearchResults.ce.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/src/plugins/addressSearch/components/SmallLoader.ce.vue b/src/plugins/addressSearch/components/SmallLoader.ce.vue new file mode 100644 index 0000000000..6bcd7c7e72 --- /dev/null +++ b/src/plugins/addressSearch/components/SmallLoader.ce.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/plugins/addressSearch/index.ts b/src/plugins/addressSearch/index.ts new file mode 100644 index 0000000000..292b68b16a --- /dev/null +++ b/src/plugins/addressSearch/index.ts @@ -0,0 +1,41 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/addressSearch + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { AddressSearchPluginOptions } from './types' + +import component from './components/AddressSearch.ce.vue' +import locales from './locales' +import { useAddressSearchStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which adds a user interface to search for various kinds of textual information to map it to a + * geometry; e.g. parcel numbers or addresses, but any kind of toponym mapping is possible. + * If multiple addresses are returned by services, the user is prompted to select a result. + * + * All results, including one selected by a user, are saved as GeoJSON for further processing. + * + * Currently supported services: + * - BKG + * - WFS + * - Hamburg WFS-G (`mpapi`), may fit some WFS-G outside HH, testing is advised + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginAddressSearch( + options: AddressSearchPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + storeModule: useAddressSearchStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/addressSearch/locales.ts b/src/plugins/addressSearch/locales.ts new file mode 100644 index 0000000000..0309d0393b --- /dev/null +++ b/src/plugins/addressSearch/locales.ts @@ -0,0 +1,68 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the addressSearch plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/addressSearch + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +export const resourcesDe = { + aria: { + description: + 'Durch Eingabe in das Suchfeld kann die Suche nach Adressen gestartet werden', + }, + defaultLabel: 'Adresssuche', + hint: { + button: 'Eingabefeld der Addresssuche anzeigen', + clear: 'Das Eingabefeld der Addresssuche leeren', + error: 'Etwas ist bei der Suche schiefgegangen.', + loading: 'Suche ...', + noResults: 'Keine Ergebnisse gefunden.', + tooShort: 'Für die Suche bitte mindestens {{minLength}} Zeichen eingeben.', + }, + groupSelector: 'Suchthema auswählen', + resultCount: '({{count}} Ergebnisse)', + resultList: { + extend: 'Alle Ergebnisse anzeigen', + reduce: 'Ergebnisliste reduzieren', + }, +} as const + +export const resourcesEn = { + aria: { + description: + 'By entering text into the search field, the address search can be started', + }, + defaultLabel: 'Address Search', + hint: { + button: 'Show address search input field', + clear: 'Clear address search input field', + error: 'Something went wrong.', + loading: 'Searching ...', + noResults: 'No results for the current query.', + tooShort: 'Please enter at least {{minLength}} characters.', + }, + groupSelector: 'Select search topic', + resultCount: '({{count}} results)', + resultList: { + extend: 'Show all results', + reduce: 'Reduce result 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/addressSearch/store.ts b/src/plugins/addressSearch/store.ts new file mode 100644 index 0000000000..33aab1788d --- /dev/null +++ b/src/plugins/addressSearch/store.ts @@ -0,0 +1,399 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/addressSearch/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { PolarGeoJsonFeature } from '@/core' +import type { + AddressSearchPluginOptions, + GroupProperties, + SearchMethodConfiguration, + SearchResult, +} from './types' + +import { debounce, toMerged } from 'es-toolkit' +import { t } from 'i18next' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' +import { getResultsFromPromises } from './utils/getResultsFromPromises' +import { getMethodContainer } from './utils/methodContainer' +import SearchResultSymbols from './utils/searchResultSymbols' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for the address search. + */ +/* eslint-enable tsdoc/syntax */ +export const useAddressSearchStore = defineStore( + 'plugins/addressSearch', + () => { + const coreStore = useCoreStore() + + const defaultGroupProperties: Required = { + label: 'defaultLabel', + hint: '', + resultDisplayMode: 'mixed', + limitResults: Number.MAX_SAFE_INTEGER, + } + + let abortController: AbortController | null = null + let debouncedSearch: ReturnType> + let methodContainer: ReturnType + + const chosenAddress = ref(null) + const _inputValue = ref('') + const isLoading = ref(false) + const searchResults = ref( + SearchResultSymbols.NO_SEARCH + ) + const _selectedGroupId = ref('defaultGroup') + + const afterResultComponent = computed( + () => configuration.value.afterResultComponent || null + ) + const configuration = computed( + () => coreStore.configuration.addressSearch as AddressSearchPluginOptions + ) + const inputValue = computed({ + get: () => _inputValue.value, + set: (value) => { + if (value === _inputValue.value) { + return + } + _inputValue.value = value + abortAndRequest() + }, + }) + const featuresAvailable = computed( + () => + Array.isArray(searchResults.value) && + searchResults.value.length > 0 && + searchResults.value.some( + ({ features: { features } }) => + Array.isArray(features) && features.length > 0 + ) + ) + const focusAfterSearch = computed( + () => configuration.value.focusAfterSearch || false + ) + const getGroupProperties = computed( + () => + (groupId: string): GroupProperties => { + const selectedGroupProperties = + configuration.value.groupProperties?.[groupId] || + ({} as GroupProperties) + // defaultGroup is only one with predefined values + return groupId === 'defaultGroup' + ? toMerged(defaultGroupProperties, selectedGroupProperties) + : selectedGroupProperties + } + ) + const groupIds = computed(() => Object.keys(searchMethodsByGroupId.value)) + const groupSelectOptions = computed(() => + Object.keys(searchMethodsByGroupId.value).map((key) => ({ + groupId: key, + text: getGroupProperties.value(key).label, + })) + ) + const hasMultipleGroups = computed(() => groupIds.value.length > 1) + const hint = computed(() => { + if (isLoading.value) { + return t(($) => $.hint.loading, { ns: PluginId }) + } + + if (searchResults.value === SearchResultSymbols.ERROR) { + return t(($) => $.hint.error, { ns: PluginId }) + } + + if ( + inputValue.value.length > 0 && + inputValue.value.length < minLength.value + ) { + return t(($) => $.hint.tooShort, { + minLength: String(minLength.value), + ns: PluginId, + }) + } + + if ( + searchResults.value !== SearchResultSymbols.NO_SEARCH && + !featuresAvailable.value + ) { + return t(($) => $.hint.noResults, { ns: PluginId }) + } + + return selectedGroupProperties.value.hint || '' + }) + const limitResults = computed( + () => + selectedGroupProperties.value.limitResults || + defaultGroupProperties.limitResults + ) + const minLength = computed(() => + typeof configuration.value.minLength === 'number' + ? configuration.value.minLength + : 3 + ) + const searchMethodsByGroupId = computed< + Record + >(() => + configuration.value.searchMethods.reduce((groups, searchMethod) => { + const searchMethodName = searchMethod.groupId || 'defaultGroup' + if (groups[searchMethodName]) { + groups[searchMethodName].push(searchMethod) + } else { + groups[searchMethodName] = [searchMethod] + } + return groups + }, {}) + ) + const selectedGroupId = computed({ + get: () => _selectedGroupId.value, + set: (value) => { + if (value === _selectedGroupId.value) { + return + } + _selectedGroupId.value = value + searchResults.value = SearchResultSymbols.NO_SEARCH + if (inputValue.value.length > 0) { + void _search() + } + }, + }) + const selectedGroupProperties = computed(() => + getGroupProperties.value(selectedGroupId.value) + ) + const waitMs = computed(() => + typeof configuration.value.waitMs === 'number' + ? configuration.value.waitMs + : 300 + ) + + function setupPlugin() { + debouncedSearch = debounce(_search, waitMs.value) + methodContainer = getMethodContainer() + selectedGroupId.value = groupIds.value[0] as string + if (configuration.value.customSearchMethods) { + // TODO: The method was bound to the store before, test with DISH if still required + methodContainer.registerSearchMethods( + configuration.value.customSearchMethods + ) + } + } + + function teardownPlugin() {} + + function abortAndRequest() { + if (abortController) { + abortController.abort() + abortController = null + } + debouncedSearch() + } + + function clear() { + inputValue.value = '' + searchResults.value = SearchResultSymbols.NO_SEARCH + chosenAddress.value = null + } + + function _search() { + if (inputValue.value.length < minLength.value) { + searchResults.value = SearchResultSymbols.NO_SEARCH + isLoading.value = false + return Promise.resolve() + } + isLoading.value = true + abortController = new AbortController() + const localAbortControllerReference = abortController + return Promise.allSettled( + configuration.value.searchMethods.map( + async ({ + categoryId, + groupId, + queryParameters, + resultModifier, + type, + url, + }) => { + const features = await methodContainer.getSearchMethod(type)( + localAbortControllerReference.signal, + url, + inputValue.value, + toMerged(queryParameters || {}, { + epsg: coreStore.configuration.epsg, + }) + ) + const id = categoryId || 'default' + const properties = configuration.value.categoryProperties?.[id] + return { + categoryId: id, + categoryLabel: properties + ? // @ts-expect-error | Other values can be used. + t(properties.label) + : t(($) => $.defaultLabel, { ns: PluginId }), + features: resultModifier?.(features) ?? features, + groupId: groupId || 'defaultGroup', + } + } + ) + ) + .then( + (results) => + (searchResults.value = getResultsFromPromises( + results, + localAbortControllerReference + )) + ) + .catch((error: unknown) => { + console.error('An error occurred while searching.', error) + searchResults.value = SearchResultSymbols.ERROR + }) + .finally(() => { + isLoading.value = false + }) + } + + async function search( + input: string, + autoselect: 'first' | 'only' | 'never' = 'never' + ) { + inputValue.value = input + if (abortController) { + abortController.abort() + abortController = null + } + await _search() + + if (!Array.isArray(searchResults.value)) { + // error or word too short, nothing to do + return + } + + const firstFound = searchResults.value.find( + ({ features }) => features.features.length + ) + if (!firstFound) { + // results are empty + return + } + const firstFeatures = firstFound.features + .features as PolarGeoJsonFeature[] + + if ( + (autoselect === 'first' && firstFeatures.length >= 1) || + (autoselect === 'only' && firstFeatures.length === 1) + ) { + selectResult( + firstFeatures[0] as PolarGeoJsonFeature, + firstFound.categoryId + ) + } + } + + function selectResult( + feature: PolarGeoJsonFeature, + categoryId = 'default' + ) { + const customMethod = configuration.value.customSelectResult?.[categoryId] + if (customMethod) { + customMethod(feature, categoryId) + } else { + chosenAddress.value = feature + _inputValue.value = feature.title + searchResults.value = SearchResultSymbols.NO_SEARCH + } + } + + return { + /** + * Address GeoJSON object _as returned by a search service_. + * The result and its fields differ depending on the used backend. + * The callback is used whenever the user clicks on a search result or + * started a one-result search, which results in an auto-select of the + * singular result. + */ + chosenAddress, + + /** @alpha */ + inputValue, + + /** @alpha */ + afterResultComponent, + + /** @alpha */ + focusAfterSearch, + + /** @alpha */ + groupSelectOptions, + + /** @alpha */ + hasMultipleGroups, + + /** @alpha */ + isLoading, + + /** + * The results of the search sorted by searchMethod. + */ + searchResults, + + /** + * ID of the currently selected group. + * Changing this triggers a new search with the currently set value + * for {@link inputValue} if it has at least one character. + */ + selectedGroupId, + + /** + * `true` if any service yielded features. + * + * @alpha + */ + featuresAvailable, + + /** @alpha */ + hint, + + /** @alpha */ + limitResults, + + /** @alpha */ + abortAndRequest, + + /** @alpha */ + clear, + + /** + * This function is solely meant for programmatic access and is not used by + * direct user input. + * + * @param input - Search string to be used. + * @param autoselect - Whether to automatically select a result. Defaults to `'never'` so that results will be presented as if the user searched for them. Using `'only'` will autoselect if a single result was returned; using `'first'` will autoselect the first of an arbitrary amount of results \>=1. + */ + search, + + /** @alpha */ + selectResult, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + } + } +) + +if (import.meta.hot) { + import.meta.hot.accept( + acceptHMRUpdate(useAddressSearchStore, import.meta.hot) + ) +} diff --git a/src/plugins/addressSearch/types.ts b/src/plugins/addressSearch/types.ts new file mode 100644 index 0000000000..89daffbfd4 --- /dev/null +++ b/src/plugins/addressSearch/types.ts @@ -0,0 +1,210 @@ +import type { Component } from 'vue' +import type { + PluginOptions, + PolarGeoJsonFeature, + PolarGeoJsonFeatureCollection, +} from '@/core' +import type { QueryParameters } from '@/lib/getFeatures/types' + +export const PluginId = 'addressSearch' + +export interface CategoryProperties { + /** + * Category label to display next to results to identify the source. + * Can be a locale key. + * + * Only relevant if the search's {@link AddressSearchPluginOptions.groupProperties | groupProperties} + * linked via {@link SearchMethodConfiguration.groupId | groupId} contain a + * {@link GroupProperties.resultDisplayMode | resultDisplayMode} scenario that uses categories. + */ + label: string +} + +export interface GroupProperties { + /** Display label for group selection. Can be a locale key. */ + label: string + + /** + * Hint that is displayed below the input field if no other plugin-state-based + * hint is to be displayed. + * Can be a locale key. + */ + hint?: string + + /** + * If set, only the first `n` results (per category in `categorized`) are displayed initially. + * All further results can be opened via UI. + */ + limitResults?: number + + /** + * In `'mixed'`, results of all requested services are offered in a list in no specific order. + * In `'categorized'`, the results are listed by their searchService's categoryId. + * + * @defaultValue 'mixed' + */ + resultDisplayMode?: 'mixed' | 'categorized' +} + +/** + * The configuration allows defining and grouping services. + * Grouped services can be requested in a search at the same time, and one group + * of searches can be active at a time. When multiple searches are in a group, + * they may be extended with category information to make the results easier to browse. + * + * @remarks + * In {@link categoryProperties} and {@link groupProperties}, id strings called + * {@link groupId} and {@link categoryId} are used. These are arbitrary strings + * you can introduce and reuse to group or categorize elements together. + */ +export interface AddressSearchPluginOptions extends PluginOptions { + /** + * Array of search method descriptions. + * Only searches configured here can be used. + */ + searchMethods: SearchMethodConfiguration[] + + /** + * If given, this component will be rendered in the last line of every single + * search result. It will be forwarded its search result feature as prop + * `feature` of type `GeoJSON.Feature`, and the focus state of the result as + * prop `focus` of type `boolean`. + */ + afterResultComponent?: Component + + /** + * An object defining properties for a category. + * The searchMethod's {@link AddressSearchPluginOptions.categoryId | addressSearch.categoryId} is used as identifier. + * + * A service without categoryId default to the {@link AddressSearchPluginOptions.categoryId | addressSearch.categoryId} + * `"default"`. + */ + categoryProperties?: Record + + /** + * An object with named search functions added to the existing set of + * configurable search methods. + */ + customSearchMethods?: Record + + /** + * An object that maps categoryIds to functions. + * These functions are then called inplace of the default `selectResult` + * implementation. This allows overriding selection behaviour. + * Use `''` as the key for categoryless results. + */ + customSelectResult?: Record + + /** + * Whether the focus should switch to the first result after a successful search. + * + * @defaultValue false + */ + focusAfterSearch?: boolean + + /** + * An object defining properties for a group. + * The searchMethod's groupId is used as identifier. + * All services without groupId fall back to the key `"defaultGroup"`. + */ + groupProperties?: Record + + /** + * Minimal input length before the search starts. + * + * @defaultValue 3 + */ + minLength?: number + + /** + * Time passed in milliseconds before another search is started. + * + * @defaultValue 300 + */ + waitMs?: number +} + +/** Possible search methods by type. */ +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents +export type SearchType = 'bkg' | 'wfs' | 'mpapi' | 'nominatim' | string + +export type SearchDisplayMode = 'mixed' | 'categorized' + +/** Object containing information for a specific search method. */ +export interface SearchMethodConfiguration { + /** + * Service type. + * Enum can be extended by configuration, see {@link AddressSearchPluginOptions.customSearchMethods | addressSearch.customSearchMethods}. + */ + type: SearchType + + /** + * Search service URL. + * Should you require a service provider, please contact us for further information. + */ + url: string + + /** + * Grouped services can optionally be distinguished in the UI with categories. + * See {@link AddressSearchPluginOptions.categoryProperties | addressSearch.categoryProperties} for configuration options. + * + * @defaultValue 'default' + */ + categoryId?: string + + /** + * All services with the same id are grouped and used together. + * See {@link AddressSearchPluginOptions.groupProperties | addressSearch.groupProperties} for configuration options. + * If multiple groups exist, the UI offers a group switcher. + * + * @remarks + * Default groupId is `"defaultGroup"`. + */ + groupId?: string + + /** + * Hint that is displayed below the input field if no other plugin-state-based hint is to be displayed. + * Can be a locale key. If grouped with other services, the group's hint will be used instead. + */ + hint?: string + + /** + * Display label. + * Can be a locale key. If grouped with other services, the group's label will be used instead. + */ + label?: string + + /** + * The object further describes details for the search request. + * Its contents vary by service type, see {@link BKGParameters}, {@link MpapiParameters}, {@link WfsParameters} or {@link NominatimParameters}. + */ + queryParameters?: QueryParameters + + /** + * The function will receive the full FeatureCollection object that may be reduced before returning it. + * The resultModifier function will be called before adding anything to the store. + * Programming knowledge required. + */ + resultModifier?: ( + object: PolarGeoJsonFeatureCollection + ) => PolarGeoJsonFeatureCollection +} + +export type SearchMethodFunction = ( + signal: AbortSignal, + url: SearchMethodConfiguration['url'], + inputValue: string, + queryParameters: SearchMethodConfiguration['queryParameters'] +) => Promise | never + +export interface SearchResult { + categoryId: string + categoryLabel: string + features: PolarGeoJsonFeatureCollection + groupId: string +} + +export type SelectResultFunction = ( + feature: PolarGeoJsonFeature, + categoryId: string +) => void diff --git a/src/plugins/addressSearch/utils/focusFirstResult.ts b/src/plugins/addressSearch/utils/focusFirstResult.ts new file mode 100644 index 0000000000..b25fc611d8 --- /dev/null +++ b/src/plugins/addressSearch/utils/focusFirstResult.ts @@ -0,0 +1,93 @@ +export function focusFirstResult( + searchResultsLength: number, + shadowRoot: ShadowRoot, + event?: KeyboardEvent +) { + for (let i = 0; i < searchResultsLength; i++) { + const firstFocusableElement = shadowRoot.getElementById( + `polar-plugin-address-search-results-feature-${i}-0` + ) + if (firstFocusableElement) { + firstFocusableElement.focus() + // prevent list scrolling on newly focused element + event?.preventDefault() + break + } + } +} + +if (import.meta.vitest) { + const { beforeEach, expect, test, vi } = import.meta.vitest + + beforeEach(() => { + vi.clearAllMocks() + }) + + const createElement = () => { + const focus = vi.fn() + return { focus, element: { focus } as unknown as HTMLElement } + } + + const createShadowRoot = ( + implementation: (id: string) => HTMLElement | null = () => null + ) => { + const getElementById = vi.fn(implementation) + return { + getElementById, + shadowRoot: { getElementById } as unknown as ShadowRoot, + } + } + + test('focuses the first available result element', () => { + const { focus, element } = createElement() + const { shadowRoot } = createShadowRoot((id) => + id === 'polar-plugin-address-search-results-feature-0-0' ? element : null + ) + + focusFirstResult(3, shadowRoot) + + expect(focus).toHaveBeenCalledTimes(1) + }) + + test('skips missing ids and focuses the first existing element', () => { + const { focus, element } = createElement() + const { getElementById, shadowRoot } = createShadowRoot((id) => + id === 'polar-plugin-address-search-results-feature-2-0' ? element : null + ) + + focusFirstResult(3, shadowRoot) + + expect(getElementById).toHaveBeenCalledTimes(3) + expect(focus).toHaveBeenCalledTimes(1) + }) + + test('does nothing when no element is found', () => { + const { getElementById, shadowRoot } = createShadowRoot() + + expect(() => { + focusFirstResult(2, shadowRoot) + }).not.toThrow() + expect(getElementById).toHaveBeenCalledTimes(2) + }) + + test('prevents default on the passed event to avoid list scrolling', () => { + const { element } = createElement() + const { shadowRoot } = createShadowRoot(() => element) + const preventDefault = vi.fn() + const event = { preventDefault } as unknown as KeyboardEvent + + focusFirstResult(1, shadowRoot, event) + + expect(preventDefault).toHaveBeenCalledTimes(1) + }) + + test('works without an event being passed', () => { + const { focus, element } = createElement() + const { shadowRoot } = createShadowRoot(() => element) + + expect(() => { + focusFirstResult(1, shadowRoot) + }).not.toThrow() + expect(focus).toHaveBeenCalledTimes(1) + }) +} diff --git a/src/plugins/addressSearch/utils/getResultsFromPromises.ts b/src/plugins/addressSearch/utils/getResultsFromPromises.ts new file mode 100644 index 0000000000..312c577430 --- /dev/null +++ b/src/plugins/addressSearch/utils/getResultsFromPromises.ts @@ -0,0 +1,83 @@ +import type { SearchResult } from '../types' + +export function getResultsFromPromises( + promises: PromiseSettledResult[], + abortController: AbortController +) { + const results = promises.reduce( + (acc, promise) => + promise.status === 'fulfilled' ? [...acc, promise.value] : acc, + [] + ) + + // only print errors if search was not aborted + if (!abortController.signal.aborted) { + ;( + promises.filter( + ({ status }) => status === 'rejected' + ) as PromiseRejectedResult[] + ).forEach(({ reason }) => { + console.error('An error occurred while sending a request: ', reason) + }) + } + + return results +} + +if (import.meta.vitest) { + const { beforeEach, expect, test, vi } = import.meta.vitest + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + beforeEach(() => { + vi.clearAllMocks() + }) + + const createResult = (categoryId: string): SearchResult => ({ + categoryId, + categoryLabel: categoryId, + groupId: 'defaultGroup', + features: { type: 'FeatureCollection', features: [] }, + }) + + const rejected = (reason: unknown): PromiseSettledResult => ({ + status: 'rejected', + reason, + }) + + test('collects the values of fulfilled promises and ignores rejected ones', () => { + const alpha = createResult('alpha') + const beta = createResult('beta') + const promises: PromiseSettledResult[] = [ + { status: 'fulfilled', value: alpha }, + rejected(new Error('boom')), + { status: 'fulfilled', value: beta }, + ] + + const results = getResultsFromPromises(promises, new AbortController()) + + expect(results).toEqual([alpha, beta]) + }) + + test('logs rejected reasons when the search was not aborted', () => { + const reason = new Error('boom') + + getResultsFromPromises([rejected(reason)], new AbortController()) + + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + expect.any(String), + 'An error occurred while sending a request: ', + reason + ) + }) + + test('does not log rejected reasons when the search was aborted', () => { + const abortController = new AbortController() + abortController.abort() + + getResultsFromPromises([rejected(new Error('boom'))], abortController) + + expect(errorSpy).not.toHaveBeenCalled() + }) +} diff --git a/src/plugins/addressSearch/utils/methodContainer.ts b/src/plugins/addressSearch/utils/methodContainer.ts new file mode 100644 index 0000000000..b0ccb01ab7 --- /dev/null +++ b/src/plugins/addressSearch/utils/methodContainer.ts @@ -0,0 +1,88 @@ +import type { SearchMethodFunction } from '../types' + +import bkg from '@/lib/getFeatures/bkg' +import mpapi from '@/lib/getFeatures/mpapi' +import nominatim from '@/lib/getFeatures/nominatim' +import { getWfsFeatures } from '@/lib/getFeatures/wfs' + +export function getMethodContainer() { + const methods = { bkg, mpapi, nominatim, wfs: getWfsFeatures } + + return { + registerSearchMethods: ( + additionalMethods: Record + ) => { + Object.entries(additionalMethods).forEach(([type, searchMethod]) => { + if (methods[type]) { + console.error( + `Method "${type}" already exists. Please choose a different name. Overrides are not allowed.` + ) + return + } + methods[type] = searchMethod + }) + }, + getSearchMethod: (type: string): SearchMethodFunction => { + const method = methods[type] + if (method) { + return method + } + throw new Error( + `The given type "${type}" does not define a valid searchMethod.` + ) + }, + } +} + +if (import.meta.vitest) { + const { beforeEach, expect, test, vi } = import.meta.vitest + + const customMethod: SearchMethodFunction = () => + Promise.resolve({ + type: 'FeatureCollection', + features: [], + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + beforeEach(() => { + vi.clearAllMocks() + }) + + test('resolves the default search methods', () => { + const container = getMethodContainer() + + expect(container.getSearchMethod('bkg')).toBe(bkg) + expect(container.getSearchMethod('mpapi')).toBe(mpapi) + expect(container.getSearchMethod('wfs')).toBe(getWfsFeatures) + }) + + test('registers and resolves an additional search method', () => { + const container = getMethodContainer() + + container.registerSearchMethods({ custom: customMethod }) + + expect(container.getSearchMethod('custom')).toBe(customMethod) + }) + + test('logs an error and does not override an existing method', () => { + const container = getMethodContainer() + + container.registerSearchMethods({ bkg: customMethod }) + + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledWith( + // enrichedConsole prepends a source-location argument + expect.any(String), + 'Method "bkg" already exists. Please choose a different name. Overrides are not allowed.' + ) + expect(container.getSearchMethod('bkg')).toBe(bkg) + }) + + test('throws for an unknown search method type', () => { + const container = getMethodContainer() + + expect(() => container.getSearchMethod('unknown')).toThrow( + 'The given type "unknown" does not define a valid searchMethod.' + ) + }) +} diff --git a/src/plugins/addressSearch/utils/searchResultSymbols.ts b/src/plugins/addressSearch/utils/searchResultSymbols.ts new file mode 100644 index 0000000000..ed38d8af56 --- /dev/null +++ b/src/plugins/addressSearch/utils/searchResultSymbols.ts @@ -0,0 +1,4 @@ +export default { + ERROR: Symbol('error'), + NO_SEARCH: Symbol('noSearch'), +} diff --git a/src/plugins/addressSearch/utils/strongTitleByInput.ts b/src/plugins/addressSearch/utils/strongTitleByInput.ts new file mode 100644 index 0000000000..05f25101f1 --- /dev/null +++ b/src/plugins/addressSearch/utils/strongTitleByInput.ts @@ -0,0 +1,33 @@ +export function strongTitleByInput(title: string, inputValue: string) { + const index = title.toLowerCase().indexOf(inputValue.toLowerCase()) + if (index === -1) { + return title + } + return ( + title.substring(0, index) + + '' + + title.substring(index, index + inputValue.length) + + '' + + title.substring(index + inputValue.length) + ) +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + test('wraps the matched part of the title in ', () => { + expect(strongTitleByInput('Hamburg', 'ham')).toBe( + 'Hamburg' + ) + }) + + test('matches case-insensitively but keeps the original casing', () => { + expect(strongTitleByInput('Hamburg', 'BUR')).toBe( + 'Hamburg' + ) + }) + + test('returns the title unchanged when there is no match', () => { + expect(strongTitleByInput('Hamburg', 'xyz')).toBe('Hamburg') + }) +} diff --git a/src/plugins/attributions/components/AttributionContent.ce.vue b/src/plugins/attributions/components/AttributionContent.ce.vue new file mode 100644 index 0000000000..af56c6d2ed --- /dev/null +++ b/src/plugins/attributions/components/AttributionContent.ce.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/plugins/attributions/components/AttributionsWrapper.ce.vue b/src/plugins/attributions/components/AttributionsWrapper.ce.vue new file mode 100644 index 0000000000..f409cc1b5b --- /dev/null +++ b/src/plugins/attributions/components/AttributionsWrapper.ce.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/plugins/attributions/components/NineLayoutAttributions.ce.vue b/src/plugins/attributions/components/NineLayoutAttributions.ce.vue new file mode 100644 index 0000000000..4b95600467 --- /dev/null +++ b/src/plugins/attributions/components/NineLayoutAttributions.ce.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/src/plugins/attributions/components/StandardLayoutAttributions.ce.vue b/src/plugins/attributions/components/StandardLayoutAttributions.ce.vue new file mode 100644 index 0000000000..467ae6243c --- /dev/null +++ b/src/plugins/attributions/components/StandardLayoutAttributions.ce.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/plugins/attributions/index.ts b/src/plugins/attributions/index.ts new file mode 100644 index 0000000000..4d169f2d0b --- /dev/null +++ b/src/plugins/attributions/index.ts @@ -0,0 +1,33 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/attributions + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { AttributionsPluginOptions } from './types' + +import AttributionsWrapper from './components/AttributionsWrapper.ce.vue' +import locales from './locales' +import { useAttributionsStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which adds attributions (copyright information) regarding all currently active layers. + * Additionally, static information can be added. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginAttributions( + options: AttributionsPluginOptions +): PluginContainer { + return { + id: PluginId, + component: AttributionsWrapper, + locales, + storeModule: useAttributionsStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/attributions/locales.ts b/src/plugins/attributions/locales.ts new file mode 100644 index 0000000000..c19818439c --- /dev/null +++ b/src/plugins/attributions/locales.ts @@ -0,0 +1,52 @@ +import type { Locale } from '@/core' + +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the attributions plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/attributions + */ +/* eslint-enable tsdoc/syntax */ + +/** + * German locales for attributions plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + button: { + title_close: 'Quellennachweis ausblenden', + title_open: 'Quellennachweis einblenden', + }, + sourceCode: + 'Quellcode lizenziert unter EUPL v1.2', + title: 'Quellennachweis', +} as const + +/** + * English locales for attributions plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + button: { + title_close: 'Hide Attributions', + title_open: 'Show Attributions', + }, + sourceCode: + 'Source code licensed under EUPL v1.2', + title: 'Attributions', +} 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/attributions/store.ts b/src/plugins/attributions/store.ts new file mode 100644 index 0000000000..413fb0e78d --- /dev/null +++ b/src/plugins/attributions/store.ts @@ -0,0 +1,134 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/attributions/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { StoreReference } from '@/core' +import type { Attribution } from './types' + +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useStoreWatcher } from '@/composables/useStoreWatcher' +import { useCoreStore } from '@/core/stores' + +import { buildMapInfo } from './utils/buildMapInfo' +import { formatAttributionText } from './utils/formatAttributionText' +import { getVisibleAttributions } from './utils/getVisibleAttributions' +import { getVisibleLayers } from './utils/getVisibleLayers' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for the attributions. + */ +/* eslint-enable tsdoc/syntax */ +export const useAttributionsStore = defineStore('plugins/attributions', () => { + const coreStore = useCoreStore() + + const attributions = ref([] as Attribution[]) + const layers = ref([]) + const windowIsOpen = ref(false) + + const configuration = computed( + () => coreStore.configuration.attributions || {} + ) + const mapInfo = computed(() => + buildMapInfo( + getVisibleAttributions(layers.value, attributions.value), + staticAttributions.value + ) + ) + const listenToChanges = computed( + () => configuration.value.listenToChanges || [] + ) + const mapInfoIcon = computed(() => + windowIsOpen.value + ? (configuration.value.icons?.close ?? 'kern-icon--chevron-forward') + : (configuration.value.icons?.open ?? 'kern-icon-fill--copyright') + ) + const renderType = computed( + () => configuration.value.renderType || 'independent' + ) + const staticAttributions = computed(() => + (configuration.value.staticAttributions || []).map(formatAttributionText) + ) + const windowWidth = computed(() => configuration.value.windowWidth || 500) + + useStoreWatcher(listenToChanges, updateLayers) + + function setupPlugin() { + const allLayers = coreStore.map.getLayers() + allLayers.on('add', updateLayers) + allLayers.on('add', updateAttributions) + allLayers.on('change', updateLayers) + coreStore.map.on('moveend', updateLayers) + + updateLayers() + updateAttributions() + + if ( + configuration.value.initiallyOpen && + renderType.value === 'independent' + ) { + windowIsOpen.value = true + } + } + + function teardownPlugin() { + const allLayers = coreStore.map.getLayers() + allLayers.un('add', updateLayers) + allLayers.un('add', updateAttributions) + allLayers.un('change', updateLayers) + coreStore.map.un('moveend', updateLayers) + } + + function updateAttributions() { + attributions.value = + configuration.value.layerAttributions === undefined + ? [] + : configuration.value.layerAttributions.map((a) => ({ + ...a, + title: formatAttributionText(a.title), + })) + } + + function updateLayers() { + layers.value = getVisibleLayers(coreStore.map.getLayers()) + } + + return { + /** + * Only relevant if the plugin controls the toggling of the window itself. + * @internal + */ + windowIsOpen, + + /** @internal */ + configuration, + + /** @internal */ + mapInfo, + + /** @internal */ + mapInfoIcon, + + /** @internal */ + renderType, + + /** @internal */ + windowWidth, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useAttributionsStore, import.meta.hot)) +} diff --git a/src/plugins/attributions/types.ts b/src/plugins/attributions/types.ts new file mode 100644 index 0000000000..714f13296d --- /dev/null +++ b/src/plugins/attributions/types.ts @@ -0,0 +1,133 @@ +import type { PluginOptions, StoreReference } from '@/core' + +export const PluginId = 'attributions' + +export interface Attribution { + /** + * ID of service the attribution relates to. + */ + id: string + + /** + * Attribution text or localization key. May contain HTML. + * The tags `` and `` are translated to the current year or month respectively. + * + * This configuration parameter is vulnerable to XSS attacks by design to allow + * the usage of HTML. Thus, no user input should be put here without further validation. + * + * @remarks + * The text will only be shown when the layer is visible. + */ + title: string +} + +/** + * Plugin options for attributions plugin. + * + * @example + * ```ts + * attributions: { + * initiallyOpen: false, + * windowWidth: 300, + * renderType: 'independent', + * listenToChanges: [ + * { + * key: 'zoom', + * }, + * { + * key: 'activeBackgroundId', + * plugin: 'layerChooser' + * }, + * { + * key: 'activeMaskIds', + * plugin: 'layerChooser' + * }, + * ], + * layerAttributions: [ + * { + * id: 'basemapId', + * title: 'Basemap', + * }, + * { + * id: 'subway', + * title: 'Subway', + * }, + * ], + * staticAttributions: [ + * 'Impressum', + * ], + * } + * ``` + * + * @remarks + * All parameters are optional. However, setting neither {@link layerAttributions} + * nor {@link staticAttributions} results in an empty window. + */ +export interface AttributionsPluginOptions extends PluginOptions { + /** + * Optional icon override. + */ + icons?: AttributionIcons + + /** + * Whether the information box is open by default. + * Only usable when {@link renderType} is set to `'independent'` and {@link MapConfiguration.layout | `layout`} + * is set to `'nineRegions'` OR {@link MapConfiguration.layout | `layout`} is set to `'standard'`. + * Otherwise, the IconMenu or the Footer handles this. + */ + initiallyOpen?: boolean + + /** + * List of attributions that are shown when the matching layer is visible. + */ + layerAttributions?: Attribution[] + + /** + * Store references to listen to for changes. + * Will update the currently visible layers depending on the current map state on changes to these values. + */ + listenToChanges?: StoreReference[] + + /** + * Defines whether this plugin (`'independent'`) or the IconMenu (`'iconMenu'`) + * should handle opening the information box or if a small information box + * should always be visible (`'footer'`). + * + * @remarks + * Only relevant if {@link MapConfiguration.layout | `layout`} is set to `'nineRegions'`, + * as it is otherwise expected to be rendered as part of the Footer. + * + * @defaultValue 'independent' + */ + renderType?: 'footer' | 'iconMenu' | 'independent' + + /** + * List of static attributions that are always shown. May contain HTML elements. + */ + staticAttributions?: string[] + + /** + * If {@link renderType} is set to `'independent'` and {@link MapConfiguration.layout | `layout`} + * is set to `'nineRegions'` OR {@link MapConfiguration.layout | `layout`} is set to `'standard'`, + * sets the width of the container of the attributions. + * + * @defaultValue 500 + */ + windowWidth?: number +} + +interface AttributionIcons { + /** + * Icon shown when pressing the button closes the attributions. + * + * @defaultValue 'kern-icon--chevron-forward' + */ + close?: string + + /** + * Icon shown when pressing the button opens the attributions. + * + * @defaultValue 'kern-icon--copyright' + */ + open?: string +} diff --git a/src/plugins/attributions/utils/buildMapInfo.ts b/src/plugins/attributions/utils/buildMapInfo.ts new file mode 100644 index 0000000000..22e3e36ea4 --- /dev/null +++ b/src/plugins/attributions/utils/buildMapInfo.ts @@ -0,0 +1,48 @@ +import type { Attribution } from '../types' + +/** + * Builds a string which contains the attributions for every visible Layer. + * + * @param infos - are all visible Layers. + * @param staticAttributions - list of attributions to always display. + * @returns an array of localizing string which contain all (copyright-)information of this Map. + */ +export function buildMapInfo( + infos: Attribution[], + staticAttributions: string[] = [] +) { + const text: string[] = [] + infos.forEach((attribution) => { + text.push(attribution.title) + }) + staticAttributions.forEach((attribution) => text.push(attribution)) + text.push('sourceCode') + return text +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + const attribution = (id: string, title: string): Attribution => ({ + id, + title, + }) + + test('lists layer titles followed by the source code entry', () => { + const infos = [attribution('a', 'Thea'), attribution('b', 'Beta')] + + expect(buildMapInfo(infos)).toEqual(['Thea', 'Beta', 'sourceCode']) + }) + + test('appends static attributions between layer titles and the source code entry', () => { + const infos = [attribution('a', 'Thea')] + const staticAttributions = ['Static 1', 'Static 2'] + + expect(buildMapInfo(infos, staticAttributions)).toEqual([ + 'Thea', + 'Static 1', + 'Static 2', + 'sourceCode', + ]) + }) +} diff --git a/src/plugins/attributions/utils/formatAttributionText.ts b/src/plugins/attributions/utils/formatAttributionText.ts new file mode 100644 index 0000000000..c8ccfea9f7 --- /dev/null +++ b/src/plugins/attributions/utils/formatAttributionText.ts @@ -0,0 +1,39 @@ +/** + * Formats the attribution-string and replaces with the current year and + * with the current month. + * + * @param text - the attribution text defined in the {@link MapConfiguration}. + * @returns a formatted string, which can be displayed in the Attributions. + */ +export function formatAttributionText(text: string) { + const now = new Date() + return text + .replaceAll('', now.getFullYear().toString()) + .replaceAll('', `${now.getMonth() + 1}`.padStart(2, '0')) +} + +if (import.meta.vitest) { + const { afterEach, beforeEach, expect, test, vi } = import.meta.vitest + + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('replaces all and zero-padded placeholders', () => { + vi.setSystemTime(new Date('2026-04-20')) + + expect(formatAttributionText('© – updated /')).toBe( + '© 2026 – updated 04/2026' + ) + }) + + test('keeps a two-digit month unpadded', () => { + vi.setSystemTime(new Date('2026-12-14')) + + expect(formatAttributionText('')).toBe('12') + }) +} diff --git a/src/plugins/attributions/utils/getVisibleAttributions.ts b/src/plugins/attributions/utils/getVisibleAttributions.ts new file mode 100644 index 0000000000..743d63d956 --- /dev/null +++ b/src/plugins/attributions/utils/getVisibleAttributions.ts @@ -0,0 +1,40 @@ +import type { Attribution } from '../types' + +/** + * Checks every layer (passed in layers) for visibility and returns an {@link Attribution}[] + * for every visible Layer. + * + * @param layers - is an array of LayerIDs (number[]) for visible Layers. + * @param attributions - is an array of all Attributions for this Map. + * @returns an array for all attributions whose id matches the id of a visible layer. + */ +export function getVisibleAttributions( + layers: string[], + attributions: Attribution[] +) { + const visibleAttributions: Attribution[] = [] + attributions.forEach((attribution) => { + if (layers.includes(attribution.id)) { + visibleAttributions.push(attribution) + } + }) + return visibleAttributions +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + const attribution = (id: string): Attribution => ({ + id, + title: `title-${id}`, + }) + + test('returns only attributions whose id matches a visible layer', () => { + const attributions = [attribution('a'), attribution('b'), attribution('c')] + + expect(getVisibleAttributions(['a', 'c'], attributions)).toEqual([ + attribution('a'), + attribution('c'), + ]) + }) +} diff --git a/src/plugins/attributions/utils/getVisibleLayers.ts b/src/plugins/attributions/utils/getVisibleLayers.ts new file mode 100644 index 0000000000..1afeea0726 --- /dev/null +++ b/src/plugins/attributions/utils/getVisibleLayers.ts @@ -0,0 +1,39 @@ +import type { Collection } from 'ol' +import type BaseLayer from 'ol/layer/Base' + +/** + * Looks for all Layers that are currently visible. + * + * @param layers - contains all Layers + * @returns an array of LayerIDs. + * + * @remarks + * Only layers added through the services include the id property. + */ +export function getVisibleLayers(layers: Collection) { + return layers + .getArray() + .filter((layer) => layer.getVisible() && layer.get('id')) + .map((layer) => layer.get('id')) +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + const { Collection } = await import('ol') + + const createLayer = (visible: boolean, id?: string) => + ({ + getVisible: () => visible, + get: (key: string) => (key === 'id' ? id : undefined), + }) as unknown as BaseLayer + + test('returns ids of visible layers that define an id', () => { + const layers = new Collection([ + createLayer(true, 'visible-with-id'), + createLayer(true), + createLayer(false, 'hidden-with-id'), + ]) + + expect(getVisibleLayers(layers)).toEqual(['visible-with-id']) + }) +} diff --git a/src/plugins/export/components/ExportUI.ce.vue b/src/plugins/export/components/ExportUI.ce.vue new file mode 100644 index 0000000000..9e0578ef17 --- /dev/null +++ b/src/plugins/export/components/ExportUI.ce.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/plugins/export/index.ts b/src/plugins/export/index.ts new file mode 100644 index 0000000000..a47e5cb320 --- /dev/null +++ b/src/plugins/export/index.ts @@ -0,0 +1,35 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/export + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { ExportPluginOptions } from './types' + +import component from './components/ExportUI.ce.vue' +import locales from './locales' +import { useExportStore } from './store' +import { PluginId } from './types' + +/** + * The Export plugin allows making screenshots of the currently visible map. + * Please note that the plugin must be added initially, before any layers are + * loaded, or the canvas will no longer be printable due to potential security + * issues. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginExport( + options: ExportPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + storeModule: useExportStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/export/locales.ts b/src/plugins/export/locales.ts new file mode 100644 index 0000000000..b372dbdc37 --- /dev/null +++ b/src/plugins/export/locales.ts @@ -0,0 +1,53 @@ +import type { Locale } from '@/core' + +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the export plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/export + */ +/* eslint-enable tsdoc/syntax */ + +/** + * German locales for export plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + button: { + tooltip: { + open: 'Kartenexportoptionen öffnen', + close: 'Kartenexportoptionen schließen', + format: 'Karte als {{format}} exportieren', + }, + }, + error: 'Beim Exportieren der Karte ist ein Fehler aufgetreten.', +} as const + +/** + * English locales for export plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + button: { + tooltip: { + open: 'Open map export options', + close: 'Close map export options', + format: 'Export map as {{format}}', + }, + }, + error: 'An error occurred while exporting the map.', +} as const + +const locales: Locale[] = [ + { + type: 'de', + resources: resourcesDe, + }, + { + type: 'en', + resources: resourcesEn, + }, +] + +export default locales diff --git a/src/plugins/export/store.ts b/src/plugins/export/store.ts new file mode 100644 index 0000000000..fcf82c2264 --- /dev/null +++ b/src/plugins/export/store.ts @@ -0,0 +1,157 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/export/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Interaction } from 'ol/interaction' +import type { ExportFormat } from './types' + +import { t } from 'i18next' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { notifyUser } from '@/lib/notifyUser' + +import { EXPORT_FORMATS, PluginId } from './types' +import { convertToPdf } from './utils/convertToPdf' +import { CrossOriginMonkey } from './utils/CrossOriginMonkey' +import { downloadAsImage } from './utils/downloadAsImage' +import { getCanvasFromMap } from './utils/getCanvasFromMap' +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for export functionality. + */ +/* eslint-enable tsdoc/syntax */ +export const useExportStore = defineStore('plugins/export', () => { + const coreStore = useCoreStore() + const exportedMap = ref('') + + const configuration = computed(() => coreStore.configuration.export ?? {}) + const download = computed(() => configuration.value.download ?? false) + const layoutTag = computed(() => configuration.value.layoutTag) + const availableFormats = computed(() => { + const validFormats = + configuration.value.formats?.filter((format) => { + const valid = EXPORT_FORMATS.includes(format) + + if (!valid) { + console.warn( + `Erroneous export.formats entry '${format}' configured. It was filtered out. Please verify configuration. Allowed formats are: '${EXPORT_FORMATS.join("', '")}'.` + ) + } + + return valid + }) ?? ([] as ExportFormat[]) + + return validFormats.length > 0 ? validFormats : (['png'] as ExportFormat[]) + }) + + function exportAs(type: ExportFormat) { + let pausedInteractions: Array = [] + + try { + if (!availableFormats.value.includes(type)) { + throw new Error(`Export format not allowed: "${type}"`) + } + + pausedInteractions = coreStore.map + .getInteractions() + .getArray() + .filter((interaction) => interaction.getActive()) + + pausedInteractions.forEach((interaction) => { + interaction.setActive(false) + }) + + coreStore.map.once('postrender', function () { + const map = coreStore.map + const canvas = getCanvasFromMap(map) + const base64String = canvas.toDataURL( + type === 'png' ? 'image/png' : 'image/jpeg' + ) + + if (!base64String) { + throw new Error('Failed to convert canvas to base64 string.') + } + + if (type === 'pdf') { + const { pdfSrc, jsPdf } = convertToPdf( + base64String, + canvas.width, + canvas.height + ) + exportedMap.value = pdfSrc + + if (download.value) { + jsPdf.save('polar-map.pdf') + } + } else { + exportedMap.value = base64String + if (download.value) { + downloadAsImage(base64String, type) + } + } + }) + + coreStore.map.renderSync() + } catch (error) { + console.error(error) + notifyUser('error', () => + t(($) => $.error, { + ns: PluginId, + }) + ) + throw error + } finally { + pausedInteractions.forEach((interaction) => { + interaction.setActive(true) + }) + } + } + + const monkey = new CrossOriginMonkey() + + function setupPlugin() { + monkey.startBusiness(coreStore.map) + } + + function teardownPlugin() { + monkey.stopBusiness(coreStore.map) + } + + return { + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + + /** + * Configured valid formats or, if none are given or all are invalid, fallback. + * @alpha + */ + availableFormats, + + /** + * Configured layout tag, if given. + * @alpha + */ + layoutTag, + + /** + * Initiates the export process for the specified format. Throws with an + * error description if something goes wrong. + */ + exportAs, + + /** + * Holds the exported map as a base64-encoded string after export. + * Content depends on chosen export format. Initially `''`. + */ + exportedMap, + } +}) diff --git a/src/plugins/export/types.ts b/src/plugins/export/types.ts new file mode 100644 index 0000000000..25419400ba --- /dev/null +++ b/src/plugins/export/types.ts @@ -0,0 +1,34 @@ +import type { PluginOptions } from '@/core' + +export const PluginId = 'export' + +/** + * Supported export formats. + */ +export const EXPORT_FORMATS = ['jpg', 'jpeg', 'pdf', 'png'] as const + +export type ExportFormat = (typeof EXPORT_FORMATS)[number] + +export interface ExportPluginOptions extends PluginOptions { + /** + * If `true`, the screenshot will be both stored in the store and offered as a + * download to the user. If `false`, it will only be stored – in that case, the + * leading application must show a fitting indication (e.g. by firing a + * toast or showing the screenshot) to the user. + * + * @defaultValue `false` + */ + download?: boolean + + /** + * Defines the export formats to be offered in the export menu. + * + * @defaultValue `['png']` + * + * @remarks + * 'jpg' and 'jpeg' are effectively the same format, + * so you can provide whatever you prefer. Providing both is not recommended + * due to the confusing nature of having both options. + */ + formats?: ExportFormat[] +} diff --git a/src/plugins/export/utils/CrossOriginMonkey.ts b/src/plugins/export/utils/CrossOriginMonkey.ts new file mode 100644 index 0000000000..051b367ce0 --- /dev/null +++ b/src/plugins/export/utils/CrossOriginMonkey.ts @@ -0,0 +1,61 @@ +import type { Map } from 'ol' + +import { ImageWMS } from 'ol/source' + +/** + * Provides a monkey patch for cross-origin shenanigans. Please mind that this + * solution is not compatible with some kinds of login. Should a case arise + * where both an afflicted kind of login and the export plugin are to be used, + * another solution must be found to this. + */ +export class CrossOriginMonkey { + /** additional element for prototype chain to shadow addLayer */ + shadowingPrototype + + #setAllCrossOrigins = (map: Map, crossOrigin: 'anonymous' | null) => + Object.getPrototypeOf(this.shadowingPrototype) + .getLayers.call(map) + .getArray() + .forEach((layer) => { + const source = layer.getSource() + + if (!source) { + return + } + + // Brittle code, ready to break on any ol update and produce a nice bug. (づ๑•ᴗ•๑)づ🐞 + if (source instanceof ImageWMS) { + // @ts-expect-error | Set private param for ol class ImageWMS. + source.crossOrigin_ = crossOrigin + } else { + source.crossOrigin = crossOrigin + } + + // trigger update + layer.setSource(source) + // NOTE(sende): oh no it doesn't untaint the canvas, so we can't print if it's added too late! ;_; + }) + + startBusiness(map: Map) { + this.shadowingPrototype = Object.create(Object.getPrototypeOf(map)) + + // Shadow Monkey patch + this.shadowingPrototype.addLayer = (...parameters) => { + Object.getPrototypeOf(this.shadowingPrototype).addLayer.call( + map, + ...parameters + ) + this.#setAllCrossOrigins(map, 'anonymous') + } + + Object.setPrototypeOf(map, this.shadowingPrototype) + + // to get all already existing layers (in case plugin was added later) + this.#setAllCrossOrigins(map, 'anonymous') + } + + stopBusiness(map: Map) { + Object.setPrototypeOf(map, Object.getPrototypeOf(this.shadowingPrototype)) + this.#setAllCrossOrigins(map, null) + } +} diff --git a/src/plugins/export/utils/convertToPdf.ts b/src/plugins/export/utils/convertToPdf.ts new file mode 100644 index 0000000000..9d00541774 --- /dev/null +++ b/src/plugins/export/utils/convertToPdf.ts @@ -0,0 +1,42 @@ +import { jsPDF as JSpdf } from 'jspdf' + +type DINmension = 'a0' | 'a1' | 'a2' | 'a3' | 'a4' | 'a5' + +const dimensions: Record = { + a0: [1189, 841], + a1: [841, 594], + a2: [594, 420], + a3: [420, 297], + a4: [297, 210], + a5: [210, 148], +} + +export const convertToPdf = ( + src: string, + imgWidth: number, + imgHeight: number +) => { + // NOTE: when supporting more formats, scale map accordingly + const format = 'a4' + const dimension = dimensions[format] + const jsPdf = new JSpdf('landscape', undefined, format) + + // Fit image proportionally onto the page + const pageWidth = dimension[0] + const pageHeight = dimension[1] + const ratio = imgWidth / imgHeight + let drawWidth = pageWidth + let drawHeight = pageWidth / ratio + if (drawHeight > pageHeight) { + drawHeight = pageHeight + drawWidth = pageHeight * ratio + } + const x = (pageWidth - drawWidth) / 2 + const y = (pageHeight - drawHeight) / 2 + jsPdf.addImage(src, 'JPEG', x, y, drawWidth, drawHeight) + + return { + pdfSrc: jsPdf.output('datauristring'), + jsPdf, + } +} diff --git a/src/plugins/export/utils/downloadAsImage.ts b/src/plugins/export/utils/downloadAsImage.ts new file mode 100644 index 0000000000..47981c3701 --- /dev/null +++ b/src/plugins/export/utils/downloadAsImage.ts @@ -0,0 +1,11 @@ +import type { ExportFormat } from '../types' + +export const downloadAsImage = (base64String: string, type: ExportFormat) => { + const link = document.createElement('a') + link.download = `polar-map.${type}` + link.href = base64String + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} diff --git a/src/plugins/export/utils/getCanvasFromMap.ts b/src/plugins/export/utils/getCanvasFromMap.ts new file mode 100644 index 0000000000..66a004a146 --- /dev/null +++ b/src/plugins/export/utils/getCanvasFromMap.ts @@ -0,0 +1,40 @@ +import type { Map } from 'ol' + +export function getCanvasFromMap(map: Map) { + const viewport = map.getViewport() + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d') + + if (!context) { + throw new Error('2D context not available') + } + + canvas.width = viewport.clientWidth + canvas.height = viewport.clientHeight + + const layerCanvases: NodeListOf = + viewport.querySelectorAll('.ol-layer canvas') + layerCanvases.forEach((layerCanvas) => { + const canvas = layerCanvas + if (canvas.width > 0) { + // use layer opacity for printing + const opacity = (canvas.parentNode as HTMLElement).style.opacity + context.globalAlpha = opacity === '' ? 1 : Number(opacity) + + // Get the transform parameters from the style's transform matrix + const transform = canvas.style.transform + const matrixMatch = transform.match(/^matrix\(([^(]*)\)$/) + if (matrixMatch && matrixMatch[1]) { + const matrix = matrixMatch[1].split(',').map(Number) + context.setTransform( + ...(matrix as [number, number, number, number, number, number]) + ) + } + + context.drawImage(canvas, 0, 0) + } else { + console.error('Canvas width is 0, remains effectively empty.') + } + }) + return canvas +} diff --git a/src/plugins/filter/components/FilterCategory.ce.vue b/src/plugins/filter/components/FilterCategory.ce.vue new file mode 100644 index 0000000000..67c02e9f13 --- /dev/null +++ b/src/plugins/filter/components/FilterCategory.ce.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/plugins/filter/components/FilterLayerChooser.ce.vue b/src/plugins/filter/components/FilterLayerChooser.ce.vue new file mode 100644 index 0000000000..0c3cf90a01 --- /dev/null +++ b/src/plugins/filter/components/FilterLayerChooser.ce.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/plugins/filter/components/FilterSection.ce.vue b/src/plugins/filter/components/FilterSection.ce.vue new file mode 100644 index 0000000000..b3ce91d250 --- /dev/null +++ b/src/plugins/filter/components/FilterSection.ce.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/plugins/filter/components/FilterSectionNineRegions.ce.vue b/src/plugins/filter/components/FilterSectionNineRegions.ce.vue new file mode 100644 index 0000000000..f456154a96 --- /dev/null +++ b/src/plugins/filter/components/FilterSectionNineRegions.ce.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/plugins/filter/components/FilterSectionStandard.ce.vue b/src/plugins/filter/components/FilterSectionStandard.ce.vue new file mode 100644 index 0000000000..8e79b54d5c --- /dev/null +++ b/src/plugins/filter/components/FilterSectionStandard.ce.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/src/plugins/filter/components/FilterTime.ce.vue b/src/plugins/filter/components/FilterTime.ce.vue new file mode 100644 index 0000000000..dada98c687 --- /dev/null +++ b/src/plugins/filter/components/FilterTime.ce.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/plugins/filter/components/FilterUI.ce.vue b/src/plugins/filter/components/FilterUI.ce.vue new file mode 100644 index 0000000000..ab3e811288 --- /dev/null +++ b/src/plugins/filter/components/FilterUI.ce.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/src/plugins/filter/components/FilterUI.spec.ts b/src/plugins/filter/components/FilterUI.spec.ts new file mode 100644 index 0000000000..93189ee749 --- /dev/null +++ b/src/plugins/filter/components/FilterUI.spec.ts @@ -0,0 +1,111 @@ +import type { VueWrapper } from '@vue/test-utils' + +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { test as _test, assert, expect, vi } from 'vitest' +import { computed, nextTick } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { mockedT } from '@/test/utils/mockI18n' + +import { useFilterStore } from '../store' +import FilterUI from './FilterUI.ce.vue' + +/* eslint-disable no-empty-pattern */ +const test = _test.extend<{ + wrapper: VueWrapper + coreStore: ReturnType + store: ReturnType +}>({ + wrapper: async ({}, use) => { + vi.mock('i18next', () => ({ + t: (keyFn, opts) => mockedT(keyFn, opts), + })) + const wrapper = mount(FilterUI, { + attachTo: document.body, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + mocks: { + $t: mockedT, + }, + }, + }) + await use(wrapper) + wrapper.unmount() + }, + coreStore: async ({}, use) => { + const store = useCoreStore() + await use(store) + }, + store: async ({}, use) => { + const store = useFilterStore() + await use(store) + }, +}) +/* eslint-enable no-empty-pattern */ + +test('Component transfers category and time filters to the store', async ({ + wrapper, + coreStore, + store, +}) => { + // @ts-expect-error | This is for testing + coreStore.configuration = { + filter: { + layers: { + one: { + categories: [ + { + targetProperty: 'pet', + knownValues: ['cat', 'dog'], + selectAll: true, + }, + ], + time: { + targetProperty: 'time', + freeSelection: 'until', + last: [1], + }, + }, + }, + }, + } + let selectionValue = ['cat', 'dog'] + const setSpy = vi.fn((value: string[]) => { + selectionValue = value + }) + // @ts-expect-error | This is for testing + store.categories = computed(() => [ + { + targetProperty: 'pet', + knownValues: ['cat', 'dog'], + selectAll: true, + get selection() { + return selectionValue + }, + set selection(v: string[]) { + setSpy(v) + }, + }, + ]) + + await nextTick() + + const onlyCat = wrapper + .findAll('label') + .find( + (lbl) => lbl.text() === '$t(filter:layer.one.category.pet.knownValue.cat)' + ) + assert(onlyCat !== undefined, 'Could not find cat button') + await onlyCat.trigger('click') + + expect(setSpy).toHaveBeenCalledExactlyOnceWith(['dog']) + + const yesterday = wrapper + .findAll('label') + .find((lbl) => lbl.text() === '$t(filter:time.last_1)') + assert(yesterday !== undefined, 'Could not find yesterday button') + await yesterday.trigger('click') + + expect(store.timeModel).toEqual('last-1') +}) diff --git a/src/plugins/filter/index.ts b/src/plugins/filter/index.ts new file mode 100644 index 0000000000..db30f9d792 --- /dev/null +++ b/src/plugins/filter/index.ts @@ -0,0 +1,33 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/filter + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { FilterPluginOptions } from './types' + +import component from './components/FilterUI.ce.vue' +import locales from './locales' +import { useFilterStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which allows to filter arbitrary configurable vector layers by their properties. + * + * @returns Plugin for use with {@link addPlugin} + */ +export default function pluginFilter( + options: FilterPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + icon: 'kern-icon--filter-alt', + storeModule: useFilterStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/filter/locales.ts b/src/plugins/filter/locales.ts new file mode 100644 index 0000000000..224ac89d5b --- /dev/null +++ b/src/plugins/filter/locales.ts @@ -0,0 +1,73 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the filter plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/filter + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +/** + * German locales for filter plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + category: { + deselectAll: 'Alle an-/abwählen', + }, + time: { + header: 'Zeitraum', + noRestriction: 'Keine Einschränkung', + last_zero: 'Heute', + last_one: 'Der letzte Tag', + last_other: 'Die letzten {{count}} Tage', + next_zero: 'Heute', + next_one: 'Der nächste Tag', + next_other: 'Die nächsten {{count}} Tage', + chooseTimeFrame: 'Zeitraum wählen', + }, +} as const + +/** + * English locales for filter plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + category: { + deselectAll: 'De-/select all', + }, + time: { + header: 'Time frame', + noRestriction: 'No restriction', + last_zero: 'Today', + last_one: 'The last day', + last_other: 'The last {{count}} days', + next_zero: 'Today', + next_one: 'The next day', + next_other: 'The next {{count}} days', + chooseTimeFrame: 'Choose time frame', + }, +} as const + +/** + * Filter 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/filter/store.ts b/src/plugins/filter/store.ts new file mode 100644 index 0000000000..4524aa6c8a --- /dev/null +++ b/src/plugins/filter/store.ts @@ -0,0 +1,158 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/filter/store + */ +/* eslint-enable tsdoc/syntax */ + +import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia' +import { watch } from 'vue' + +import { getVectorSource } from '@/lib/getVectorSource' + +import { useFilterMainStore } from './stores/main' +import { useFilterTimeStore } from './stores/time' +import { updateFeatureVisibility } from './utils/updateFeatureVisibility' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for filtering features. + */ +/* eslint-enable tsdoc/syntax */ +export const useFilterStore = defineStore('plugins/filter', () => { + const filterMainStore = useFilterMainStore() + const filterMainStoreRefs = storeToRefs(filterMainStore) + const filterTimeStore = useFilterTimeStore() + const filterTimeStoreRefs = storeToRefs(filterTimeStore) + + const teardownCallbacks = [] as (() => void)[] + + function setupPlugin() { + filterMainStore.filteredLayers.forEach((layer) => { + const source = getVectorSource(layer) + const callback = () => { + updateFeatureVisibility( + source, + filterMainStore.state[layer.get('id')] ?? { knownValues: {} } + ) + } + source.on('featuresloadend', callback) + teardownCallbacks.push(() => { + source.un('featuresloadend', callback) + }) + teardownCallbacks.push( + watch( + () => filterMainStore.state[layer.get('id')], + () => { + callback() + }, + { deep: true, immediate: true } + ) + ) + }) + } + + function teardownPlugin() { + teardownCallbacks.forEach((callback) => { + callback() + }) + } + + return { + /** + * Flat list of filterable layers. + * + * @alpha + */ + layers: filterMainStoreRefs.layers, + + /** + * ID of the selected layer. + * + * @alpha + */ + selectedLayerId: filterMainStoreRefs.selectedLayerId, + + /** + * Information on the selected layer. + * + * @alpha + */ + selectedLayer: filterMainStoreRefs.selectedLayer, + + /** + * Configuration of the selected layer w.r.t. filter. + * If no layer is selected, the configuration is an empty object. + * + * @alpha + */ + selectedLayerConfiguration: filterMainStoreRefs.selectedLayerConfiguration, + + /** + * State of the selected layer w.r.t. filter. + * If no layer is selected, the state is `null`. + * + * @alpha + */ + selectedLayerState: filterMainStoreRefs.selectedLayerState, + + /** + * `true` if the selected layer allows filtering for time. + * + * @alpha + */ + selectedLayerHasTimeFilter: filterMainStoreRefs.selectedLayerHasTimeFilter, + + /** + * Categories of the selected layer, each enriched with a writable + * `selection` computed (usable with `v-model`). + * + * @alpha + */ + categories: filterMainStoreRefs.categories, + + /** + * For a given category, select all values if at least some are not selected yet, or de-select all values otherwise. + * + * @param category - Category to toggle value's selection states + * @alpha + */ + selectOrDeselectAllFromCategory: filterMainStore.selectOrDeselectAll, + + /** + * @alpha + */ + timeModel: filterTimeStoreRefs.model, + + /** + * @alpha + */ + timeStart: filterTimeStoreRefs.customModelStart, + + /** + * @alpha + */ + timeEnd: filterTimeStoreRefs.customModelEnd, + + /** + * @alpha + */ + timeConstraints: filterTimeStoreRefs.timeConstraints, + + /** + * @alpha + */ + timeItems: filterTimeStoreRefs.items, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterStore, import.meta.hot)) +} diff --git a/src/plugins/filter/stores/main.ts b/src/plugins/filter/stores/main.ts new file mode 100644 index 0000000000..0d825ef3ff --- /dev/null +++ b/src/plugins/filter/stores/main.ts @@ -0,0 +1,189 @@ +import type { + Category, + CategoryWithSelection, + FilterConfiguration, + FilterPluginOptions, + FilterState, +} from '../types' + +import { union } from 'es-toolkit' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from '../types' +import { + expandValue, + flattenValue, + getAllTechnicalValues, +} from '../utils/categoryValues' +export const useFilterMainStore = defineStore('plugins/filter/main', () => { + const coreStore = useCoreStore() + + const configuration = computed( + () => + (coreStore.configuration[PluginId] ?? { + layers: {}, + }) as FilterPluginOptions + ) + + const state = ref>({}) + + const layers = computed(() => + Object.entries(configuration.value.layers).map( + ([layerId, filterConfiguration]) => ({ + layerId, + layerConfiguration: coreStore.getLayerMapConfiguration(layerId), + filterConfiguration, + }) + ) + ) + + const selectedLayerId = ref(null) + watch( + () => configuration.value.layers, + (layers) => { + Object.keys(layers).forEach((layerId) => { + state.value[layerId] ??= { knownValues: {} } + }) + selectedLayerId.value = Object.keys(layers)[0] || '' + }, + { immediate: true, deep: true } + ) + + const selectedLayer = computed( + () => + layers.value.find((layer) => layer.layerId === selectedLayerId.value) ?? + null + ) + + const selectedLayerConfiguration = computed( + () => + (selectedLayerId.value + ? configuration.value.layers[selectedLayerId.value] + : {}) as FilterConfiguration + ) + + const selectedLayerState = computed( + () => + (selectedLayerId.value + ? state.value[selectedLayerId.value] + : null) as FilterState | null + ) + + const selectedLayerHasTimeFilter = computed( + () => + selectedLayerConfiguration.value.time?.last || + selectedLayerConfiguration.value.time?.next || + selectedLayerConfiguration.value.time?.freeSelection + ) + + const filteredLayers = computed(() => + coreStore.map + .getAllLayers() + .filter((layer) => + Object.keys(configuration.value.layers).includes(layer.get('id')) + ) + ) + + /** + * Initializes the filter state for all categories of the selected layer. + * Runs whenever the selected layer (or its category configuration) changes, + * so layers or categories configured at runtime are covered as well. + * Existing selections persist: only targetProperties not yet present are + * seeded, so previously deselected values are not re-added on layer switch. + */ + watch( + () => selectedLayerConfiguration.value.categories, + (categories) => { + const layerState = selectedLayerState.value + if (!categories || !layerState) { + return + } + // Aggregate default values per targetProperty first, so multiple + // categories sharing a targetProperty are merged instead of the + // later one overwriting the earlier. + const defaults: Record = {} + for (const category of categories) { + defaults[category.targetProperty] = union( + defaults[category.targetProperty] ?? [], + getAllTechnicalValues(category) + ) + } + // Only seed targetProperties that are not present yet, so existing + // (de)selections persist across layer switches. + for (const [targetProperty, values] of Object.entries(defaults)) { + layerState.knownValues[targetProperty] ??= values + } + }, + { immediate: true } + ) + + const categories = computed( + () => + selectedLayerConfiguration.value.categories?.map((category) => ({ + ...category, + get selection() { + const stateValues = + selectedLayerState.value?.knownValues[category.targetProperty] ?? [] + return category.knownValues + .filter((entry) => + expandValue(entry).values.every((v) => stateValues.includes(v)) + ) + .map(flattenValue) + }, + set selection(selectedKeys: string[]) { + const layerState = selectedLayerState.value as FilterState + const allMyValues = getAllTechnicalValues(category) + const newMyValues = selectedKeys.flatMap((key) => { + const entry = category.knownValues.find( + (v) => flattenValue(v) === key + ) + return entry ? expandValue(entry).values : [key] + }) + const current = layerState.knownValues[category.targetProperty] ?? [] + const othersValues = current.filter((v) => !allMyValues.includes(v)) + layerState.knownValues[category.targetProperty] = union( + othersValues, + newMyValues + ) + }, + })) ?? [] + ) + + function selectOrDeselectAll(category: Category) { + const layerState = selectedLayerState.value as FilterState + const stateValues = layerState.knownValues[category.targetProperty] ?? [] + const allMyValues = getAllTechnicalValues(category) + const allSelected = allMyValues.every((v) => stateValues.includes(v)) + if (allSelected) { + layerState.knownValues[category.targetProperty] = stateValues.filter( + (v) => !allMyValues.includes(v) + ) + } else { + layerState.knownValues[category.targetProperty] = union( + stateValues, + allMyValues + ) + } + } + + return { + categories, + configuration, + state, + layers, + selectedLayerId, + selectedLayer, + selectedLayerConfiguration, + selectedLayerState, + selectedLayerHasTimeFilter, + filteredLayers, + selectOrDeselectAll, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterMainStore, import.meta.hot)) +} diff --git a/src/plugins/filter/stores/time.ts b/src/plugins/filter/stores/time.ts new file mode 100644 index 0000000000..00755c2b71 --- /dev/null +++ b/src/plugins/filter/stores/time.ts @@ -0,0 +1,151 @@ +import type { Time } from '../types' + +import { t } from 'i18next' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from '../types' +import { useFilterMainStore } from './main' + +type TimeModel = 'all' | 'custom' | `last-${string}` | `next-${string}` + +export const useFilterTimeStore = defineStore('plugins/filter/time', () => { + const minDate = new Date(-8640000000000000) + const maxDate = new Date(8640000000000000) + + const coreStore = useCoreStore() + const filterMainStore = useFilterMainStore() + + const configuration = computed( + () => filterMainStore.selectedLayerConfiguration.time || ({} as Time) + ) + + const targetProperty = computed( + () => configuration.value.targetProperty || '' + ) + const pattern = computed(() => configuration.value.pattern || 'YYYY-MM-DD') + + const model = ref('all') + const customModelStart = ref(null) + const customModelEnd = ref(null) + + /** Reset time filter selection when the active layer changes. */ + watch( + () => filterMainStore.selectedLayerId, + () => { + model.value = 'all' + customModelStart.value = null + customModelEnd.value = null + } + ) + + watch( + [model, customModelStart, customModelEnd], + ([value, start, end]) => { + const layerState = filterMainStore.selectedLayerState + if (!layerState || !targetProperty.value) { + return + } + layerState.timeSpan ??= {} + + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + let from: Date + let until: Date + + if (value === 'all') { + from = minDate + until = maxDate + } else if (value === 'custom') { + from = start || minDate + until = end + ? new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1) + : maxDate + } else if (value.startsWith('last-')) { + const offset = Number(value.substring(5)) + from = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - offset + ) + until = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + 1 + ) + } else { + const offset = Number(value.substring(5)) + from = today + until = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + offset + 1 + ) + } + + layerState.timeSpan[targetProperty.value] = { + from, + until, + pattern: pattern.value, + } + }, + { immediate: true } + ) + + const timeConstraints = computed( + () => + ({ + from: { + min: new Date(), + }, + until: { + max: new Date(), + }, + })[ + filterMainStore.selectedLayerConfiguration.time?.freeSelection || '' + ] || {} + ) + + const items = computed(() => { + // This reactive value needs to recompute on language changes. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + coreStore.language + + return [ + { + value: 'all', + label: t(($) => $.time.noRestriction, { ns: PluginId }), + }, + ...(configuration.value.last?.map((offset) => ({ + value: `last-${offset}`, + label: t(($) => $.time.last, { count: offset, ns: PluginId }), + })) || []), + ...(configuration.value.next?.map((offset) => ({ + value: `next-${offset}`, + label: t(($) => $.time.next, { count: offset, ns: PluginId }), + })) || []), + ...(configuration.value.freeSelection + ? [ + { + value: 'custom', + label: t(($) => $.time.chooseTimeFrame, { ns: PluginId }), + }, + ] + : []), + ] + }) + + return { + model, + customModelStart, + customModelEnd, + timeConstraints, + items, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useFilterTimeStore, import.meta.hot)) +} diff --git a/src/plugins/filter/types.ts b/src/plugins/filter/types.ts new file mode 100644 index 0000000000..8b97635074 --- /dev/null +++ b/src/plugins/filter/types.ts @@ -0,0 +1,346 @@ +import type { Icon, PluginOptions } from '@/core' + +/** + * Plugin identifier. + */ +export const PluginId = 'filter' + +/** + * One or more values grouped to a single entry of a category-based filter configuration for a layer. + * + * @example + * ```ts + * { + * key: 'myHomeIsMyCastle', + * values: ['home', 'castle'], + * icon: 'kern-icon--home', + * } + * ``` + */ +export interface CategoryValue { + /** + * Key that is used for localization of the entry. + */ + key: string + + /** + * Technical values of the feature property. + */ + values: string[] + + /** + * An icon that is assigned to the value for filtering. + * + * Only usable if {@link MapConfiguration.layout | layout} is set to `'standard'`. + */ + icon?: Icon +} + +/** + * Category-based filter configuration for a layer. + * + * @example + * ```ts + * { + * targetProperty: 'favouriteIceCream', + * knownValues: ['chocolate', 'vanilla', 'strawberry'], + * selectAll: true, + * } + * ``` + * + * This example configuration will add these checkboxes: + * + * ``` + * ▢ De-/select all + * ▢ Chocolate + * ▢ Vanilla + * ▢ Strawberry + * ``` + */ +export interface Category { + /** + * Known values for the target property to filter by. + * Values not listed here cannot be filtered. + * + * If using `string` instead of `CategoryValue`, the string is interpreted as the `key` and as the only entry in `values`. + * + * @remarks + * The values listed here should be localized: + * ```ts + * filter: { + * layer: { + * haus: { + * category: { + * houseType: { + * knownValue: { + * shed: 'Schuppen', + * mansion: 'Villa', + * fortress: 'Festung', + * }, + * }, + * }, + * }, + * }, + * } + * ``` + * + * @example ['shed', 'mansion', 'fortress'] + */ + knownValues: (CategoryValue | string)[] + + /** + * Key of the feature property to filter by. + * + * @remarks + * This value can be localized: + * ```ts + * filter: { + * layer: { + * haus: { + * category: { + * houseType: { + * title: 'Art des Hauses', + * }, + * }, + * }, + * }, + * } + * ``` + * + * @example `'houseType'` + */ + targetProperty: string + + /** + * If `true`, a checkbox is provided to enable or disable all `knownValues` at once. + * + * @example true + * @defaultValue false + */ + selectAll?: boolean +} + +export interface CategoryWithSelection extends Category { + selection: string[] +} + +/** + * Time-based filter configuration for a layer. + * + * @remarks + * Of all time restrictions, at most one can be selected at any time. + * The produced options are selectable by radio buttons. + * + * @example + * ```ts + * { + * targetProperty: 'start', + * pattern: 'YYYYMMDD', + * last: [ + * { + * amounts: [7, 30], + * }, + * ], + * next: [ + * { + * amounts: [7, 30], + * }, + * ], + * freeSelection: { + * now: 'until', + * }, + * } + * ``` + */ +export interface Time { + /** + * Key of the feature property to filter by. + */ + targetProperty: string + + /** + * Defines if the time filter is freely selectable by the user. + * If set to `'until'`, every time range until the current day (inclusive) can be selected. + * If set to `'from'`, every time range from the current day (inclusive) can be selected. + * If not set, this feature is disabled. + * + * @example + * The configuration `'until'` will add this option: + * ```ts + * ◯ Choose time frame + * From ▒▒▒▒▒▒▒▒▒▒▒ // clicking input opens a selector restricted *until* today + * To ▇▇▇▇▇▇▇▇▇▇▇ // clicking input opens a selector restricted *until* today + * ``` + */ + freeSelection?: 'until' | 'from' + + /** + * Configuration for preset time ranges in the past, measured in days. + * A configuration of `[5, 10]` adds the options `Last 5 days` and `Last 10 days`. + * + * @example + * For the configuration `[3, 7]`, this will yield the following options: + * ``` + * ◯ Last 3 days + * ◯ Last 7 days + * ``` + * + * @remarks + * The selections will always include full days, and additionally the current day. + * Due to this, the time frame of "last 7 days" is actually 8*24h long. + * This seems unexpected at first, but follows intuition – if it's Monday and a user filters to the "last seven days", they would expect to fully see last week's Monday, but also features from that day's morning. + */ + last?: number[] + + /** + * Configuration for preset time ranges in the future. + * A configuration of `[5, 10]` adds the options `Next 5 days` and `Next 10 days`. + * + * @example + * For the configuration `[3, 7]`, this will yield the following options: + * ``` + * ◯ Next 3 days + * ◯ Next 7 days + * ``` + * + * @remarks + * The selections will always include full days, and additionally the current day. + * Due to this, the time frame of "next 7 days" is actually 8*24h long. + * This seems unexpected at first, but follows intuition – if it's Monday and a user filters to the "next seven days", they would expect to fully see next week's Monday, but also features from that day's morning. + */ + next?: number[] + + /** + * A pattern that specifies the date format used in the feature properties. + * The pattern definition allows the following tokens: + * - `YYYY`: 4-digit year + * - `MM`: 2-digit month (01-12) + * - `DD`: 2-digit day of month (01-31) + * - `-`: ignored character + * + * @privateRemarks + * All characters that are not tokens are handled as ignored characters. + * This behavior may change in future versions without a breaking change! + * + * @example For the pattern `'--YYYYDD-MM'`, the value `'ML197001-04'` will be interpreted as 1970-04-01 / Apr 1, 1970. + * @defaultValue 'YYYY-MM-DD' + */ + pattern?: string +} + +/** + * Filter configuration for a layer. + * + * @example + * ```ts + * { + * categories: [ + * { + * targetProperty: 'favouriteIceCream', + * knownValues: ['chocolate', 'vanilla', 'strawberry'], + * selectAll: true, + * }, + * ], + * time: { + * targetProperty: 'start', + * pattern: 'YYYYMMDD', + * }, + * } + * ``` + */ +export interface FilterConfiguration { + /** + * A definition of different categories to filter features based on their properties. + */ + categories?: Category[] + + /** + * Filter features based on a time property. + */ + time?: Time +} + +/** + * Plugin options for filter plugin. + * + * @example + * ```ts + * { + * layers: { + * '1234': { + * categories: [ + * { + * selectAll: true, + * targetProperty: 'buildingType', + * knownValues: ['shed', 'mansion', 'fortress'] + * }, + * { + * selectAll: false, + * targetProperty: 'lightbulb', + * knownValues: ['on', 'off'] + * } + * ], + * time: { + * targetProperty: 'lastAccident', + * last: [ + * { + * amounts: [7, 30], + * unit: 'days', + * }, + * ], + * freeSelection: { + * unit: 'days', + * now: 'until' + * }, + * pattern: 'YYYYDDMM' + * } + * } + * } + * } + * ``` + */ +export interface FilterPluginOptions extends PluginOptions { + /** + * Maps a layer ID to its filter configuration. + */ + layers: Record +} + +/** + * Filter state for a layer. + * This represents the filters enabled by the user. + */ +export interface FilterState { + /** + * For each key representing a property's key, only the technical values + * contained in the array are visible. A property key that is absent imposes + * no restriction (all of its values pass). + * + * @example + * The following example allows the property `houseType` to have the value `shed` only. + * ```ts + * { + * houseType: ['shed'], + * } + * ``` + */ + knownValues: Record + + /** + * For each key representing a property's key, only values starting after `from` and ending until `until` are visible. + * The interpretation of the date is done using the `pattern` as described in `Time.pattern`. + * + * @example + * The following example allows the property `time` (ISO date) to be in 2025. + * ```ts + * { + * time: { + * from: new Date('2025-01-01'), + * to: new Date('2025-12-31'), + * pattern: 'YYYY-MM-DD', + * }, + * } + * ``` + */ + timeSpan?: Record +} diff --git a/src/plugins/filter/utils/categoryValues.ts b/src/plugins/filter/utils/categoryValues.ts new file mode 100644 index 0000000000..4465994a50 --- /dev/null +++ b/src/plugins/filter/utils/categoryValues.ts @@ -0,0 +1,35 @@ +import type { Category } from '../types' + +export function expandValue(value: Category['knownValues'][number]) { + return typeof value === 'string' ? { key: value, values: [value] } : value +} + +export function flattenValue(value: Category['knownValues'][number]) { + return expandValue(value).key +} + +export function getAllTechnicalValues(category: Category) { + return category.knownValues.flatMap((v) => expandValue(v).values) +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + test('expandValue wraps a string into a key/values object', () => { + expect(expandValue('shed')).toEqual({ key: 'shed', values: ['shed'] }) + }) + + test('expandValue returns an object value unchanged', () => { + const value = { key: 'home', values: ['home', 'castle'] } + expect(expandValue(value)).toBe(value) + }) + + test('flattenValue returns the value itself for a string', () => { + expect(flattenValue('shed')).toBe('shed') + }) + + test('flattenValue returns the key of an object value', () => { + const value = { key: 'home', values: ['home', 'castle'] } + expect(flattenValue(value)).toBe('home') + }) +} diff --git a/src/plugins/filter/utils/doesFeaturePassFilter.ts b/src/plugins/filter/utils/doesFeaturePassFilter.ts new file mode 100644 index 0000000000..7db2785f78 --- /dev/null +++ b/src/plugins/filter/utils/doesFeaturePassFilter.ts @@ -0,0 +1,118 @@ +import type { FilterState } from '../types' + +import { Feature } from 'ol' + +import { parseDateWithPattern } from './parseDateWithPattern' + +/** + * Checks if a given feature passes the given filter state. + * + * @param feature - Feature to check + * @param filter - Current filter state + * @returns `true` if the feature should be visible, `false` otherwise + */ +export function doesFeaturePassFilter(feature: Feature, filter: FilterState) { + const passesKnownValues = Object.entries(filter.knownValues).every( + ([key, values]) => values.includes(feature.get(key)) + ) + const passesTimeSpan = + !filter.timeSpan || + Object.entries(filter.timeSpan).every(([key, config]) => { + const featureDate = parseDateWithPattern(feature.get(key), config.pattern) + return featureDate >= config.from && featureDate < config.until + }) + return passesKnownValues && passesTimeSpan +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + const feature = new Feature() + feature.set('category', 'blue') + feature.set('time', '2025-01-01') + + const passingTimeSpan = { + time: { + pattern: 'YYYY-MM-DD', + from: new Date('Jan 1, 2024'), + until: new Date('Dec 31, 2026'), + }, + } satisfies FilterState['timeSpan'] + + const failingTimeSpan = { + time: { + pattern: 'YYYY-MM-DD', + from: new Date('Jan 1, 2024'), + until: new Date('Dec 31, 2024'), + }, + } satisfies FilterState['timeSpan'] + + test('a feature passes an empty filter', () => { + const filter = { knownValues: {} } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature passes the category filter', () => { + const filter = { + knownValues: { + category: ['blue'], + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature fails the category filter', () => { + const filter = { + knownValues: { + category: ['red'], + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) + + test('a feature passes the time filter', () => { + const filter = { + knownValues: {}, + timeSpan: passingTimeSpan, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature fails the time filter', () => { + const filter = { + knownValues: {}, + timeSpan: failingTimeSpan, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) + + test('a feature fails one out of two filters', () => { + const filter = { + knownValues: { + category: ['blue'], + misc: ['yes'], + }, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) + + test('a feature passes combined category and time filters', () => { + const filter = { + knownValues: { + category: ['blue'], + }, + timeSpan: passingTimeSpan, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeTruthy() + }) + + test('a feature fails combined filters when only the time filter fails', () => { + const filter = { + knownValues: { + category: ['blue'], + }, + timeSpan: failingTimeSpan, + } satisfies FilterState + expect(doesFeaturePassFilter(feature, filter)).toBeFalsy() + }) +} diff --git a/src/plugins/filter/utils/parseDateWithPattern.ts b/src/plugins/filter/utils/parseDateWithPattern.ts new file mode 100644 index 0000000000..ad950725db --- /dev/null +++ b/src/plugins/filter/utils/parseDateWithPattern.ts @@ -0,0 +1,43 @@ +/** + * Returns a `Date` object from a string using the parsing instruction described with pattern. + * + * @param date - The date string to parse from + * @param pattern - The pattern to parse with + * @returns Parsed Date object + */ +export function parseDateWithPattern(date: string, pattern: string): Date { + const result = Object.fromEntries(['Y', 'M', 'D'].map((key) => [key, ''])) + pattern.split('').forEach((token, index) => { + if (token in result && typeof result[token] === 'string') { + result[token] += date[index] || '' + } + }) + return new Date(Number(result.Y), Number(result.M) - 1, Number(result.D)) +} + +if (import.meta.vitest) { + const { expect, test } = import.meta.vitest + + test.for([ + { + date: '2025-07-01', + pattern: 'YYYY-MM-DD', + expected: new Date('Jul 1, 2025'), + }, + { + date: '202-521-12X', + pattern: 'YYY-YDM-MD-', + expected: new Date('Nov 22, 2025'), + }, + { + date: '2026-01', + pattern: 'YYYY-MM-DD', + expected: new Date('Dec 31, 2025'), + }, + ])( + 'parseDateWithPattern works as expected', + ({ date, pattern, expected }) => { + expect(parseDateWithPattern(date, pattern)).toEqual(expected) + } + ) +} diff --git a/src/plugins/filter/utils/updateFeatureVisibility.ts b/src/plugins/filter/utils/updateFeatureVisibility.ts new file mode 100644 index 0000000000..409c2acb71 --- /dev/null +++ b/src/plugins/filter/utils/updateFeatureVisibility.ts @@ -0,0 +1,59 @@ +import type { FilterState } from '../types' + +import { Feature } from 'ol' +import VectorSource from 'ol/source/Vector' + +import { hideFeature, showFeature } from '@/lib/invisibleStyle' + +import { doesFeaturePassFilter } from './doesFeaturePassFilter' + +/** + * Update the features in the given source according to the given filter. + * + * @param source - Source of the layer + * @param filter - Filter state for the layer + */ +export function updateFeatureVisibility( + source: VectorSource, + filter: FilterState +) { + const features = source + .getFeatures() + .flatMap((feature) => feature.get('features') || [feature]) as Feature[] + + // For performance reasons, do not update each feature individually on screen. + source.clear() + features.forEach((feature) => { + ;(doesFeaturePassFilter(feature, filter) ? showFeature : hideFeature)( + feature + ) + }) + source.addFeatures(features) +} + +if (import.meta.vitest) { + const { test, expect, vi } = import.meta.vitest + const { isVisible, isInvisible } = await import('@/lib/invisibleStyle') + const doesFeaturePassFilterFile = await import('./doesFeaturePassFilter') + const filterSpy = vi.spyOn(doesFeaturePassFilterFile, 'doesFeaturePassFilter') + filterSpy.mockImplementation((f: Feature) => f.get('filter') === 'yes') + + test('feature visibility is updated according to filter', () => { + const alpha = new Feature() + alpha.set('filter', 'yes') + + const beta = new Feature() + beta.set('filter', 'no') + + const source = new VectorSource() + source.addFeatures([alpha, beta]) + + updateFeatureVisibility(source, { knownValues: {} }) + + expect(source.getFeatures()).toHaveLength(2) + expect(source.getFeatures()).toContain(alpha) + expect(isVisible(alpha)).toBeTruthy() + expect(source.getFeatures()).toContain(beta) + expect(isInvisible(beta)).toBeTruthy() + }) +} diff --git a/src/plugins/footer/components/PolarFooter.ce.vue b/src/plugins/footer/components/PolarFooter.ce.vue new file mode 100644 index 0000000000..cd71ff348f --- /dev/null +++ b/src/plugins/footer/components/PolarFooter.ce.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/src/plugins/footer/index.ts b/src/plugins/footer/index.ts new file mode 100644 index 0000000000..f1b4c80cd6 --- /dev/null +++ b/src/plugins/footer/index.ts @@ -0,0 +1,35 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/footer + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { FooterPluginOptions } from './types' + +import component from './components/PolarFooter.ce.vue' +import locales from './locales' +import { useFooterStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..57c853dc51 --- /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 0000000000..8153a52bcd --- /dev/null +++ b/src/plugins/footer/store.ts @@ -0,0 +1,73 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/footer/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Component } from 'vue' +import type { PluginContainer } from '@/core' + +import { toMerged } from 'es-toolkit' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { markRaw, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' + +/* 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 0000000000..245ce23b3d --- /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 0000000000..f7759cd0a8 --- /dev/null +++ b/src/plugins/fullscreen/components/FullscreenUI.ce.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/plugins/fullscreen/components/FullscreenUI.spec.ts b/src/plugins/fullscreen/components/FullscreenUI.spec.ts new file mode 100644 index 0000000000..db51139321 --- /dev/null +++ b/src/plugins/fullscreen/components/FullscreenUI.spec.ts @@ -0,0 +1,64 @@ +import type { VueWrapper } from '@vue/test-utils' + +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { test as _test, expect, vi } from 'vitest' +import { nextTick } from 'vue' + +import { mockedT } from '@/test/utils/mockI18n' + +import { useFullscreenStore } from '../store' +import { PluginId } from '../types' +import FullscreenUI from './FullscreenUI.ce.vue' + +/* 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 0000000000..83077a7135 --- /dev/null +++ b/src/plugins/fullscreen/index.ts @@ -0,0 +1,32 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/fullscreen + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { FullscreenPluginOptions } from './types' + +import component from './components/FullscreenUI.ce.vue' +import locales from './locales' +import { useFullscreenStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..74e80cf20d --- /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 0000000000..7ab019b7ff --- /dev/null +++ b/src/plugins/fullscreen/store.ts @@ -0,0 +1,284 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/fullscreen/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Reactive } from 'vue' +import type { FullscreenPluginOptions } from './types' + +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' + +/* 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 || 'independent' + ) + + 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') + + /* 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 = document.createElement('div').style + 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 0000000000..aa1e5c3864 --- /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 0000000000..ba2d214f01 --- /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 0000000000..fc786be745 --- /dev/null +++ b/src/plugins/geoLocation/components/GeoLocation.spec.ts @@ -0,0 +1,79 @@ +import type { VueWrapper } from '@vue/test-utils' + +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { test as _test, expect, vi } from 'vitest' + +import { mockedT } from '@/test/utils/mockI18n' + +import { useGeoLocationStore } from '../store' +import GeoLocation from './GeoLocation.ce.vue' + +/* 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('.kern-label').exists()).toBe(true) + const tooltip = wrapper.find('.polar-tooltip') + expect(tooltip.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 0000000000..8cfae4bf97 --- /dev/null +++ b/src/plugins/geoLocation/index.ts @@ -0,0 +1,37 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/geoLocation + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { GeoLocationPluginOptions } from './types' + +import component from './components/GeoLocation.ce.vue' +import locales from './locales' +import { useGeoLocationStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..d5f57c3cb3 --- /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 0000000000..7d839c3c36 --- /dev/null +++ b/src/plugins/geoLocation/store.ts @@ -0,0 +1,556 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/geoLocation/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Coordinate } from 'ol/coordinate' +import type { ObjectEvent } from 'ol/Object' +import type { GeoLocationPluginOptions, PluginState } from './types' + +import { noop, toMerged } from 'es-toolkit' +import { t } from 'i18next' +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, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { notifyUser } from '@/lib/notifyUser' +import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck' +import { getTooltip } from '@/lib/tooltip' + +import { PluginId } from './types' +import { detectDeniedGeolocationEarly } from './utils/detectDeniedGeolocationEarly' +import { getGeoLocationStyle } from './utils/olStyle' +import { positionChanged } from './utils/positionChanged' + +/* 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([]) + + let mapHasBeenMovedByUser = false + + const configuration = computed< + GeoLocationPluginOptions & { showTooltip: boolean; zoomLevel: number } + >(() => + toMerged( + { showTooltip: false, zoomLevel: 7 }, + coreStore.configuration.geoLocation || {} + ) + ) + const boundary = computed(() => configuration.value.boundary) + const renderType = computed<'independent' | 'iconMenu'>( + () => configuration.value.renderType || 'independent' + ) + 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: PluginId }], + ]) + 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() { + mapHasBeenMovedByUser = false + 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', setHeading) + geolocation.value.on('error', onError) + } + + watch( + () => coreStore.center, + () => { + if (position.value !== coreStore.center) { + mapHasBeenMovedByUser = true + } + } + ) + + /** + * 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: PluginId, + }) + ) + console.error(error.message) + + isGeolocationDenied.value = true + removeMarker() + } + + function setHeading({ target }: ObjectEvent) { + markerFeature.set('heading', target.getHeading()) + } + + /** + * Stop tracking of geo position. + */ + function untrack() { + if (geolocation.value) { + geolocation.value.un('change:position', positioning) + geolocation.value.un('change:heading', setHeading) + geolocation.value.un('error', onError) + 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 && + !mapHasBeenMovedByUser + ) { + 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: PluginId }) + notifyUser('error', msg) + console.error(msg) + return + } + const msg = t(($) => $.toast.notInBoundary, { + ns: PluginId, + }) + 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, + + /** + * @internal + */ + renderType, + + /** + * 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 0000000000..3e99ddd847 --- /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 0000000000..6b257e3a44 --- /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 0000000000..6af2acefdc --- /dev/null +++ b/src/plugins/geoLocation/utils/olStyle.ts @@ -0,0 +1,78 @@ +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 0000000000..8e4d212590 --- /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 0000000000..99c8bcb444 --- /dev/null +++ b/src/plugins/iconMenu/components/IconMenu.ce.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/plugins/iconMenu/components/NineRegionsButton.ce.vue b/src/plugins/iconMenu/components/NineRegionsButton.ce.vue new file mode 100644 index 0000000000..8bb25a50d5 --- /dev/null +++ b/src/plugins/iconMenu/components/NineRegionsButton.ce.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue b/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue new file mode 100644 index 0000000000..bc193e516a --- /dev/null +++ b/src/plugins/iconMenu/components/NineRegionsMenu.ce.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue b/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue new file mode 100644 index 0000000000..9080f88540 --- /dev/null +++ b/src/plugins/iconMenu/components/StandardFocusMenu.ce.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/src/plugins/iconMenu/components/StandardMenu.ce.vue b/src/plugins/iconMenu/components/StandardMenu.ce.vue new file mode 100644 index 0000000000..6b744d6452 --- /dev/null +++ b/src/plugins/iconMenu/components/StandardMenu.ce.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/src/plugins/iconMenu/components/StandardMenuList.ce.vue b/src/plugins/iconMenu/components/StandardMenuList.ce.vue new file mode 100644 index 0000000000..1b0df4448f --- /dev/null +++ b/src/plugins/iconMenu/components/StandardMenuList.ce.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/src/plugins/iconMenu/index.ts b/src/plugins/iconMenu/index.ts new file mode 100644 index 0000000000..d42418a049 --- /dev/null +++ b/src/plugins/iconMenu/index.ts @@ -0,0 +1,37 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/iconMenu + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { IconMenuPluginOptions } from './types' + +import component from './components/IconMenu.ce.vue' +import locales from './locales' +import { useIconMenuStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..4e29783791 --- /dev/null +++ b/src/plugins/iconMenu/locales.ts @@ -0,0 +1,60 @@ +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', + routing: 'Routenplaner', + }, +} 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', + routing: 'Route Planner', + }, +} 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 0000000000..f0fd4ac858 --- /dev/null +++ b/src/plugins/iconMenu/store.ts @@ -0,0 +1,195 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/iconMenu/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Component } from 'vue' +import type { Icon } from '@/core' +import type { Menu } from './types' + +import { toMerged } from 'es-toolkit' +import { t } from 'i18next' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, markRaw, ref, toRaw } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' + +/* 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: Icon })[]>([]) + const open = ref(null) + const focusOpen = ref(null) + + const buttonComponent = computed(() => + coreStore.configuration.iconMenu?.buttonComponent + ? markRaw(coreStore.configuration.iconMenu.buttonComponent) + : null + ) + + const layoutTag = computed( + () => coreStore.configuration.iconMenu?.layoutTag ?? '' + ) + + const visibleMenus = computed(() => + menus.value.map((menuGroup) => + menuGroup.filter( + (item) => !coreStore.hasSmallDisplay || !item.disabledOnMobile + ) + ) + ) + const visibleFocusMenus = computed(() => + focusMenus.value + .flat() + .filter((item) => !coreStore.hasSmallDisplay || !item.disabledOnMobile) + ) + + function setupPlugin() { + // Components are marked raw so they themselves are not made reactive + menus.value = (coreStore.configuration.iconMenu?.menus || []).map( + (menuGroup) => + menuGroup + .filter(({ plugin: { id } }) => { + const display = coreStore.configuration[id]?.displayComponent + return typeof display === 'boolean' ? display : true + }) + .map((menuItem) => ({ + ...menuItem, + plugin: { + ...menuItem.plugin, + component: markRaw(toRaw(menuItem.plugin.component as Component)), + }, + })) + ) + focusMenus.value = (coreStore.configuration.iconMenu?.focusMenus || []) + .filter(({ plugin: { id } }) => { + const display = coreStore.configuration[id]?.displayComponent + return typeof display === 'boolean' ? display : true + }) + .map((menuItem) => ({ + ...menuItem, + plugin: { + ...menuItem.plugin, + component: markRaw(toRaw(menuItem.plugin.component as Component)), + }, + })) + + menus.value + .concat(focusMenus.value) + .flat() + .forEach(({ plugin }) => { + coreStore.addPlugin(toMerged(plugin, { independent: false })) + }) + + 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 entry = menus.value.flat().find(({ plugin: { id } }) => id === openId) + + if (entry) { + open.value = openId + openInMoveHandle(openId) + } + } + + function openFocusMenuById(openId: string) { + const entry = focusMenus.value.find(({ plugin: { id } }) => id === openId) + + if (entry) { + focusOpen.value = openId + openInMoveHandle(openId, true) + } + } + + function openInMoveHandle(openId: string, focusMenu = false) { + const menu = (focusMenu ? focusMenus.value : menus.value.flat()).find( + ({ plugin: { id } }) => id === openId + ) + if (!menu) { + console.error(`Menu with id ${openId} 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 !== null) { + open.value = null + } else if (!focusMenu && focusOpen.value !== null) { + focusOpen.value = null + } + } + coreStore.setMoveHandle({ + closeFunction: () => { + if (focusMenu) { + focusOpen.value = null + return + } + open.value = null + }, + closeLabel: t(($) => $.mobileCloseButton, { + ns: PluginId, + plugin: t(($) => $.hints[menu.plugin.id], { ns: PluginId }), + }), + component: menu.plugin.component, + plugin: PluginId, + }) + } + + return { + visibleMenus, + visibleFocusMenus, + open, + focusOpen, + buttonComponent, + openInMoveHandle, + openMenuById, + openFocusMenuById, + + /** @alpha */ + layoutTag, + + /** @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 0000000000..d8de83629e --- /dev/null +++ b/src/plugins/iconMenu/types.ts @@ -0,0 +1,105 @@ +import type { Component } from 'vue' +import type { Icon, PluginContainer, PluginOptions } from '@/core' + +export const PluginId = 'iconMenu' + +export interface Menu { + /** + * The plugin that should be part of the icon menu. + */ + plugin: PluginContainer + + /** + * Adds possibility to disable some entries on mobile. + * + * @defaultValue `false` + */ + disabledOnMobile?: boolean + + /** + * 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 | `layout`} is set to `'nineRegions'` + */ + icon?: Icon +} + +/** + * 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 | `layout`} 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 | `layout`} 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 | `layout`} 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 | `layout`} 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: Icon })[] + + /** + * 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?: PluginOptions['layoutTag'] +} diff --git a/src/plugins/initialView/components/InitialView.ce.vue b/src/plugins/initialView/components/InitialView.ce.vue new file mode 100644 index 0000000000..33fe263cf3 --- /dev/null +++ b/src/plugins/initialView/components/InitialView.ce.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/plugins/initialView/index.ts b/src/plugins/initialView/index.ts new file mode 100644 index 0000000000..cd127be546 --- /dev/null +++ b/src/plugins/initialView/index.ts @@ -0,0 +1,32 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module @polar/polar/plugins/InitialView + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { InitialViewPluginOptions } from './types' + +import component from './components/InitialView.ce.vue' +import locales from './locales' +import { useInitialViewStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which offers a button to return to the map's start view. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginInitialView( + options: InitialViewPluginOptions = {} +): PluginContainer { + return { + id: PluginId, + component, + locales, + storeModule: useInitialViewStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/initialView/locales.ts b/src/plugins/initialView/locales.ts new file mode 100644 index 0000000000..35491f87e5 --- /dev/null +++ b/src/plugins/initialView/locales.ts @@ -0,0 +1,32 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module locales/plugins/InitialView + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +export const resourcesDe = { + label: { + return: 'Zurück zur Startansicht', + }, +} as const + +export const resourcesEn = { + label: { + return: 'Return to start view', + }, +} as const + +const locales: Locale[] = [ + { + type: 'de', + resources: resourcesDe, + }, + { + type: 'en', + resources: resourcesEn, + }, +] + +export default locales diff --git a/src/plugins/initialView/store.ts b/src/plugins/initialView/store.ts new file mode 100644 index 0000000000..5fe027082d --- /dev/null +++ b/src/plugins/initialView/store.ts @@ -0,0 +1,124 @@ +import type { ComputedRef } from 'vue' +import type { InitialViewPluginOptions } from './types' + +import { defineStore } from 'pinia' +import { computed } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' + +export const useInitialViewStore = defineStore('plugins/initialView', () => { + const coreStore = useCoreStore() + + const configuration = computed( + () => coreStore.configuration[PluginId] as InitialViewPluginOptions + ) + + const layoutTag = computed(() => configuration.value.layoutTag ?? '') + + const renderType = computed( + () => configuration.value.renderType ?? 'independent' + ) + + const tooltipPosition = computed(() => + renderType.value === 'independent' + ? layoutTag.value.includes('RIGHT') + ? 'left' + : 'right' + : coreStore.getPluginStore('iconMenu')?.layoutTag.includes('RIGHT') + ? 'left' + : 'right' + ) as ComputedRef<'left' | 'right'> + + const startCenter = computed(() => coreStore.configuration.startCenter) + + const startResolution = computed( + () => coreStore.configuration.startResolution + ) + + function returnToInitialView() { + coreStore.center = startCenter.value + const zoom = coreStore.configuration.options.find( + ({ resolution }) => resolution === startResolution.value + ) + if (zoom) { + coreStore.zoom = zoom.zoomLevel + } + } + + function setupPlugin() {} + + function teardownPlugin() {} + + return { + /** @alpha */ + returnToInitialView, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + + /** + * Indicates in which direction of the element space is available for a tooltip. + * + * @alpha + * @readonly + */ + tooltipPosition, + } +}) + +if (import.meta.vitest) { + const { createPinia, setActivePinia } = await import('pinia') + const { describe, it, expect, vi, beforeEach } = import.meta.vitest + const useCoreStoreFile = await import('@/core/stores') + + interface MockCoreStore { + center: number[] | null + configuration: { + startCenter: number[] + startResolution: number + options: { resolution: number; zoomLevel: number }[] + } + getPluginStore: () => unknown + zoom: number | null + } + + let mockCoreStore: MockCoreStore + + const mockOptions = [ + { resolution: 1, zoomLevel: 5 }, + { resolution: 2, zoomLevel: 10 }, + { resolution: 3, zoomLevel: 15 }, + ] + + beforeEach(() => { + setActivePinia(createPinia()) + mockCoreStore = { + configuration: { + startCenter: [10, 20], + startResolution: 2, + options: mockOptions, + }, + center: null, + zoom: null, + getPluginStore: vi.fn(), + } + // @ts-expect-error | Mocking useCoreStore + vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(mockCoreStore) + }) + + const { useInitialViewStore } = await import('./store') + + describe('InitialView Store', () => { + it('should set center and zoom on returnToInitialView', () => { + const store = useInitialViewStore() + store.returnToInitialView() + expect(mockCoreStore.center).toEqual([10, 20]) + expect(mockCoreStore.zoom).toBe(10) + }) + }) +} diff --git a/src/plugins/initialView/types.ts b/src/plugins/initialView/types.ts new file mode 100644 index 0000000000..b19e34745b --- /dev/null +++ b/src/plugins/initialView/types.ts @@ -0,0 +1,12 @@ +import type { PluginOptions } from '@/core' + +export const PluginId = 'initialView' as const + +export interface InitialViewPluginOptions extends PluginOptions { + /** + * Defines if the initialView button is rendered independent or as part of the + * icon menu. + * @defaultValue `'independent'` + */ + renderType?: 'independent' | 'iconMenu' +} diff --git a/src/plugins/layerChooser/components/LayerChooser.ce.vue b/src/plugins/layerChooser/components/LayerChooser.ce.vue new file mode 100644 index 0000000000..9bc8017fbf --- /dev/null +++ b/src/plugins/layerChooser/components/LayerChooser.ce.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/plugins/layerChooser/components/LayerInformationCard.ce.vue b/src/plugins/layerChooser/components/LayerInformationCard.ce.vue new file mode 100644 index 0000000000..29852159ce --- /dev/null +++ b/src/plugins/layerChooser/components/LayerInformationCard.ce.vue @@ -0,0 +1,49 @@ + + diff --git a/src/plugins/layerChooser/components/LayerLegend.ce.vue b/src/plugins/layerChooser/components/LayerLegend.ce.vue new file mode 100644 index 0000000000..790ee53c13 --- /dev/null +++ b/src/plugins/layerChooser/components/LayerLegend.ce.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/plugins/layerChooser/components/LayerOptions.ce.vue b/src/plugins/layerChooser/components/LayerOptions.ce.vue new file mode 100644 index 0000000000..d8af39d8bf --- /dev/null +++ b/src/plugins/layerChooser/components/LayerOptions.ce.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/plugins/layerChooser/components/LayerSelection.ce.vue b/src/plugins/layerChooser/components/LayerSelection.ce.vue new file mode 100644 index 0000000000..5ac3efe291 --- /dev/null +++ b/src/plugins/layerChooser/components/LayerSelection.ce.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/src/plugins/layerChooser/components/LegendButton.ce.vue b/src/plugins/layerChooser/components/LegendButton.ce.vue new file mode 100644 index 0000000000..bc7afd8141 --- /dev/null +++ b/src/plugins/layerChooser/components/LegendButton.ce.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/plugins/layerChooser/index.ts b/src/plugins/layerChooser/index.ts new file mode 100644 index 0000000000..3e406f2f19 --- /dev/null +++ b/src/plugins/layerChooser/index.ts @@ -0,0 +1,40 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/layerChooser + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PluginOptions, PolarPluginStore } from '@/core' + +import component from './components/LayerChooser.ce.vue' +import locales from './locales' +import { useLayerChooserStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..7a06fcc83c --- /dev/null +++ b/src/plugins/layerChooser/locales.ts @@ -0,0 +1,60 @@ +/* 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 type { Locale } from '@/core' + +import { PluginId } from './types' + +/** + * 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(${PluginId}:legend.to, { name: {{name}} ) ö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(${PluginId}:legend.to, { name: {{name}} })`, + }, + 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 0000000000..2925c5a767 --- /dev/null +++ b/src/plugins/layerChooser/store.ts @@ -0,0 +1,282 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/layerChooser/store + */ +/* eslint-enable tsdoc/syntax */ + +import type Layer from 'ol/layer/Layer' +import type { ImageWMS, TileWMS } from 'ol/source' +import type { LayerConfiguration } from '@/core' +import type { LayerLegend, LayerOptions } from './types' + +import { toMerged } from 'es-toolkit' +import { defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { areLayersActive } from './utils/areLayersActive' +import { + loadCapabilities, + prepareLayersWithOptions, +} from './utils/capabilities' +import { getBackgroundsAndMasks } from './utils/getBackgroundsAndMasks' +import { prepareLegends } from './utils/prepareLegends' + +/* 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 visibleMaskIds = computed(() => + availableMasks.value + .map(({ id }) => id) + .filter((id) => activeMaskIds.value.includes(id)) + ) + 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(visibleMaskIds, (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. + * + * @alpha + */ + activeMaskIds, + + /** + * Ids of the currently active mask layers without distinction between mask groups, + * filtered by availability. + * + * @alpha + */ + visibleMaskIds, + + /** @alpha */ + backgrounds, + + /** + * Maps a layer id to its GetCapabilities xml return value or null if an error happened. + * + * @alpha + */ + capabilities, + + /** @alpha */ + disabledBackgrounds, + + /** @alpha */ + disabledMasks, + + /** @alpha */ + layersWithLegends, + + /** @alpha */ + layersWithOptions, + + /** @alpha */ + masksSeparatedByType, + + /** @alpha */ + shownMasks, + + /** @alpha */ + openedLegendId, + + /** @alpha */ + openedOptionsId, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + + /** @alpha */ + toggleOpenedOptionsServiceLayer, + } +}) diff --git a/src/plugins/layerChooser/types.ts b/src/plugins/layerChooser/types.ts new file mode 100644 index 0000000000..4b791a199e --- /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 0000000000..a9b3b7d5f6 --- /dev/null +++ b/src/plugins/layerChooser/utils/areLayersActive.ts @@ -0,0 +1,20 @@ +import type { LayerConfiguration } from '@/core' + +/** + * Returns a boolean list which contains 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 = Number.MIN_SAFE_INTEGER + } + 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 0000000000..079871a872 --- /dev/null +++ b/src/plugins/layerChooser/utils/capabilities.ts @@ -0,0 +1,101 @@ +import type { LayerConfiguration, LayerConfigurationOptionLayers } from '@/core' + +import { rawLayerList } from '@masterportal/masterportalapi' +import { toMerged } from 'es-toolkit' +import WMSCapabilities from 'ol/format/WMSCapabilities' + +import { + findLayerTitleInCapabilitiesByName, + findLegendUrlInCapabilitiesByName, +} from './findInCapabilities' + +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 0000000000..2bc3c58313 --- /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 0000000000..763e781c1c --- /dev/null +++ b/src/plugins/layerChooser/utils/getBackgroundsAndMasks.ts @@ -0,0 +1,27 @@ +import type { LayerConfiguration } from '@/core' + +import { rawLayerList } from '@masterportal/masterportalapi' + +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 0000000000..203a5cd6a0 --- /dev/null +++ b/src/plugins/layerChooser/utils/prepareLegends.ts @@ -0,0 +1,23 @@ +import type { LayerConfiguration } from '@/core' +import type { LayerLegend } from '../types' + +import { layerLib, rawLayerList } from '@masterportal/masterportalapi' +import { toMerged } from 'es-toolkit' + +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 0000000000..8ae360b0fc 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 0000000000..1ed00949d9 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 0000000000..47237b7f3d 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 0000000000..71fffb6622 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 0000000000..4bbe376cad 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 0000000000..6af68a1090 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 0000000000..fc1628674e --- /dev/null +++ b/src/plugins/loadingIndicator/components/LoadingIndicator.ce.vue @@ -0,0 +1,79 @@ + + + + + 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 0000000000..699453865d --- /dev/null +++ b/src/plugins/loadingIndicator/components/loaderStyles/BasicLoader.ce.vue @@ -0,0 +1,106 @@ + + + + + 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 0000000000..61cd84dfd5 --- /dev/null +++ b/src/plugins/loadingIndicator/components/loaderStyles/CircleLoader.ce.vue @@ -0,0 +1,44 @@ + + + + + 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 0000000000..aacc321caf --- /dev/null +++ b/src/plugins/loadingIndicator/components/loaderStyles/RingLoader.ce.vue @@ -0,0 +1,52 @@ + + + + + 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 0000000000..ba166dbc48 --- /dev/null +++ b/src/plugins/loadingIndicator/components/loaderStyles/RollerLoader.ce.vue @@ -0,0 +1,102 @@ + + + + + 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 0000000000..b5033a03a8 --- /dev/null +++ b/src/plugins/loadingIndicator/components/loaderStyles/SpinnerLoader.ce.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/plugins/loadingIndicator/components/loaderStyles/index.ts b/src/plugins/loadingIndicator/components/loaderStyles/index.ts new file mode 100644 index 0000000000..39a5d8c89e --- /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 0000000000..f697eb4319 --- /dev/null +++ b/src/plugins/loadingIndicator/index.ts @@ -0,0 +1,31 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/loadingIndicator + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { LoadingIndicatorOptions } from './types' + +import component from './components/LoadingIndicator.ce.vue' +import { useLoadingIndicatorStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..d26bcebf3b --- /dev/null +++ b/src/plugins/loadingIndicator/store.ts @@ -0,0 +1,109 @@ +/* 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' + +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 0000000000..25fa8c6005 --- /dev/null +++ b/src/plugins/loadingIndicator/types.ts @@ -0,0 +1,31 @@ +import type { PluginOptions } from '@/core' +import type { LoaderStyles } from './store' + +export const PluginId = 'loadingIndicator' + +export interface LoadingIndicatorOptions extends PluginOptions { + /** + * Choose between different loader styles. + * + * Supported options: + * + * + * + * + * + * + * + * + * + * + * + *
KernLoader
KernLoader
BasicLoader
BasicLoader
RingLoader
RingLoader
RollerLoader
RollerLoader
CircleLoader
CircleLoader
SpinnerLoader
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/composables/usePinLayer.ts b/src/plugins/pins/composables/usePinLayer.ts new file mode 100644 index 0000000000..5789b53592 --- /dev/null +++ b/src/plugins/pins/composables/usePinLayer.ts @@ -0,0 +1,47 @@ +import type { Coordinate } from 'ol/coordinate' +import type { Style } from 'ol/style' +import type { Ref } from 'vue' + +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import VectorLayer from 'ol/layer/Vector' +import { Vector } from 'ol/source' +import { watch } from 'vue' + +export function usePinLayer(coordinate: Ref, style: Style) { + const pinLayer = new VectorLayer({ + source: new Vector(), + style, + }) + + function addPin(newCoordinate: Coordinate) { + // Always clean up other/old pin first – single pin only atm. + removePin() + ;(pinLayer.getSource() as Vector).addFeature( + new Feature({ + geometry: new Point(newCoordinate), + type: 'point', + name: 'mapMarker', + zIndex: 100, + }) + ) + } + + function removePin() { + ;(pinLayer.getSource() as Vector).clear() + } + + watch( + coordinate, + (newCoordinate) => { + if (newCoordinate) { + addPin(newCoordinate) + } else { + removePin() + } + }, + { deep: true, immediate: true } + ) + + return { pinLayer } +} diff --git a/src/plugins/pins/index.ts b/src/plugins/pins/index.ts new file mode 100644 index 0000000000..72fd4bd796 --- /dev/null +++ b/src/plugins/pins/index.ts @@ -0,0 +1,35 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/pins + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { PinsPluginOptions } from './types' + +import locales from './locales' +import { usePinsStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..aa800119e6 --- /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 0000000000..39e37a3d8a --- /dev/null +++ b/src/plugins/pins/store.ts @@ -0,0 +1,225 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/pins/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { GeoJsonGeometryTypes, Point as GeoJsonPoint } from 'geojson' +import type { MapBrowserEvent } from 'ol' +import type { Coordinate } from 'ol/coordinate' +import type Point from 'ol/geom/Point' +import type { PolarGeoJsonFeature } from '@/core' +import type { PinMovable, PinsPluginOptions } from './types' + +import { toMerged } from 'es-toolkit' +import { pointerMove } from 'ol/events/condition' +import { Select, Translate } from 'ol/interaction' +import { toLonLat } from 'ol/proj' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useStoreWatcher } from '@/composables/useStoreWatcher' +import { useCoreStore } from '@/core/stores' + +import { usePinLayer } from './composables/usePinLayer' +import { PluginId } from './types' +import { getPinStyle } from './utils/getPinStyle' +import { getPointCoordinate } from './utils/getPointCoordinate' +import { isCoordinateInBoundaryLayer } from './utils/isCoordinateInBoundaryLayer' + +/* 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(null) + 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(() => { + if (!coordinate.value) { + return null + } + const lonLat = toLonLat(coordinate.value, coreStore.configuration.epsg) + return [lonLat[1], lonLat[0]] + }) + + const { pinLayer } = usePinLayer( + coordinate, + 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], + }) + + useStoreWatcher( + () => configuration.value.coordinateSources || [], + (value) => { + const feature = value as PolarGeoJsonFeature | null + // NOTE: 'reverse_geocoded' is set as type on reverse geocoded features + // to prevent infinite loops as in: ReverseGeocode->AddressSearch->Pins->ReverseGeocode. + if (feature && feature.type !== 'reverse_geocoded') { + addPin(feature.geometry.coordinates, false, { + type: feature.geometry.type, + }) + } + }, + { target: { plugin: PluginId, key: 'coordinate' } } + ) + + function setupPlugin() { + coreStore.map.addLayer(pinLayer) + pinLayer.setZIndex(100) + coreStore.map.on('singleclick', onSingleClick) + setupInitial() + setupInteractions() + } + + function teardownPlugin() { + const { map } = coreStore + map.un('singleclick', onSingleClick) + map.removeLayer(pinLayer) + map.removeInteraction(move) + map.removeInteraction(translate) + coordinate.value = null + } + + 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() + + const newCoordinate = !(await isCoordinateInBoundaryLayer( + geometryCoordinates, + coreStore.map, + configuration.value.boundary + )) + ? coordinate.value + : geometryCoordinates + + if (newCoordinate) { + addPin(newCoordinate) + } + }) + }) + coreStore.map.addInteraction(translate) + } + + async function onSingleClick({ coordinate }: MapBrowserEvent) { + await click(coordinate) + } + + async function click(coordinate: Coordinate) { + 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 && + !coreStore.isInteractionMasked('click') && + (await isCoordinateInBoundaryLayer( + coordinate, + coreStore.map, + configuration.value.boundary + )) + ) { + addPin(coordinate) + } + } + + function addPin( + newCoordinate: Coordinate, + clicked = true, + pinInformation?: { + type: Exclude + epsg?: string + } + ) { + if (!clicked && pinInformation) { + coordinate.value = getPointCoordinate( + pinInformation.epsg || coreStore.configuration.epsg, + coreStore.configuration.epsg, + pinInformation.type, + newCoordinate + ) + coreStore.map.getView().setCenter(coordinate.value) + coreStore.map.getView().setZoom(configuration.value.toZoomLevel) + } else { + coordinate.value = newCoordinate + } + } + + 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 0000000000..6ca98f6f91 --- /dev/null +++ b/src/plugins/pins/types.ts @@ -0,0 +1,108 @@ +import type { Color, LayerBoundPluginOptions, StoreReference } 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 + * ``` + * [{ + * plugin: 'addressSearch', + * key: 'chosenAddress' + * }] + * ``` + */ + coordinateSources?: StoreReference[] + + /** + * 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 0000000000..8d5f033cc0 --- /dev/null +++ b/src/plugins/pins/utils/getPinStyle.ts @@ -0,0 +1,37 @@ +import type { PinStyle } from '../types' + +import { Icon, Style } from 'ol/style' + +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 8793b4c595..1f93ec1bc1 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 0000000000..9ef15ff21d --- /dev/null +++ b/src/plugins/pins/utils/getPointCoordinate.ts @@ -0,0 +1,56 @@ +import type { GeoJsonGeometryTypes } from 'geojson' +import type { Coordinate } from 'ol/coordinate' + +import { getCenter } from 'ol/extent' +import { + Circle, + LinearRing, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, +} from 'ol/geom' +import { transform } from 'ol/proj' + +// 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 0000000000..bdf8837de4 --- /dev/null +++ b/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts @@ -0,0 +1,47 @@ +import type { Map } from 'ol' +import type { Coordinate } from 'ol/coordinate' +import type { BoundaryOptions } from '@/core' + +import { t } from 'i18next' + +import { notifyUser } from '@/lib/notifyUser' +import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck' + +import { PluginId } from '../types' + +/** + * 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: PluginId })) + console.error('Checking boundary layer failed.') + } else { + notifyUser('info', () => t(($) => $.notInBoundary, { ns: PluginId })) + // eslint-disable-next-line no-console + console.info('Pin position outside of boundary layer:', coordinate) + } + return false +} diff --git a/src/plugins/pointerPosition/components/PointerPosition.ce.vue b/src/plugins/pointerPosition/components/PointerPosition.ce.vue new file mode 100644 index 0000000000..b7656a5e57 --- /dev/null +++ b/src/plugins/pointerPosition/components/PointerPosition.ce.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/plugins/pointerPosition/index.ts b/src/plugins/pointerPosition/index.ts new file mode 100644 index 0000000000..06fcc4bbe7 --- /dev/null +++ b/src/plugins/pointerPosition/index.ts @@ -0,0 +1,34 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/pointerPosition + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { PointerPositionPluginOptions } from './types' + +import component from './components/PointerPosition.ce.vue' +import locales from './locales' +import { usePointerPositionStore } from './store' +import { PluginId } from './types' + +/** + * The PointerPosition plugin makes the current/last pointer position visible + * as coordinates. An optional select menu is configurable to allow users to + * switch to their preferred coordinate reference system. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginPointerPosition( + options: PointerPositionPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + options, + storeModule: usePointerPositionStore as PolarPluginStore, + } +} + +export * from './types' diff --git a/src/plugins/pointerPosition/locales.ts b/src/plugins/pointerPosition/locales.ts new file mode 100644 index 0000000000..aa26c31694 --- /dev/null +++ b/src/plugins/pointerPosition/locales.ts @@ -0,0 +1,63 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the pointerPosition plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/pointerPosition + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +/** + * German locales for pointerPosition plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + contextMenu: 'Position kopieren', + label: 'Zeigerposition', + projectionSelect: { + label: 'Koordinatenreferenzsystem', + }, + toast: { + success: 'Position in Zwischenablage kopiert.', + error: 'Position konnte nicht kopiert werden.', + }, +} as const + +/** + * English locales for pointerPosition plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + contextMenu: 'Copy position', + label: 'Pointer position', + projectionSelect: { + label: 'Coordinate reference system', + }, + toast: { + success: 'Position copied to clipboard.', + error: 'Position could not be copied.', + }, +} as const + +/** + * PointerPositionplugin 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/pointerPosition/store.ts b/src/plugins/pointerPosition/store.ts new file mode 100644 index 0000000000..9e0d8e91db --- /dev/null +++ b/src/plugins/pointerPosition/store.ts @@ -0,0 +1,157 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/pointerPosition/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Coordinate } from 'ol/coordinate' + +import { t } from 'i18next' +import { createStringXY } from 'ol/coordinate' +import { transform } from 'ol/proj' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { notifyUser } from '@/lib/notifyUser' + +import { PluginId } from './types' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for the pointerPosition. + */ +/* eslint-enable tsdoc/syntax */ +export const usePointerPositionStore = defineStore( + 'plugins/pointerPosition', + () => { + const coreStore = useCoreStore() + + const selectedProjectionIndex = ref(0) + const pointerPosition = ref([]) + + const availableProjections = computed(() => + coreStore.configuration.pointerPosition?.projections + ? coreStore.configuration.pointerPosition.projections.map((entry) => ({ + ...entry, + decimals: entry.decimals ?? 4, + })) + : coreStore.configuration.namedProjections.map(([code]) => ({ + code, + decimals: 4, + })) + ) + + const currentEpsgSystem = computed(() => { + const projection = + availableProjections.value[selectedProjectionIndex.value] + if (!projection) { + throw new Error( + 'selectedProjectionIndex out of bounds. This should never happen.' + ) + } + return projection + }) + + const selectedProjection = computed({ + get: () => currentEpsgSystem.value.code, + set: (value) => { + const index = availableProjections.value.findIndex( + ({ code }) => code === value + ) + if (index !== -1) { + selectedProjectionIndex.value = index + return + } + console.error(`EPSG code ${value} not found in available projections.`) + }, + }) + + const formattedPointerPosition = computed(() => + pointerPosition.value.length + ? getFormattedCoordinate(pointerPosition.value) + : 'X, Y' + ) + + function getFormattedCoordinate(coordinate: Coordinate) { + const mapProjection = coreStore.map.getView().getProjection().getCode() + return createStringXY(currentEpsgSystem.value.decimals)( + transform(coordinate, mapProjection, selectedProjection.value) + ) + } + + const updatePointerPosition = ({ coordinate }) => + (pointerPosition.value = coordinate) + + function setupPlugin() { + coreStore.map.on('pointermove', updatePointerPosition) + coreStore.addToContextMenu({ + id: 'pointerPosition', + icon: 'kern-icon--point-scan', + text: 'contextMenu', + textNs: PluginId, + callback: (coordinate) => { + const onError = () => { + notifyUser( + 'error', + t(($) => $.toast.error, { ns: PluginId }) + ) + } + // navigator.clipboard is only available in secure contexts + if (!window.isSecureContext) { + onError() + return + } + navigator.clipboard + .writeText(getFormattedCoordinate(coordinate)) + .then(() => { + notifyUser( + 'success', + t(($) => $.toast.success, { ns: PluginId }) + ) + }) + .catch(onError) + }, + }) + } + + function teardownPlugin() { + coreStore.map.un('pointermove', updatePointerPosition) + coreStore.removeFromContextMenu('pointerPosition') + } + return { + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + + /** + * Offers last pointer position as formatted string `X, Y` in selected + * EPSG system with decimal cut-off applied. + */ + formattedPointerPosition, + + /** + * Array of available projections; either configured set or + * {@link MasterportalApiConfiguration.namedProjections | `mapConfiguration.namedProjections`}. + * @alpha + */ + availableProjections, + + /** + * Currently selected projection as EPSG code, e.g. `EPSG:4326`. + * @alpha + */ + selectedProjection, + } + } +) + +if (import.meta.hot) { + import.meta.hot.accept( + acceptHMRUpdate(usePointerPositionStore, import.meta.hot) + ) +} diff --git a/src/plugins/pointerPosition/types.ts b/src/plugins/pointerPosition/types.ts new file mode 100644 index 0000000000..50fb1f308b --- /dev/null +++ b/src/plugins/pointerPosition/types.ts @@ -0,0 +1,30 @@ +import type { PluginOptions } from '@/core' + +export const PluginId = 'pointerPosition' + +export interface PointerPositionProjection { + /** + * Configured codes must be defined via the core's configuration field + * {@link MasterportalApiConfiguration.namedProjections | `mapConfiguration.namedProjections`} or its default value. + */ + code: `EPSG:${string}` + + /** + * Decimal count to be displayed for the projection. + * + * @defaultValue 4 + */ + decimals?: number +} + +export interface PointerPositionPluginOptions extends PluginOptions { + /** + * List of which projections from the {@link MasterportalApiConfiguration.namedProjections | `mapConfiguration.namedProjections`} to + * use, i.e., only a subset can be chosen here. If not given, all EPSG + * systems configured in {@link MasterportalApiConfiguration.namedProjections | `mapConfiguration.namedProjections`} will be chosen. + * In both cases, the coordinate reference system that is first in the + * list will be used as initial selection. If only one system is + * available, the selection element will be omitted. + */ + projections?: PointerPositionProjection[] +} diff --git a/src/plugins/reverseGeocoder/index.ts b/src/plugins/reverseGeocoder/index.ts new file mode 100644 index 0000000000..fb6f0e31c6 --- /dev/null +++ b/src/plugins/reverseGeocoder/index.ts @@ -0,0 +1,28 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/reverseGeocoder + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { ReverseGeocoderPluginOptions } from './types' + +import { useReverseGeocoderStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..2f4f83b63a --- /dev/null +++ b/src/plugins/reverseGeocoder/store.ts @@ -0,0 +1,268 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/reverseGeocoder/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Mock } from 'vitest' +import type { Reactive } from 'vue' +import type { + ReverseGeocoderFeature, + ReverseGeocoderPluginOptions, +} from './types' + +import { easeOut } from 'ol/easing' +import { Point } from 'ol/geom' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, toRaw } from 'vue' + +import { useStoreWatcher } from '@/composables/useStoreWatcher' +import { useCoreStore } from '@/core/stores' +import { indicateLoading } from '@/lib/indicateLoading' + +import { PluginId } from './types' +import { reverseGeocodeNominatim } from './utils/reverseGeocodeNominatim' +import { reverseGeocodeWps } from './utils/reverseGeocodeWps' + +/* 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 abortController = ref(null) + + const configuration = computed( + () => coreStore.configuration[PluginId] as ReverseGeocoderPluginOptions + ) + + useStoreWatcher( + () => configuration.value.coordinateSources || [], + async (value: unknown) => { + const coordinate = value as [number, number] | null + if (coordinate) { + await reverseGeocode(coordinate) + } + }, + { immediate: true, target: configuration.value.addressTarget } + ) + + function setupPlugin() {} + + function teardownPlugin() {} + + 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() + if (abortController.value) { + abortController.value.abort() + abortController.value = null + } + abortController.value = new AbortController() + const signal = toRaw(abortController.value.signal) + try { + const reverseGeocodeUtil = { + wps: (params) => + reverseGeocodeWps({ + ...params, + serviceEpsg: configuration.value.epsg || 'EPSG:25832', + }), + nominatim: reverseGeocodeNominatim, + }[configuration.value.type] + const feature = await reverseGeocodeUtil({ + url: configuration.value.url, + coordinate, + epsg: coreStore.configuration.epsg, + signal, + }) + 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) { + if (!signal.aborted) { + 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 */ + abortController, + + /** @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') + const reverseGeocodeUtilFile = await import('./utils/reverseGeocodeWps') + 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, 'reverseGeocodeWps') + .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 pluginStores = { + pins: reactive({ + coordinate: null, + }), + } + const coreStore = reactive({ + configuration: { + epsg: 'EPSG:25832', + [PluginId]: { + type: 'wps', + url: 'https://wps.example', + coordinateSources: [{ plugin: 'pins', key: 'coordinate' }], + addressTarget: { key: 'addressTarget' }, + zoomTo: 99, + }, + }, + map: { + getView: () => ({ fit }), + }, + addressTarget: vi.fn(), + getPluginStore: (plugin) => pluginStores[plugin] || null, + }) + // @ts-expect-error | Mocking useCoreStore + vi.spyOn(useCoreStoreFile, 'useCoreStore').mockReturnValue(coreStore) + await use({ ...coreStore, pluginStores }) + }, + { 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, + store, + }) => { + const pluginStore = coreStore.pluginStores as { + pins: { + coordinate: [number, number] | null + } + } + pluginStore.pins.coordinate = [1, 2] + await new Promise((resolve) => setTimeout(resolve)) + expect(reverseGeocodeUtil).toHaveBeenCalledExactlyOnceWith({ + url: 'https://wps.example', + coordinate: [1, 2], + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: store.abortController?.signal, + }) + }) + + 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).toHaveBeenCalledExactlyOnceWith(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 0000000000..a57f01ca0b --- /dev/null +++ b/src/plugins/reverseGeocoder/types.ts @@ -0,0 +1,53 @@ +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 { + /** + * Type of reverse geocoding service. + */ + type: 'wps' | 'nominatim' + + /** + * URL of a WPS service to use for reverse geocoding. + */ + url: string + + /** + * Store actions 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[] + + /** + * EPSG code of the coordinate system used by the service. + * Considered only if {@link ReverseGeocoderPluginOptions.type | type} is set to `'wps'`. + * + * @defaultValue `'EPSG:25832'` + */ + epsg?: string + + /** + * 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/reverseGeocodeNominatim.ts b/src/plugins/reverseGeocoder/utils/reverseGeocodeNominatim.ts new file mode 100644 index 0000000000..775d7c404b --- /dev/null +++ b/src/plugins/reverseGeocoder/utils/reverseGeocodeNominatim.ts @@ -0,0 +1,98 @@ +import type { FeatureCollection, Point } from 'geojson' +import type { ReverseGeocoderFeature } from '../types' + +import { transform as transformCoordinate } from 'ol/proj' + +interface NominatimReverseGeocodeProperties { + address: { + house_number?: string + road?: string + hamlet?: string + village?: string + town?: string + suburb?: string + city_district?: string + city?: string + county?: string + state_district?: string + state?: string + // eslint-disable-next-line @typescript-eslint/naming-convention + 'ISO3166-2-lvl4'?: string + postcode?: string + country?: string + country_code?: string + } + category: string + display_name: string + importance: number + licence: string + name: string + osm_id: string + osm_type: string + place_id: number + type: string + extratags?: Record + icon?: string + place_rank?: number +} + +export async function reverseGeocodeNominatim({ + url, + coordinate, + epsg, + signal, +}: { + url: string + coordinate: [number, number] + epsg: string + signal: AbortSignal +}): Promise { + const searchCoordinate = transformCoordinate( + coordinate, + epsg, + 'EPSG:4326' + ) as [number, number] + + const fetchUrl = new URL(url) + fetchUrl.searchParams.set('lat', searchCoordinate[1].toString()) + fetchUrl.searchParams.set('lon', searchCoordinate[0].toString()) + fetchUrl.searchParams.set('format', 'geojson') + + const result: FeatureCollection = + await fetch(fetchUrl, { signal }).then((response) => response.json()) + + const feature = result.features[0] + if (!feature) { + throw new Error('No features returned from Nominatim reverse geocode') + } + const { properties } = feature + + return { + type: 'reverse_geocoded', + title: [ + [properties.address.road, properties.address.house_number] + .filter((x) => x) + .join(' '), + properties.address.town || + properties.address.city || + properties.address.village, + ] + .filter((x) => x) + .join(', '), + 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: transformCoordinate( + feature.geometry.coordinates as [number, number], + 'EPSG:4326', + epsg + ), + type: 'Point', + }, + } +} diff --git a/src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts b/src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts new file mode 100644 index 0000000000..f30d280672 --- /dev/null +++ b/src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts @@ -0,0 +1,236 @@ +import type { Coordinate } from 'ol/coordinate' +import type { ReverseGeocoderFeature } from '../types' + +import { transform as transformCoordinate } from 'ol/proj' + +const buildPostBody = ([x, y]: Coordinate) => ` + ReverseGeocoder.fmw + + + X + + ${x} + + + + Y + + ${y} + + + +` + +function getTextContent(parent: Element, localName: string) { + return parent.getElementsByTagNameNS('*', localName)[0]?.textContent ?? '' +} + +export async function reverseGeocodeWps({ + url, + coordinate, + epsg, + serviceEpsg, + signal, +}: { + url: string + coordinate: [number, number] + epsg: string + serviceEpsg: string + signal: AbortSignal +}): Promise { + const response = await fetch(url, { + method: 'POST', + body: buildPostBody(transformCoordinate(coordinate, epsg, serviceEpsg)), + signal, + }) + + const doc = new DOMParser().parseFromString(await response.text(), 'text/xml') + + const parseError = doc.querySelector('parsererror') + if (parseError) { + throw new Error(`Failed to parse XML response: ${parseError.textContent}.`) + } + + const address = doc.getElementsByTagNameNS('*', 'Adresse')[0] + if (!address) { + throw new Error('Response does not contain an "Adresse" element.') + } + + // NOTE: Property names come from the WPS. + /* eslint-disable @typescript-eslint/naming-convention */ + const properties = { + Distanz: parseFloat(getTextContent(address, 'Distanz')), + Hausnr: parseInt(getTextContent(address, 'Hausnr'), 10), + Plz: parseInt(getTextContent(address, 'Plz'), 10), + Strasse: getTextContent(address, 'Strasse'), + XKoordinate: parseFloat(getTextContent(address, 'XKoordinate')), + YKoordinate: parseFloat(getTextContent(address, 'YKoordinate')), + Zusatz: getTextContent(address, 'Zusatz'), + } + /* eslint-enable @typescript-eslint/naming-convention */ + + return { + 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', + }, + } +} + +if (import.meta.vitest) { + const { beforeEach, expect, test, vi } = import.meta.vitest + const { + default: { registerProjections }, + } = await import('@masterportal/masterportalapi/src/crs') + const { + default: { namedProjections }, + } = await import('@/core/utils/defaults') + + beforeEach(() => { + vi.restoreAllMocks() + registerProjections(namedProjections) + }) + + 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 throws on invalid XML', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + text: () => Promise.resolve(' { + vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + text: () => Promise.resolve(''), + } as Response) + const abortController = new AbortController() + + await expect( + reverseGeocodeWps({ + url: testUrl, + coordinate: testCoordinates, + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: abortController.signal, + }) + ).rejects.toThrow('Response does not contain an "Adresse" element.') + }) + + test('reverseGeocode works with Hamburg-WPS-style', async () => { + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ + text: () => Promise.resolve(testResponse), + } as Response) + + const abortController = new AbortController() + const feature = await reverseGeocodeWps({ + url: testUrl, + coordinate: testCoordinates, + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: abortController.signal, + }) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledWith(testUrl, { + method: 'POST', + body: buildPostBody(testCoordinates), + signal: abortController.signal, + }) + + 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/routing/components/RoutingDetails.ce.vue b/src/plugins/routing/components/RoutingDetails.ce.vue new file mode 100644 index 0000000000..37ee84e92f --- /dev/null +++ b/src/plugins/routing/components/RoutingDetails.ce.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/plugins/routing/components/RoutingInput.ce.vue b/src/plugins/routing/components/RoutingInput.ce.vue new file mode 100644 index 0000000000..72743b834e --- /dev/null +++ b/src/plugins/routing/components/RoutingInput.ce.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/plugins/routing/components/RoutingOptions.ce.vue b/src/plugins/routing/components/RoutingOptions.ce.vue new file mode 100644 index 0000000000..ff2620fadf --- /dev/null +++ b/src/plugins/routing/components/RoutingOptions.ce.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/plugins/routing/components/RoutingWrapper.ce.vue b/src/plugins/routing/components/RoutingWrapper.ce.vue new file mode 100644 index 0000000000..be032fc235 --- /dev/null +++ b/src/plugins/routing/components/RoutingWrapper.ce.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/src/plugins/routing/composables/useMarkerLayer.ts b/src/plugins/routing/composables/useMarkerLayer.ts new file mode 100644 index 0000000000..df18d55d90 --- /dev/null +++ b/src/plugins/routing/composables/useMarkerLayer.ts @@ -0,0 +1,45 @@ +import type { Map } from 'ol' +import type { Coordinate } from 'ol/coordinate' +import type VectorSource from 'ol/source/Vector' +import type { Ref } from 'vue' + +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import VectorLayer from 'ol/layer/Vector' +import { Circle, Fill, Stroke, Style } from 'ol/style' +import { onScopeDispose, watch } from 'vue' + +export function useMarkerLayer( + map: Map, + markerSource: VectorSource, + route: Ref +) { + const layer = new VectorLayer({ + source: markerSource, + style: new Style({ + image: new Circle({ + radius: 6, + fill: new Fill({ color: '#1E90FF' }), + stroke: new Stroke({ color: 'white', width: 2 }), + }), + }), + }) + + map.addLayer(layer) + onScopeDispose(() => { + map.removeLayer(layer) + }) + + watch(route, () => { + markerSource.clear() + route.value.forEach((coordinate) => { + if (coordinate.length) { + markerSource.addFeature( + new Feature({ + geometry: new Point(coordinate), + }) + ) + } + }) + }) +} diff --git a/src/plugins/routing/composables/useRouteLayer.ts b/src/plugins/routing/composables/useRouteLayer.ts new file mode 100644 index 0000000000..94cdb68cd1 --- /dev/null +++ b/src/plugins/routing/composables/useRouteLayer.ts @@ -0,0 +1,20 @@ +import type { Map } from 'ol' +import type VectorSource from 'ol/source/Vector' + +import VectorLayer from 'ol/layer/Vector' +import { Stroke, Style } from 'ol/style' +import { onScopeDispose } from 'vue' + +export function useRouteLayer(map: Map, routeSource: VectorSource) { + const layer = new VectorLayer({ + source: routeSource, + style: new Style({ + stroke: new Stroke({ color: 'blue', width: 6 }), + }), + }) + + map.addLayer(layer) + onScopeDispose(() => { + map.removeLayer(layer) + }) +} diff --git a/src/plugins/routing/index.ts b/src/plugins/routing/index.ts new file mode 100644 index 0000000000..5309248879 --- /dev/null +++ b/src/plugins/routing/index.ts @@ -0,0 +1,44 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/routing + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { RoutingPluginOptions } from './types' + +import component from './components/RoutingWrapper.ce.vue' +import locales from './locales' +import { useRoutingStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which offers routing functionality to the user. + * + * A user can select multiple waypoints by clicking on the map. + * If at least two waypoints have been added, the route is automatically calculated and displayed on the map. + * + * The travel mode can be adjusted as well as the types of routes to avoid. + * Similarly, the route preference is set to `'recommended'` by default, but can be changed to `'fastest'` or `'shortest'`. + * + * Once a route is available, a detailed listing of every route segment is available including instructions, distance and duration. + * + * @remarks + * This plugin currently can only be used on larger devices. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginRouting( + options: RoutingPluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + icon: 'kern-icon-fill--assistant-direction', + storeModule: useRoutingStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/routing/locales.ts b/src/plugins/routing/locales.ts new file mode 100644 index 0000000000..1d9c425b5c --- /dev/null +++ b/src/plugins/routing/locales.ts @@ -0,0 +1,103 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the routing plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/routing + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +export const resourcesDe = { + title: 'Routenplaner', + label: { + aria: 'Durch Klicken in die Karte eine Koordinate als {{position}} auswählen.', + start: 'Start', + middle: 'Wegpunkt', + end: 'Ziel', + add: 'Wegpunkt hinzufügen', + remove: 'Wegpunkt entfernen', + travelMode: 'Fortbewegungsart', + preference: 'Bevorzugte Route', + avoid: 'Verkehrswege meiden', + reset: 'Zurücksetzen', + details: 'Details zur Route', + steps: 'Routenanweisungen', + }, + travelMode: { + car: 'Auto', + hgv: 'LKW', + bike: 'Fahrrad', + walking: 'Zu Fuß', + wheelchair: 'Rollstuhl', + }, + preference: { + recommended: 'Empfohlen', + fastest: 'Schnellste', + shortest: 'Kürzeste', + }, + avoid: { + highways: 'Autobahnen', + tollways: 'Mautstraßen', + ferries: 'Fähren', + }, + ariaLive: `Route berechnet: {{steps}} Schritte, {{duration}}, {{distance}}.`, + distance: 'Entfernung: {{distance}}', + duration: 'Dauer: {{duration}}', + noFeature: + 'Die Route konnte nicht ermittelt werden. Versuchen Sie es mit anderen Koordinaten.', +} as const + +export const resourcesEn = { + title: 'Route Planner', + label: { + aria: 'Add a coordinate as {{position}} by clicking in the map.', + start: 'Start', + middle: 'Waypoint', + end: 'Destination', + add: 'Add waypoint', + remove: 'Remove waypoint', + travelMode: 'Travel Mode', + preference: 'Preferred Route', + avoid: 'Types of routes to avoid', + reset: 'Reset', + details: 'Route Details', + steps: 'Route instructions', + }, + travelMode: { + car: 'Car', + hgv: 'Heavy Goods Vehicle', + bike: 'Bike', + walking: 'Walking', + wheelchair: 'Wheelchair', + }, + preference: { + recommended: 'Recommended', + fastest: 'Fastest', + shortest: 'Shortest', + }, + avoid: { + highways: 'Highways', + tollways: 'Tollways', + ferries: 'Ferries', + }, + ariaLive: `Route calculated: {{steps}} steps, {{duration}}, {{distance}}.`, + distance: 'Distance: {{distance}}', + duration: 'Duration: {{duration}}', + noFeature: 'Route could not be determined. Try different coordinates.', +} 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/routing/store.ts b/src/plugins/routing/store.ts new file mode 100644 index 0000000000..eceae2058b --- /dev/null +++ b/src/plugins/routing/store.ts @@ -0,0 +1,451 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/routing/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Coordinate } from 'ol/coordinate' +import type { Point } from 'ol/geom' +import type { + RoutingPluginOptions, + RoutingResponseData, + SelectableTravelMode, + TravelMode, +} from './types' + +import { t } from 'i18next' +import { Feature } from 'ol' +import { LineString } from 'ol/geom' +import Draw from 'ol/interaction/Draw' +import { transform } from 'ol/proj' +import VectorSource from 'ol/source/Vector' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { computedT } from '@/lib/computedT' + +import { useMarkerLayer } from './composables/useMarkerLayer' +import { useRouteLayer } from './composables/useRouteLayer' +import { PluginId } from './types' +import { handleErrors } from './utils/handleErrors' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for routing. + */ +/* eslint-enable tsdoc/syntax */ +export const useRoutingStore = defineStore('plugins/routing', () => { + const coreStore = useCoreStore() + + const routeSource = new VectorSource() + const markerSource = new VectorSource() + let abortController: AbortController | null = null + let draw: Draw | undefined + + const _currentlyFocusedInput = ref(-1) + const route = ref([[], []]) + const routingResponseData = ref(null) + const selectedPreference = ref('recommended') + const selectedRouteTypesToAvoid = ref([]) + const selectedTravelMode = ref('driving-car') + + const configuration = computed( + () => (coreStore.configuration.routing || {}) as RoutingPluginOptions + ) + const currentlyFocusedInput = computed({ + get: () => _currentlyFocusedInput.value, + set: (index) => { + _currentlyFocusedInput.value = index + + if (index !== -1) { + coreStore.maskInteraction( + 'routing', + 'click', + () => { + coreStore.map.addInteraction(draw as Draw) + }, + () => { + coreStore.map.removeInteraction(draw as Draw) + } + ) + } else { + coreStore.unmaskInteraction('routing', 'click') + } + }, + }) + const routeIncomplete = computed(() => + route.value.some((part) => part.length === 0) + ) + const routeAsWGS84 = computed(() => + route.value.map((coordinate) => + transform( + coordinate, + coreStore.map.getView().getProjection().getCode(), + 'EPSG:4326' + ) + ) + ) + const routeFeature = computed( + () => routingResponseData.value?.features[0] ?? null + ) + const showDetails = computed(() => routingResponseData.value !== null) + const url = computed( + () => configuration.value.url + selectedTravelMode.value + '/geojson' + ) + const displayPreferences = computed( + () => coreStore.configuration.routing?.displayPreferences || false + ) + const selectablePreferences = computed(() => + ['recommended', 'fastest', 'shortest'].map((value) => ({ + value, + label: computedT(() => t(($) => $.preference[value], { ns: PluginId })), + })) + ) + const displayRouteTypesToAvoid = computed( + () => coreStore.configuration.routing?.displayRouteTypesToAvoid || false + ) + const selectableRouteTypesToAvoid = computed(() => + selectedTravelMode.value === 'driving-car' || + selectedTravelMode.value === 'driving-hgv' + ? ['highways', 'tollways', 'ferries'] + : ['ferries'] + ) + const selectableTravelModes = computed( + () => + coreStore.configuration.routing?.selectableTravelModes || [ + 'driving-car', + 'cycling-regular', + 'foot-walking', + ] + ) + const travelModes = computed(() => + ( + [ + { + value: 'driving-car', + label: computedT(() => t(($) => $.travelMode.car, { ns: PluginId })), + icon: 'kern-icon--directions-car', + }, + { + value: 'driving-hgv', + label: computedT(() => t(($) => $.travelMode.hgv, { ns: PluginId })), + icon: 'kern-icon--local-shipping', + }, + { + value: 'cycling-regular', + label: computedT(() => t(($) => $.travelMode.bike, { ns: PluginId })), + icon: 'kern-icon--directions-bike', + }, + { + value: 'foot-walking', + label: computedT(() => + t(($) => $.travelMode.walking, { ns: PluginId }) + ), + icon: 'kern-icon--directions-walk', + }, + { + value: 'wheelchair', + label: computedT(() => + t(($) => $.travelMode.wheelchair, { ns: PluginId }) + ), + icon: 'kern-icon--accessible', + }, + ] as TravelMode[] + ).filter(({ value }) => selectableTravelModes.value.includes(value)) + ) + + function addCoordinateToRoute(coordinate: Coordinate) { + route.value = route.value.toSpliced( + currentlyFocusedInput.value, + 1, + coordinate + ) + } + + async function fetchRoute(signal: AbortSignal): Promise { + const response = await fetch(url.value, { + method: 'POST', + headers: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'Content-Type': 'application/json', + ...(configuration.value.apiKey && { + Authorization: configuration.value.apiKey, + }), + /* eslint-enable @typescript-eslint/naming-convention */ + }, + body: JSON.stringify({ + coordinates: routeAsWGS84.value, + geometry: true, + instructions: true, + language: coreStore.language, + options: { + avoid_features: selectedRouteTypesToAvoid.value, + }, + preference: selectedPreference.value, + units: 'm', + }), + signal, + }) + if (!response.ok) { + throw new Error( + 'Route could not be determined. Try different coordinates.' + ) + } + return response.json() + } + + async function getRoute() { + routeSource.clear() + if (abortController) { + abortController.abort() + } + abortController = new AbortController() + const { signal } = abortController + try { + routingResponseData.value = await fetchRoute(signal) + + if (!routeFeature.value) { + throw new Error(t(($) => $.noFeature, { ns: PluginId })) + } + routeSource.addFeature( + new Feature({ + geometry: new LineString( + routeFeature.value.geometry.coordinates.map((coordinate) => + transform( + coordinate, + 'EPSG:4326', + coreStore.map.getView().getProjection().getCode() + ) + ) + ), + }) + ) + } catch (error) { + if (!signal.aborted) { + handleErrors(error) + } + } + } + + function initializeDraw() { + draw = new Draw({ stopClick: true, type: 'Point' }) + draw.on('drawend', (e) => { + addCoordinateToRoute((e.feature.getGeometry() as Point).getCoordinates()) + coreStore.unmaskInteraction('routing', 'click') + currentlyFocusedInput.value = -1 + }) + } + + function updateFocus(event: Event) { + if (currentlyFocusedInput.value === -1) { + return + } + const path = event.composedPath() + const isRoutingInput = path.some( + (el) => + el instanceof HTMLElement && + el.id.startsWith('polar-plugin-routing-input-') + ) + if (!isRoutingInput && !path.includes(coreStore.map.getTargetElement())) { + currentlyFocusedInput.value = -1 + } + } + + watch( + [ + route, + selectedPreference, + selectedRouteTypesToAvoid, + selectedTravelMode, + () => coreStore.language, + ], + () => { + if (!routeIncomplete.value) { + void getRoute() + } + } + ) + watch(selectedTravelMode, () => { + selectedRouteTypesToAvoid.value = [] + }) + + useRouteLayer(coreStore.map, routeSource) + useMarkerLayer(coreStore.map, markerSource, route) + + function setupPlugin() { + initializeDraw() + // `pointerdown` handles mouse interaction while `focusin` handles keyboard + // navigation (e.g. tabbing) away from the routing inputs. + ;(coreStore.shadowRoot as ShadowRoot).addEventListener( + 'pointerdown', + updateFocus + ) + ;(coreStore.shadowRoot as ShadowRoot).addEventListener( + 'focusin', + updateFocus + ) + } + + function teardownPlugin() { + ;(coreStore.shadowRoot as ShadowRoot).removeEventListener( + 'pointerdown', + updateFocus + ) + ;(coreStore.shadowRoot as ShadowRoot).removeEventListener( + 'focusin', + updateFocus + ) + + reset() + + if (draw) { + coreStore.unmaskInteraction('routing', 'click') + draw = undefined + } + } + + function reset() { + route.value = [[], []] + currentlyFocusedInput.value = -1 + selectedPreference.value = 'recommended' + selectedTravelMode.value = 'driving-car' + selectedRouteTypesToAvoid.value = [] + routingResponseData.value = null + routeSource.clear() + markerSource.clear() + + if (abortController) { + abortController.abort() + abortController = null + } + } + + function setRoute(index: number, remove = false) { + route.value = remove + ? route.value.toSpliced(index, 1) + : route.value.toSpliced(index, 0, []) + } + + return { + /** + * The coordinates selected by the user. + * If all coordinate pairs are filled, a route is requested. + */ + route, + + /** + * The response of the routing service depending on the {@link route} and + * other chosen options. + */ + routingResponseData, + + /** + * The input that currently has focus. + * Adds a draw interaction to the map if this value is not `-1` so the user + * can add a coordinate for the selected waypoint. + * + * @alpha + */ + currentlyFocusedInput, + + /** + * The preferences of the route type that a user can select. + * + * @alpha + */ + selectablePreferences, + + /** + * The types of routes that a user can select to avoid on their route. + * + * @alpha + */ + selectableRouteTypesToAvoid, + + /** + * The routing preference selected by the user. + * + * @alpha + */ + selectedPreference, + + /** + * The types of routes the user wishes to avoid on their route. + * + * @alpha + */ + selectedRouteTypesToAvoid, + + /** + * The selected mode of transportation by the user. + * + * @alpha + */ + selectedTravelMode, + + /** + * The modes of transportation a user can select. + * Is constrained by {@link RoutingPluginOptions.selectableTravelModes}. + * + * @alpha + */ + travelModes, + + /** + * Resets the state and clears the route layer source. + * + * @alpha + */ + reset, + + /** + * Inserts an empty coordinate pair into the route. + * + * @alpha + */ + setRoute, + + /** + * Value of {@link RoutingPluginOptions.displayPreferences}. + * + * @internal + */ + displayPreferences, + + /** + * Value of {@link RoutingPluginOptions.displayRouteTypesToAvoid}. + * + * @internal + */ + displayRouteTypesToAvoid, + + /** + * The feature of the {@link routingResponseData}. + * The ORS only returns one feature that is instead split in 1 to n segments. + * + * @internal + */ + routeFeature, + + /** + * Whether the route details should be displayed. + * Is `true` if {@link routingResponseData} is not `null`. + * + * @internal + */ + showDetails, + + /** @internal */ + setupPlugin, + + /** @internal */ + teardownPlugin, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useRoutingStore, import.meta.hot)) +} diff --git a/src/plugins/routing/types.ts b/src/plugins/routing/types.ts new file mode 100644 index 0000000000..baedbedf59 --- /dev/null +++ b/src/plugins/routing/types.ts @@ -0,0 +1,78 @@ +import type { + FeatureCollection, + LineString as GeoJsonLineString, +} from 'geojson' +import type { Ref } from 'vue' +import type { Icon, PluginOptions } from '@/core' + +export const PluginId = 'routing' + +export type SelectableTravelMode = + | 'driving-car' + | 'driving-hgv' + | 'cycling-regular' + | 'foot-walking' + | 'wheelchair' + +export interface TravelMode { + icon: Icon + label: Ref + value: SelectableTravelMode +} + +interface RouteStep { + distance: number + duration: number + instruction: string +} + +export interface RouteSegment { + distance: number + duration: number + steps: RouteStep[] +} + +export type RoutingResponseData = FeatureCollection< + GeoJsonLineString, + { segments: RouteSegment[] } +> + +export interface RoutingPluginOptions extends PluginOptions { + /** + * The type of routing service to be used. + * Currently, only the [OpenRouteService](https://openrouteservice.org/) (`'ors'`) is implemented. + */ + type: 'ors' + + /** + * The url of the routing service to be used. + */ + url: string + + /** + * The API key to access the routing service. + * Required for OpenRouteService if not already covered by the given {@link RoutingPluginOptions.url | `url`}. + */ + apiKey?: string + + /** + * Defines whether the user can choose their route preference. + * + * @defaultValue `false` + */ + displayPreferences?: boolean + + /** + * Defines whether the user can select types of routes to avoid. + * + * @defaultValue `false` + */ + displayRouteTypesToAvoid?: boolean + + /** + * List of available travel modes. + * + * @defaultValue `['driving-car', 'cycling-regular', 'foot-walking']` + */ + selectableTravelModes?: SelectableTravelMode[] +} diff --git a/src/plugins/routing/utils/handleErrors.ts b/src/plugins/routing/utils/handleErrors.ts new file mode 100644 index 0000000000..3c2b2dbdc3 --- /dev/null +++ b/src/plugins/routing/utils/handleErrors.ts @@ -0,0 +1,12 @@ +import { notifyUser } from '@/lib/notifyUser' + +export function handleErrors(error: unknown) { + let errorMessage = '' + if (error instanceof Error) { + errorMessage = error.message + console.error(error.message) + } else { + console.error('Unexpected error', error) + } + notifyUser('error', errorMessage) +} diff --git a/src/plugins/scale/components/ScaleWidget.ce.vue b/src/plugins/scale/components/ScaleWidget.ce.vue new file mode 100644 index 0000000000..0a20b2a0be --- /dev/null +++ b/src/plugins/scale/components/ScaleWidget.ce.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/plugins/scale/index.ts b/src/plugins/scale/index.ts new file mode 100644 index 0000000000..5ac717e36f --- /dev/null +++ b/src/plugins/scale/index.ts @@ -0,0 +1,36 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/scale + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { ScalePluginOptions } from './types' + +import component from './components/ScaleWidget.ce.vue' +import locales from './locales' +import { useScaleStore } from './store' +import { PluginId } from './types' + +export { beautifyScale } from './utils/beautifyScale' +export { calculateScaleFromResolution } from './utils/calculateScaleFromResolution' + +/** + * Creates a plugin that shows the scale as "1 : x", relative to a line, and/or as a scale selection element. + * Its options are defined by the zoom options defined by configuration of the {@link MasterportalApiConfiguration.options | `mapConfiguration.options`}. + * + * @returns Plugin for use with {@link addPlugin}. + */ +export default function pluginScale( + options: ScalePluginOptions +): PluginContainer { + return { + id: PluginId, + component, + locales, + options, + storeModule: useScaleStore as PolarPluginStore, + } +} + +export * from './types' diff --git a/src/plugins/scale/locales.ts b/src/plugins/scale/locales.ts new file mode 100644 index 0000000000..2f60b57329 --- /dev/null +++ b/src/plugins/scale/locales.ts @@ -0,0 +1,51 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the scale plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/scale + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +/** + * German locales for scale plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + label: 'Skala', + scaleSwitcher: 'Maßstab ändern', + to: 'Eins zu {{number}}', +} as const + +/** + * English locales for scale plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + label: 'Scale', + scaleSwitcher: 'Change scale', + to: 'One to {{number}}', +} as const + +/** + * Scale 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/scale/store.ts b/src/plugins/scale/store.ts new file mode 100644 index 0000000000..1375d00867 --- /dev/null +++ b/src/plugins/scale/store.ts @@ -0,0 +1,132 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/scale/store + */ +/* eslint-enable tsdoc/syntax */ + +import { t } from 'i18next' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, onScopeDispose, ref } from 'vue' + +import { useCoreStore } from '@/core/stores' +import { computedT } from '@/lib/computedT' +import { useDpi } from '@/lib/dpi' + +import { PluginId } from './types' +import { beautifyScale } from './utils/beautifyScale' +import { calculateScaleFromResolution } from './utils/calculateScaleFromResolution' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for the scale. + */ +/* eslint-enable tsdoc/syntax */ +export const useScaleStore = defineStore('plugins/scale', () => { + const coreStore = useCoreStore() + const { dpi } = useDpi() + + const scaleValue = ref(0) + + const scaleToOne = computed(() => + beautifyScale(scaleValue.value, coreStore.language) + ) + + const scaleWithUnit = computed(() => { + const scaleNumber = Math.round(0.02 * scaleValue.value) + + return scaleNumber >= 1000 + ? `${Math.round(scaleNumber / 100) / 10}km` + : `${scaleNumber}m` + }) + + const zoomOptions = computed(() => + coreStore.configuration.options.map((option) => { + const label = beautifyScale( + calculateScaleFromResolution( + coreStore.map.getView().getProjection().getUnits(), + option.resolution, + dpi.value + ), + coreStore.language + ) + + return { + ...option, + label, + ariaLabel: computedT(() => + t(($) => $.to, { + number: label.split(':')[1]?.replace(/[,.]/g, '').trim() ?? '', + ns: PluginId, + }) + ).value, + value: option.zoomLevel, + } + }) + ) + + const layoutTag = computed(() => coreStore.configuration.scale?.layoutTag) + + const showScaleSwitcher = computed( + () => + coreStore.configuration.scale?.showScaleSwitcher && + zoomOptions.value.length > 0 + ) + + function updateScale(): void { + const unit = coreStore.map.getView().getProjection().getUnits() + const resolution: number = coreStore.map.getView().getResolution() as number + const scale: number = calculateScaleFromResolution( + unit, + resolution, + dpi.value + ) + + scaleValue.value = scale + } + + coreStore.map.on('moveend', updateScale) + + onScopeDispose(() => { + coreStore.map.un('moveend', updateScale) + }) + + return { + /** + * If {@link MapConfiguration.layout | `mapConfiguration.layout`} is set to `'nineRegions'`, + * then this parameter declares the positioning of the ScaleWidget. + * @alpha + */ + layoutTag, + + /** + * A string of format `1 : x`, with x being a number, indicating resolution. Rounded value. + * @alpha + */ + scaleToOne, + + /** + * A string of format `xy`, with x being a number, and y being the unit (either `m` or `km`), indicating + * the actual width of 2 on-screen cm. Rounded value. + * @alpha + */ + scaleWithUnit, + + /** + * Indicates whether, instead of a `1 : x` scale, a switch element (e.g. select) should be displayed. + * @alpha + */ + showScaleSwitcher, + + /** + * Available options for zoom levels. + * @alpha + */ + zoomOptions, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useScaleStore, import.meta.hot)) +} diff --git a/src/plugins/scale/types.ts b/src/plugins/scale/types.ts new file mode 100644 index 0000000000..d7e773fa63 --- /dev/null +++ b/src/plugins/scale/types.ts @@ -0,0 +1,13 @@ +import type { PluginOptions } from '@/core' + +export const PluginId = 'scale' + +export interface ScalePluginOptions extends PluginOptions { + /** + * If set to `true`, the `1 : x` text will be replaced with a select element + * that allows switching between scale values. Requires the configuration + * parameter {@link MapConfiguration.options} to be set. + * @defaultValue false + */ + showScaleSwitcher?: boolean +} diff --git a/src/plugins/scale/utils/beautifyScale.ts b/src/plugins/scale/utils/beautifyScale.ts new file mode 100644 index 0000000000..0190d90a9e --- /dev/null +++ b/src/plugins/scale/utils/beautifyScale.ts @@ -0,0 +1,14 @@ +/** + * Rounds the scale number so that the scale can be displayed in a beautified format in the map. + * @param scaleNumber - the scale to be beautified + * @param language - the language according to which the number is formatted + * @returns the scale in a beautified format (=rounded based on its value) + */ +export const beautifyScale = (scaleNumber: number, language: string) => + `1 : ${new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format( + scaleNumber > 10000 + ? Math.round(scaleNumber / 500) * 500 + : scaleNumber > 1000 + ? Math.round(scaleNumber / 50) * 50 + : scaleNumber + )}` diff --git a/src/plugins/scale/utils/calculateScaleFromResolution.ts b/src/plugins/scale/utils/calculateScaleFromResolution.ts new file mode 100644 index 0000000000..9c8f58d175 --- /dev/null +++ b/src/plugins/scale/utils/calculateScaleFromResolution.ts @@ -0,0 +1,24 @@ +import type { Units } from 'ol/proj/Units' + +import { METERS_PER_UNIT } from 'ol/proj/Units' + +/** + * Calculates the current scale from given parameters. + * @param unit - projection units + * @param resolution - resolution + * @param dpi - device dpi + * @returns calculated scale + */ +export function calculateScaleFromResolution( + unit: Units, + resolution: number, + dpi: number +) { + // inchesPerMeter is used to convert the resolution (distance in meters) to + // inches per pixel (1in = 96px) so that it can be multiplied with dpi. + const inchesPerMeter = 39.37 + const scale = Math.round( + resolution * METERS_PER_UNIT[unit] * inchesPerMeter * dpi + ) + return scale +} diff --git a/src/plugins/toast/components/ToastContainer.ce.vue b/src/plugins/toast/components/ToastContainer.ce.vue new file mode 100644 index 0000000000..82bf684006 --- /dev/null +++ b/src/plugins/toast/components/ToastContainer.ce.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/src/plugins/toast/components/ToastUI.ce.vue b/src/plugins/toast/components/ToastUI.ce.vue new file mode 100644 index 0000000000..e61c54a6ea --- /dev/null +++ b/src/plugins/toast/components/ToastUI.ce.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/plugins/toast/components/ToastUI.spec.ts b/src/plugins/toast/components/ToastUI.spec.ts new file mode 100644 index 0000000000..b34809c1f3 --- /dev/null +++ b/src/plugins/toast/components/ToastUI.spec.ts @@ -0,0 +1,62 @@ +import type { VueWrapper } from '@vue/test-utils' + +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import { test as _test, expect, vi } from 'vitest' +import { nextTick } from 'vue' + +import { mockedT } from '@/test/utils/mockI18n' + +import { useToastStore } from '../store' +import ToastUI from './ToastUI.ce.vue' + +/* 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 0000000000..b787d2ab56 --- /dev/null +++ b/src/plugins/toast/index.ts @@ -0,0 +1,36 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/toast + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { ToastPluginOptions } from './types' + +import component from './components/ToastContainer.ce.vue' +import locales from './locales' +import { useToastStore } from './store' +import { PluginId } from './types' + +/** + * 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 0000000000..8a0c0acfe3 --- /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 0000000000..ca01968483 --- /dev/null +++ b/src/plugins/toast/store.ts @@ -0,0 +1,220 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/toast/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { Reactive } from 'vue' +import type { + Toast, + ToastOptions, + ToastPluginOptions, + ToastSeverity, + ToastTheme, +} from './types' + +import { toMerged } from 'es-toolkit' +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed, ref, toRaw } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' + +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') + + /* 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 0000000000..7df62f0f9d --- /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/plugins/zoom/components/ZoomButtons.ce.vue b/src/plugins/zoom/components/ZoomButtons.ce.vue new file mode 100644 index 0000000000..26b1cce9d9 --- /dev/null +++ b/src/plugins/zoom/components/ZoomButtons.ce.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/plugins/zoom/components/ZoomSlider.ce.vue b/src/plugins/zoom/components/ZoomSlider.ce.vue new file mode 100644 index 0000000000..6b798081fb --- /dev/null +++ b/src/plugins/zoom/components/ZoomSlider.ce.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/plugins/zoom/components/ZoomUI.ce.vue b/src/plugins/zoom/components/ZoomUI.ce.vue new file mode 100644 index 0000000000..30f48cd1ee --- /dev/null +++ b/src/plugins/zoom/components/ZoomUI.ce.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/plugins/zoom/index.ts b/src/plugins/zoom/index.ts new file mode 100644 index 0000000000..98f493da85 --- /dev/null +++ b/src/plugins/zoom/index.ts @@ -0,0 +1,32 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/zoom + */ +/* eslint-enable tsdoc/syntax */ + +import type { PluginContainer, PolarPluginStore } from '@/core' +import type { ZoomPluginOptions } from './types' + +import component from './components/ZoomUI.ce.vue' +import locales from './locales' +import { useZoomStore } from './store' +import { PluginId } from './types' + +/** + * Creates a plugin which provides UI and functionality regarding zooming. + * + * @returns Plugin for use with {@link addPlugin} + */ +export default function pluginZoom( + options: ZoomPluginOptions = {} +): PluginContainer { + return { + id: PluginId, + component, + locales, + storeModule: useZoomStore as PolarPluginStore, + options, + } +} + +export * from './types' diff --git a/src/plugins/zoom/locales.ts b/src/plugins/zoom/locales.ts new file mode 100644 index 0000000000..e36ed9ca21 --- /dev/null +++ b/src/plugins/zoom/locales.ts @@ -0,0 +1,51 @@ +/* eslint-disable tsdoc/syntax */ +/** + * This is the documentation for the locales keys in the zoom plugin. + * These locales are *NOT* exported, but documented only. + * + * @module locales/plugins/zoom + */ +/* eslint-enable tsdoc/syntax */ + +import type { Locale } from '@/core' + +/** + * German locales for zoom plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesDe = { + zoomIn: 'Hinein zoomen', + zoomOut: 'Heraus zoomen', + slider: 'Zoomstufe wählen', +} as const + +/** + * English locales for zoom plugin. + * For overwriting these values, use the plugin's ID as namespace. + */ +export const resourcesEn = { + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + slider: 'Choose zoom level', +} as const + +/** + * Zoom 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/zoom/store.ts b/src/plugins/zoom/store.ts new file mode 100644 index 0000000000..c88aa97445 --- /dev/null +++ b/src/plugins/zoom/store.ts @@ -0,0 +1,171 @@ +/* eslint-disable tsdoc/syntax */ +/** + * @module \@polar/polar/plugins/zoom/store + */ +/* eslint-enable tsdoc/syntax */ + +import type { ComputedRef } from 'vue' +import type { ZoomPluginOptions } from './types' + +import { acceptHMRUpdate, defineStore } from 'pinia' +import { computed } from 'vue' + +import { useCoreStore } from '@/core/stores' + +import { PluginId } from './types' + +/* eslint-disable tsdoc/syntax */ +/** + * @function + * + * Plugin store for zoom buttons and zoom slider. + */ +/* eslint-enable tsdoc/syntax */ +export const useZoomStore = defineStore('plugins/zoom', () => { + const coreStore = useCoreStore() + + const configuration = computed( + () => coreStore.configuration[PluginId] as ZoomPluginOptions + ) + + const zoomLevel = computed({ + get: () => coreStore.zoom, + set: (value) => { + coreStore.zoom = value + }, + }) + + const layoutTag = computed(() => configuration.value.layoutTag ?? '') + + const zoomLevels = computed(() => + coreStore.configuration.options.map((option) => option.zoomLevel) + ) + const minimumZoomLevel = computed(() => Math.min(...zoomLevels.value)) + const maximumZoomLevel = computed(() => Math.max(...zoomLevels.value)) + const minimumZoomLevelActive = computed( + () => zoomLevel.value <= minimumZoomLevel.value + ) + const maximumZoomLevelActive = computed( + () => zoomLevel.value >= maximumZoomLevel.value + ) + + const renderType = computed( + () => configuration.value.renderType ?? 'independent' + ) + + const renderHorizontal = computed( + () => + (renderType.value === 'iconMenu' && coreStore.deviceIsHorizontal) || + (renderType.value === 'independent' && + ['TOP_MIDDLE', 'BOTTOM_MIDDLE'].includes(layoutTag.value)) + ) + + const tooltipPosition = computed(() => + renderType.value === 'independent' + ? layoutTag.value.includes('RIGHT') + ? 'left' + : 'right' + : coreStore.getPluginStore('iconMenu')?.layoutTag.includes('RIGHT') + ? 'left' + : 'right' + ) as ComputedRef<'left' | 'right'> + + const zoomUiVisible = computed( + () => configuration.value.showMobile || !coreStore.hasSmallDisplay + ) + const zoomSliderVisible = computed(() => configuration.value.showZoomSlider) + + const zoomInIcon = computed( + () => configuration.value.icons?.zoomIn ?? 'kern-icon--add' + ) + const zoomOutIcon = computed( + () => configuration.value.icons?.zoomOut ?? 'kern-icon--remove' + ) + + return { + /** + * Current zoom level. + */ + zoomLevel, + + /** + * Minimum zoom level. + * + * @readonly + */ + minimumZoomLevel, + + /** + * Whether minimum zoom level is active. + * + * @readonly + */ + minimumZoomLevelActive, + + /** + * Maximum zoom level. + * + * @readonly + */ + maximumZoomLevel, + + /** + * Whether maximum zoom level is active. + * + * @readonly + */ + maximumZoomLevelActive, + + /** + * Whether zoom buttons and slider should be rendered. + * + * @alpha + * @readonly + */ + zoomUiVisible, + + /** + * Whether zoom slider should be rendered. + * + * @alpha + * @readonly + */ + zoomSliderVisible, + + /** + * CSS icon class for the icon of the zoom in button. + * + * @alpha + * @readonly + */ + zoomInIcon, + + /** + * CSS icon class for the icon of the zoom out button. + * + * @alpha + * @readonly + */ + zoomOutIcon, + + /** + * Whether the zoom UI should be rendered horizontally. + * + * @alpha + * @readonly + */ + renderHorizontal, + + /** + * Indicates in which direction of the element space is available for a tooltip. + * + * @alpha + * @readonly + */ + tooltipPosition, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useZoomStore, import.meta.hot)) +} diff --git a/src/plugins/zoom/types.ts b/src/plugins/zoom/types.ts new file mode 100644 index 0000000000..a61f51cc7e --- /dev/null +++ b/src/plugins/zoom/types.ts @@ -0,0 +1,56 @@ +import type { Icon, PluginOptions } from '@/core' + +/** + * Plugin identifier. + */ +export const PluginId = 'zoom' + +/** + * Override options for icons used within the zoom plugin. + */ +export interface ZoomIconOptions { + /** + * Icon for the zoom-in button. + * @defaultValue 'kern-icon--zoom-in' + */ + zoomIn?: Icon + + /** + * Icon for the zoom-out button. + * @defaultValue 'kern-icon--zoom-out' + */ + zoomOut?: Icon +} + +/** + * Plugin options for zoom plugin. + */ +export interface ZoomPluginOptions extends PluginOptions { + /** + * Override the default icons for the zoom buttons. + * @defaultValue `{ zoomIn: 'kern-icon--zoom-in', zoomOut: 'kern-icon--zoom-out' }` + */ + icons?: ZoomIconOptions + + /** + * Render type. + * + * @defaultValue `'independent'` + */ + renderType?: 'independent' | 'iconMenu' + + /** + * Defines if the zoom buttons and slider should be visible on small devices. + * + * @defaultValue `false` + */ + showMobile?: boolean + + /** + * Defines if a zoom slider is offered in addition to the zoom buttons. + * The zoom slider is (regardless of this setting) only displayed if there is enough space. + * + * @defaultValue `false` + */ + showZoomSlider?: boolean +} diff --git a/src/test/utils/mockI18n.ts b/src/test/utils/mockI18n.ts new file mode 100644 index 0000000000..6b69d107da --- /dev/null +++ b/src/test/utils/mockI18n.ts @@ -0,0 +1,26 @@ +import type { ResourceKey } from 'i18next' + +type MockedSelectorFn = ($: Record) => string + +export function mockedT( + keyFn: MockedSelectorFn, + options: { + ns: string + context?: string + count?: number + } +) { + 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}` : ''}${options.count ? `_${options.count}` : ''})` +} diff --git a/src/tsconfig.json b/src/tsconfig.json new file mode 100644 index 0000000000..e67a93c0c8 --- /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/stylelint.config.ts b/stylelint.config.ts new file mode 100644 index 0000000000..be10f3a05c --- /dev/null +++ b/stylelint.config.ts @@ -0,0 +1,60 @@ +import type { Config } from 'stylelint' + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.dirname(fileURLToPath(import.meta.url)) + +export default { + extends: ['stylelint-config-recommended', 'stylelint-config-recommended-vue'], + plugins: ['stylelint-value-no-unknown-custom-properties'], + // TODO: Remove 'vue2/**' after migration + ignoreFiles: ['examples/iceberg/**', 'vue2/**'], + rules: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'csstools/value-no-unknown-custom-properties': [ + true, + { + // Custom properties defined within the linted file are detected + // automatically. Variables provided by external sources must be + // declared here so they are not reported as unknown. + importFrom: [ + // KERN design system variables, loaded at runtime via loadKern.ts. + path.join(repoRoot, 'node_modules/@kern-ux/native/dist/kern.css'), + { + // Project-global custom properties defined on the POLAR + // container host and inherited by all shadow-DOM components. + customProperties: { + '--brand-color-l': '0', + '--brand-color-c': '0', + '--brand-color-h': '0', + '--polar-shadow-color': '0deg 0% 63%', + '--polar-shadow': '0 0 0', + }, + }, + ], + }, + ], + /* eslint-enable @typescript-eslint/naming-convention */ + }, + overrides: [ + { + // Match all files within github-io, including nested folders like + // `components/`. A single `*` does not cross directory boundaries. + files: ['examples/github-io/**/*.{css,vue}'], + rules: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'csstools/value-no-unknown-custom-properties': [ + true, + { + importFrom: [ + path.join(repoRoot, 'examples/github-io/variables.css'), + path.join(repoRoot, 'node_modules/@kern-ux/native/dist/kern.css'), + ], + }, + ], + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + ], +} satisfies Config diff --git a/tsconfig.json b/tsconfig.json index 480672cc94..b395138bf7 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 0000000000..c250144c08 --- /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 0000000000..5731da9684 --- /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/index.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 0000000000..d745ea635a --- /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.github-io.ts b/vite.config.github-io.ts new file mode 100644 index 0000000000..849abb551b --- /dev/null +++ b/vite.config.github-io.ts @@ -0,0 +1,38 @@ +import vue from '@vitejs/plugin-vue' +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import kernExtraIcons from 'vite-plugin-kern-extra-icons' + +export default defineConfig({ + plugins: [ + vue({ + template: { + compilerOptions: { + isCustomElement: (tag) => tag.includes('-'), + }, + }, + }), + kernExtraIcons({ + cssLayer: 'kern-ux-icons', + ignoreFilename: (filename) => !filename.includes('/examples/github-io/'), + }), + ], + build: { + outDir: resolve(import.meta.dirname, 'examples', 'github-io', 'dist'), + emptyOutDir: true, + rollupOptions: { + external: ['@polar/polar', '@polar/polar/client', '@polar/polar/store'], + input: resolve( + import.meta.dirname, + 'examples', + 'github-io', + 'index.html' + ), + output: { + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name].[ext]', + }, + }, + }, +}) diff --git a/vite.config.preview.ts b/vite.config.preview.ts new file mode 100644 index 0000000000..37e32b2cbd --- /dev/null +++ b/vite.config.preview.ts @@ -0,0 +1,60 @@ +import vue from '@vitejs/plugin-vue' +import { resolve } from 'node:path' +import { defineConfig } from 'vite' +import commonJs from 'vite-plugin-commonjs' +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/') && + !filename.includes('/examples/github-io/'), + }), + enrichedConsole(), + ], + build: { + outDir: '.dist.preview', + rollupOptions: { + input: { + main: resolve(import.meta.dirname, 'index.html'), + snowbox: resolve( + import.meta.dirname, + 'examples', + 'snowbox', + 'index.html' + ), + iceberg: resolve( + import.meta.dirname, + 'examples', + 'iceberg', + 'index.html' + ), + githubIo: resolve( + import.meta.dirname, + 'examples', + 'github-io', + 'index.html' + ), + }, + }, + }, + preview: { + port: 1235, + }, + optimizeDeps: { + entries: ['snowbox', 'iceberg', 'github-io'], + }, +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000000..ddede482fc --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,187 @@ +import vue from '@vitejs/plugin-vue' +import { globSync, readdirSync } from 'node:fs' +import { basename, join, relative, resolve, sep } from 'node:path' +import dts from 'unplugin-dts/vite' +import { defineConfig } from 'vite' +import checker from 'vite-plugin-checker' +import commonJs from 'vite-plugin-commonjs' +import kernExtraIcons from 'vite-plugin-kern-extra-icons' +import vueDevTools from 'vite-plugin-vue-devtools' + +import enrichedConsole from './vitePlugins/enrichedConsole.js' + +/** + * Collects public `lib` entries for subpath exports (e.g.`@polar/polar/lib/invisibleStyle`). + * A directory with an `index.ts` is exposed only via its folder path; + * otherwise each `.ts` file is exposed and subdirectories are traversed. + */ +function collectLibEntries() { + const baseDir = resolve(import.meta.dirname, 'src') + const entries: Record = {} + + const toKey = (absPath: string) => + relative(baseDir, absPath).split(sep).join('/') + const addEntry = (key: string, file: string) => { + if (entries[key]) { + console.warn( + `[vite.config] Duplicate lib entry key "${key}"; ignoring "${file}".` + ) + return + } + entries[key] = file + } + const walk = (currentDir: string) => { + const items = readdirSync(currentDir, { withFileTypes: true }) + if (items.some((item) => item.isFile() && item.name === 'index.ts')) { + addEntry(toKey(currentDir), join(currentDir, 'index.ts')) + return + } + for (const item of items) { + const full = join(currentDir, item.name) + if (item.isDirectory()) { + walk(full) + } else if (item.isFile() && item.name.endsWith('.ts')) { + addEntry(toKey(full).replace(/\.ts$/, ''), full) + } + } + } + + walk(join(baseDir, 'lib')) + + return entries +} + +const libEntries = collectLibEntries() + +export default defineConfig(({ mode }) => ({ + plugins: [ + // @ts-expect-error | commonJs dts is broken + commonJs(), + vue({ + template: { + compilerOptions: { + isCustomElement: (tag) => tag.includes('-'), + }, + }, + }), + vueDevTools(), + dts({ + bundleTypes: true, + processor: 'vue', + 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: { + client: 'src/client.ts', + polar: 'src/core/index.ts', + store: 'src/core/stores/index.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)], + ]) + ), + ...libEntries, + }, + }, + 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'), + ], + ]) + ), + // lib keys are never a prefix of one another, so their order is irrelevant. + ...Object.fromEntries( + Object.entries(libEntries).map(([key, file]) => [ + `@polar/polar/${key}`, + file, + ]) + ), + '@polar/polar/client': resolve( + import.meta.dirname, + 'src', + 'client.ts' + ), + '@polar/polar/store': resolve( + import.meta.dirname, + 'src', + 'core', + 'stores', + 'index.ts' + ), + '@polar/polar/polar.css': resolve( + import.meta.dirname, + 'src', + 'core', + '.polar-dev.css' + ), + '@polar/polar': resolve( + import.meta.dirname, + 'src', + 'core', + 'index.ts' + ), + } + : {}), + '@': resolve(import.meta.dirname, 'src'), + /* eslint-enable @typescript-eslint/naming-convention */ + }, + }, + test: { + environment: 'jsdom', + include: ['src/**/*.spec.ts'], + includeSource: ['src/**/*.ts'], + coverage: { + all: true, + include: ['src/**/*.{ts,vue}'], + exclude: ['**/*.d.ts', 'src/test/**'], + }, + }, +})) diff --git a/viteConfigs/index.js b/viteConfigs/index.js deleted file mode 100644 index 0b30cb9c36..0000000000 --- a/viteConfigs/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import path from 'path' -import merge from 'lodash.merge' -import clientConfiguration from './vite.client' -import codeConfiguration from './vite.code' - -const { name } = path.resolve(__dirname, 'package.json') - -export function getCodeConfig(options = {}) { - return merge(codeConfiguration, { build: { lib: { name } } }, options) -} - -export function getClientConfig(options = {}) { - return merge(clientConfiguration, options) -} diff --git a/viteConfigs/vite.client.js b/viteConfigs/vite.client.js deleted file mode 100644 index e89b340e88..0000000000 --- a/viteConfigs/vite.client.js +++ /dev/null @@ -1,40 +0,0 @@ -import { createRequire } from 'module' -import { resolve } from 'path' -import { defineConfig } from 'vite' -import commonJs from 'vite-plugin-commonjs' -import vuePlugin from '@vitejs/plugin-vue2' - -const require = createRequire(import.meta.url) - -export default defineConfig({ - plugins: [commonJs(), vuePlugin()], - root: 'src', - define: { - 'process.env.NODE_ENV': `"${process.env.NODE_ENV}"`, - }, - build: { - outDir: '../dist', - sourcemap: true, - }, - server: { - port: 1234, - }, - optimizeDeps: { - exclude: ['geojson'], - }, - resolve: { - alias: { - // mitigation for ignoring package.json exports in @masterportal/masterportalapi - 'olcs/lib/olcs': resolve( - __dirname, - '..', - 'node_modules', - 'olcs', - 'lib', - 'olcs' - ), - stream: require.resolve('stream-browserify'), - timers: require.resolve('timers-browserify'), - }, - }, -}) diff --git a/viteConfigs/vite.code.js b/viteConfigs/vite.code.js deleted file mode 100644 index d80c32eefe..0000000000 --- a/viteConfigs/vite.code.js +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from 'vite' -import vuePlugin from '@vitejs/plugin-vue2' - -export default defineConfig({ - plugins: [vuePlugin()], - build: { - lib: { - entry: 'src/index.ts', - }, - }, -}) diff --git a/vitePlugins/enrichedConsole.ts b/vitePlugins/enrichedConsole.ts new file mode 100644 index 0000000000..43f95d457f --- /dev/null +++ b/vitePlugins/enrichedConsole.ts @@ -0,0 +1,62 @@ +import MagicString from 'magic-string' +import { resolve } from 'node:path' + +const fileRegex = /\.(ts|js|vue)$/ +const consoleRegex = /console\.(log|warn|error|info)\(/g + +function stripId(id: string): string | null { + const root = resolve(import.meta.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/vue2/README.md b/vue2/README.md new file mode 100644 index 0000000000..e7a6278c61 --- /dev/null +++ b/vue2/README.md @@ -0,0 +1,132 @@ +![Public Money, Public Value](https://img.shields.io/badge/Public%20Money-Public%20Value-red) +[![License: EUPL v1.2](https://img.shields.io/badge/License-EUPL%20v1.2-blue)](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12) +[![We're on NPM!](https://img.shields.io/badge/npm-%F0%9F%9A%80-green)](https://www.npmjs.com/search?q=%40polar) + +

POLAR

+ +**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: + +

+Screenshot example of a possible POLAR client +

+ +## Backers and users + +### States of Germany + + + + + + + + + + +
Bremer Wappenzeichen
Freie Hansestadt Bremen
Hamburg-Symbol
Freie und Hansestadt Hamburg
Landessymbol Sachsen-Anhalt
Sachsen-Anhalt
Landessymbol Schleswig-Holstein
Schleswig-Holstein
+ +### Government agencies + +* [Senatskanzlei Hamburg](https://www.hamburg.de/senatskanzlei/) +* [Landesamt für Denkmalpflege Schleswig-Holstein](https://www.schleswig-holstein.de/DE/landesregierung/ministerien-behoerden/LD/ld_node.html) +* [Dataport AöR](https://www.dataport.de/) + +## Technical concepts + +### Reusability *and* adaptability + +POLAR is built to ease the creation of new map clients. A lot of feature requests in map clients are recurring and can be fulfilled with reusable parts. Then again, many map clients require a _little extra_. + +POLAR is built to serve both worlds. For generic use cases, generic clients are ready-made and usable by configuration. More specific use cases can be matched with special clients that still make use of the plugins and fill in the missing parts. + +POLAR runs both as full page application and as component. The most common usage is as component: Think of it as a form input where the input data is geospatial. + +### Plugin-based approach + +To see our plugins in action, please visit our [documentation page](https://dataport.github.io/polar/) to see running examples. Plugins are designed to be configurable, optional, and replacable. + +|Name|Details| +|-|-| +|[AddressSearch](https://github.com/Dataport/polar/tree/main/packages/plugins/AddressSearch)|Offers a search field and standard search service implementations with API for your own configurable custom search services. For already usable search services, see the documentation of the package. Integration with Reverse Geocoder and Pins possible, or usable as a data source for further processing.| +|[Attributions](https://github.com/Dataport/polar/tree/main/packages/plugins/Attributions)|Shows layer copyright information of visible layers and client.| +|[Draw](https://github.com/Dataport/polar/tree/main/packages/plugins/Draw)|Allows the user to draw various geometries onto the map. The resulting GeoJSON can be forwarded to later processing steps, or be used by the Export plugin to generate screenshots.| +|[Export](https://github.com/Dataport/polar/tree/main/packages/plugins/Export)|Offers screenshot functionality for the user or further processing.| +|[Filter](https://github.com/Dataport/polar/tree/main/packages/plugins/Filter)|Allows users to filter vector layers to content relevant to their interests.| +|[Fullscreen](https://github.com/Dataport/polar/tree/main/packages/plugins/Fullscreen)|User can toggle between integrated and fullscreen view with this plugin.| +|[GeoLocation](https://github.com/Dataport/polar/tree/main/packages/plugins/GeoLocation)|Geolocalizes the user either on user demand or as a background procedure. An icon is shown on the user position on the map.| +|[Gfi](https://github.com/Dataport/polar/tree/main/packages/plugins/Gfi)|Short for "Get Feature Information". Retrieves feature information from a WMS or WFS layer for display or usage by further processing steps. Can be used as feature list viewer for vector layers.| +|[IconMenu](https://github.com/Dataport/polar/tree/main/packages/plugins/IconMenu)|Handles display of visible plugin buttons. Only relevant for programming clients, no direct user feature.| +|[LayerChooser](https://github.com/Dataport/polar/tree/main/packages/plugins/LayerChooser)|Allows choosing a background layer and an arbitrary amount of feature or overlay layers. WMS layers can optionally be filtered by sub-layers by the user.| +|[Legend](https://github.com/Dataport/polar/tree/main/packages/plugins/Legend)|Displays an overview of layer legend images as delivered by the used WMS services. Images can be clicked for large view.| +|[LoadingIndicator](https://github.com/Dataport/polar/tree/main/packages/plugins/LoadingIndicator)|Loading spinner. Only relevant for programming clients, no direct user feature.| +|[PointerPosition](https://github.com/Dataport/polar/tree/main/packages/plugins/PointerPosition)|Displays the current/last pointer position in a coordinate reference system chosen by the user.| +|[Pins](https://github.com/Dataport/polar/tree/main/packages/plugins/Pins)|Pin feature that allows users to set and move pins to indicate a position. Integration with AddressSearch and ReverseGeocoder configurable.| +|[ReverseGeocoder](https://github.com/Dataport/polar/tree/main/packages/plugins/ReverseGeocoder)|Configurable to translate an arbitrary coordinate to an address. Integration with AddressSearch and Pins configurable.| +|[Scale](https://github.com/Dataport/polar/tree/main/packages/plugins/Scale)|Shows current scale as ratio and size indicator.| +|[Toast](https://github.com/Dataport/polar/tree/main/packages/plugins/Toast)|Shows information to the user. Configurable in many plugins to communicate status updates or procedural advice.| +|[Zoom](https://github.com/Dataport/polar/tree/main/packages/plugins/Zoom)|Allows zooming in and out of the client with buttons.| + +## Getting started (for developers) + +For a detailed step-by-step guide, please refer to our comprehensive [Getting Started guide](https://github.com/Dataport/polar/tree/main/gettingStarted.md). + +## Stay In Touch + +- [Contact us via email 📧](mailto:polar@dataport.de) + +made by [Dataport](https://www.dataport.de/) with ❤️ diff --git a/__mocks__/.eslintrc b/vue2/__mocks__/.eslintrc similarity index 100% rename from __mocks__/.eslintrc rename to vue2/__mocks__/.eslintrc diff --git a/__mocks__/fileMock.js b/vue2/__mocks__/fileMock.js similarity index 100% rename from __mocks__/fileMock.js rename to vue2/__mocks__/fileMock.js diff --git a/__mocks__/jest.setup.js b/vue2/__mocks__/jest.setup.js similarity index 87% rename from __mocks__/jest.setup.js rename to vue2/__mocks__/jest.setup.js index 144f6e943c..435ee3c84b 100644 --- a/__mocks__/jest.setup.js +++ b/vue2/__mocks__/jest.setup.js @@ -1,5 +1,3 @@ -import 'regenerator-runtime/runtime' - class Worker { constructor(stringUrl) { this.url = stringUrl diff --git a/__mocks__/styleMock.js b/vue2/__mocks__/styleMock.js similarity index 100% rename from __mocks__/styleMock.js rename to vue2/__mocks__/styleMock.js diff --git a/arcana.md b/vue2/arcana.md similarity index 100% rename from arcana.md rename to vue2/arcana.md diff --git a/e2e/draw.spec.ts b/vue2/e2e/draw.spec.ts similarity index 100% rename from e2e/draw.spec.ts rename to vue2/e2e/draw.spec.ts diff --git a/e2e/iconMenu.spec.ts b/vue2/e2e/iconMenu.spec.ts similarity index 100% rename from e2e/iconMenu.spec.ts rename to vue2/e2e/iconMenu.spec.ts diff --git a/e2e/pins.spec.ts b/vue2/e2e/pins.spec.ts similarity index 100% rename from e2e/pins.spec.ts rename to vue2/e2e/pins.spec.ts diff --git a/e2e/toast.spec.ts b/vue2/e2e/toast.spec.ts similarity index 100% rename from e2e/toast.spec.ts rename to vue2/e2e/toast.spec.ts diff --git a/e2e/utils/clickTimes.ts b/vue2/e2e/utils/clickTimes.ts similarity index 100% rename from e2e/utils/clickTimes.ts rename to vue2/e2e/utils/clickTimes.ts diff --git a/e2e/utils/draw.ts b/vue2/e2e/utils/draw.ts similarity index 100% rename from e2e/utils/draw.ts rename to vue2/e2e/utils/draw.ts diff --git a/e2e/utils/openSnowbox.ts b/vue2/e2e/utils/openSnowbox.ts similarity index 100% rename from e2e/utils/openSnowbox.ts rename to vue2/e2e/utils/openSnowbox.ts diff --git a/e2e/utils/package.json b/vue2/e2e/utils/package.json similarity index 100% rename from e2e/utils/package.json rename to vue2/e2e/utils/package.json diff --git a/e2e/utils/vuex.ts b/vue2/e2e/utils/vuex.ts similarity index 100% rename from e2e/utils/vuex.ts rename to vue2/e2e/utils/vuex.ts diff --git a/e2e/zoom.spec.ts b/vue2/e2e/zoom.spec.ts similarity index 100% rename from e2e/zoom.spec.ts rename to vue2/e2e/zoom.spec.ts diff --git a/gettingStarted.md b/vue2/gettingStarted.md similarity index 100% rename from gettingStarted.md rename to vue2/gettingStarted.md diff --git a/vue2/package.json b/vue2/package.json new file mode 100644 index 0000000000..09b629d03e --- /dev/null +++ b/vue2/package.json @@ -0,0 +1,119 @@ +{ + "name": "polar-monorepo", + "private": true, + "description": "monorepository to build masterportalAPI-based map clients", + "author": "Dataport AöR ", + "license": "EUPL-1.2", + "engines": { + "node": "^20.16.0", + "npm": "^10.8.1" + }, + "workspaces": [ + "packages/clients/*", + "packages/components", + "packages/core", + "packages/lib/*", + "packages/plugins/*", + "packages/types/custom" + ], + "scripts": { + "afm:build": "nx build @polar/client-afm && npm run docs:afm", + "afm:build:serve": "http-server ./packages/clients/afm -o /example/prod-example.html", + "afm:dev": "nx dev @polar/client-afm", + "bgw:build": "nx build @polar/client-bgw", + "bgw:build:serve": "http-server ./packages/clients/bgw -o /dist/index.html", + "bgw:dev": "nx dev @polar/client-bgw", + "generic:build": "nx build @polar/client-generic", + "dish:build": "nx build @polar/client-dish", + "dish:build:serve": "http-server ./packages/clients/dish -o /dist/index.html", + "dish:dev": "nx dev @polar/client-dish", + "meldemichel:build": "nx build @polar/client-meldemichel", + "meldemichel:build:serve": "http-server packages/clients/meldemichel -o /example/index.html", + "meldemichel:dev": "nx dev @polar/client-meldemichel", + "snowbox": "nx dev @polar/client-snowbox", + "snowbox:build": "nx build @polar/client-snowbox", + "snowbox:build:serve": "http-server packages/clients/snowbox -o /dist/index.html", + "snowbox:build:serve:e2e": "http-server packages/clients/snowbox", + "stylePreview:build": "nx build @polar/client-style-preview", + "stylePreview:build:serve": "http-server packages/clients/stylePreview -o /example/prod-example.html", + "stylePreview:dev": "nx dev @polar/client-style-preview", + "textLocator:build": "nx build @polar/client-text-locator", + "textLocator:build:serve": "http-server ./packages/clients/textLocator -o /dist/index.html", + "textLocator:dev": "nx dev @polar/client-text-locator", + "pages:build": "rimraf ./pages/docs && npm run generic:build && bash ./scripts/buildPages.sh", + "pages:build:serve": "http-server pages -o index.html", + "clean": "nx reset && rimraf --glob packages/**/{.cache,dist,docs} && rimraf --glob {.cache,dist} && node ./scripts/clean", + "docs:afm": "tsx ./scripts/makeDocs afm", + "docs:generic": "tsx ./scripts/makeDocs generic", + "docs:meldemichel": "tsx ./scripts/makeDocs meldemichel && npm run meldemichel:build && cp -r ./packages/clients/meldemichel/dist ./packages/clients/meldemichel/example ./packages/clients/meldemichel/docs", + "docs:stylePreview": "tsx ./scripts/makeDocs stylePreview && npm run stylePreview:build && cp -r ./packages/clients/stylePreview/dist ./packages/clients/stylePreview/example ./packages/clients/stylePreview/docs", + "docs:snowbox": "tsx ./scripts/makeDocs snowbox", + "docs:textLocator": "tsx ./scripts/makeDocs textLocator && npm run textLocator:build && cp -r ./packages/clients/textLocator/dist ./packages/clients/textLocator/docs", + "lint": "npx eslint . --cache --ext .js,.ts,.vue", + "lint:ci": "npx eslint . --ext .js,.ts,.vue", + "lint:fix": "npx eslint . --fix --cache --ext .js,.ts,.vue", + "tsc:ci": "tsc --noEmit", + "publishPackages": "node ./scripts/publishPackages", + "test": "jest", + "test:e2e": "npx playwright test", + "test:dev": "jest --coverage --coverageReporters=text --watchAll", + "test:coverage": "jest --coverage" + }, + "devDependencies": { + "@actions/github": "^6.0.0", + "@babel/core": "^7.24.8", + "@babel/preset-env": "^7.24.8", + "@cesium/engine": "^15.0.0", + "@jest/types": "^29.3.1", + "@nx/js": "^19.8.2", + "@playwright/test": "^1.47.2", + "@swc-node/register": "~1.9.1", + "@swc/core": "~1.5.7", + "@swc/helpers": "~0.5.11", + "@types/geojson": "^7946.0.8", + "@types/jest": "^29.5.13", + "@types/node": "^20.16.10", + "@types/proj4": "^2.5.2", + "@typescript-eslint/eslint-plugin": "^5.9.0", + "@typescript-eslint/parser": "^5.9.0", + "@vitejs/plugin-vue2": "^2.2.0", + "@vue/test-utils": "^1.2.2", + "@vue/vue2-jest": "^28.1.0", + "babel-core": "^7.0.0-bridge.0", + "cesium": "^1.125.0", + "copyfiles": "^2.4.1", + "eslint": "^8.6.0", + "eslint-config-prettier": "^8.3.0", + "eslint-config-standard": "^17.0.0", + "eslint-import-resolver-typescript": "^2.5.0", + "eslint-plugin-import": "^2.25.4", + "eslint-plugin-jest": "^27.2.1", + "eslint-plugin-n": "^15.6.1", + "eslint-plugin-prettier": "^4.0.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-tsdoc": "^0.2.14", + "eslint-plugin-vue": "^9.18.1", + "github-markdown-css": "^5.7.0", + "http-server": "^14.1.1", + "jest": "^29.3.1", + "jest-canvas-mock": "^2.5.2", + "jest-environment-jsdom": "^29.3.1", + "lodash.merge": "^4.6.2", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^9.2.0", + "nx": "19.8.2", + "prettier": "^2.5.1", + "rimraf": "^6.0.1", + "sass": "^1.79.4", + "stream-browserify": "^3.0.0", + "timers-browserify": "^2.0.12", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "tslib": "^2.3.0", + "tsx": "^4.19.2", + "typescript": "^5.6.2", + "vite": "^5.4.8", + "vite-plugin-commonjs": "^0.6.2", + "vue-template-compiler": "^2.7.16" + } +} diff --git a/packages/clients/afm/API.md b/vue2/packages/clients/afm/API.md similarity index 100% rename from packages/clients/afm/API.md rename to vue2/packages/clients/afm/API.md diff --git a/vue2/packages/clients/afm/CHANGELOG.md b/vue2/packages/clients/afm/CHANGELOG.md new file mode 100644 index 0000000000..fb3ff36b2e --- /dev/null +++ b/vue2/packages/clients/afm/CHANGELOG.md @@ -0,0 +1,33 @@ +# CHANGELOG + +## 2.1.0 + +- Feature: Add `@polar/plugin-reverse-geocoder` to the client. For details about this plugin, see [the plugin's documentation](https://dataport.github.io/polar/docs/afm/plugin-reverse-geocoder.html). +- Chore: `AddressSearch` is now visible by default in the example configuration to illustrate a working default scenario for the added `ReverseGeocoder`. This did not result in a change to the software's defaults, but merely to the example. + +## 2.0.3 + +- Fix: Use v3.2.2 of `@polar/core` and v3.1.1 of `@polar/plugin-address-search` to resolve issues when using `+` and `-` characters in the search window or using the arrow keys to navigate the entered input. + +## 2.0.2 + +- Fix: Use v3.1.1 of `polar/lib-get-features` that includes a fix for reading a service's WFS response's CRS from its features if it's not available on the root node. + +## 2.0.1 + +- Fix: Use v3.0.1 of `@polar/plugin-gfi` that includes a fix for the usage of `directSelect`, `multiSelect` and their usage in conjunction with `@polar/plugin-pins`. + +## 2.0.0 + +- Breaking: Update `@polar`-dependencies to the latest versions. This includes an update of `ol` from `^7.1.0` to `^10.3.1`. +- Feature: This client now supports the `@polar/core`'s field `stylePath`. The usage is documented in the API.md file. +- Feature: Update icon of `layerChooser` in `iconMenu` to `fa-layer-group` to clear-up the content hidden behind the menu button. +- Chore: Change value of `pins.movable` configuration to `'drag'` as using a boolean has been deprecated. + +## 1.0.1 + +- Fix: The included example files have been updated to the new syntax and work again. + +## 1.0.0 + +Initial release. diff --git a/packages/clients/afm/LICENSE b/vue2/packages/clients/afm/LICENSE similarity index 100% rename from packages/clients/afm/LICENSE rename to vue2/packages/clients/afm/LICENSE diff --git a/packages/clients/afm/README.md b/vue2/packages/clients/afm/README.md similarity index 100% rename from packages/clients/afm/README.md rename to vue2/packages/clients/afm/README.md diff --git a/packages/clients/afm/example/index.html b/vue2/packages/clients/afm/example/index.html similarity index 100% rename from packages/clients/afm/example/index.html rename to vue2/packages/clients/afm/example/index.html diff --git a/packages/clients/afm/example/polar-example.js b/vue2/packages/clients/afm/example/polar-example.js similarity index 79% rename from packages/clients/afm/example/polar-example.js rename to vue2/packages/clients/afm/example/polar-example.js index 3ba7422ecd..d1163513c5 100644 --- a/packages/clients/afm/example/polar-example.js +++ b/vue2/packages/clients/afm/example/polar-example.js @@ -44,7 +44,19 @@ const mapConfiguration = { ], }, addressSearch: { - displayComponent: false, + searchMethods: [ + { + queryParameters: { + searchAddress: true, + searchStreets: true, + searchHouseNumbers: true, + }, + type: 'mpapi', + url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + }, + ], + minLength: 3, + waitMs: 300, }, export: { showPdf: false, @@ -73,6 +85,14 @@ const mapConfiguration = { atZoomLevel: 3, }, }, + reverseGeocoder: { + url: 'https://geodienste.hamburg.de/HH_WPS', + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + zoomTo: 7, + coordinateSource: 'plugin/pins/transformedCoordinate', + addressTarget: 'plugin/addressSearch/selectResult', + }, } // you may as well use a local array diff --git a/packages/clients/afm/example/prod-example.html b/vue2/packages/clients/afm/example/prod-example.html similarity index 100% rename from packages/clients/afm/example/prod-example.html rename to vue2/packages/clients/afm/example/prod-example.html diff --git a/packages/clients/afm/example/reset.css b/vue2/packages/clients/afm/example/reset.css similarity index 100% rename from packages/clients/afm/example/reset.css rename to vue2/packages/clients/afm/example/reset.css diff --git a/vue2/packages/clients/afm/package.json b/vue2/packages/clients/afm/package.json new file mode 100644 index 0000000000..873f3f714f --- /dev/null +++ b/vue2/packages/clients/afm/package.json @@ -0,0 +1,62 @@ +{ + "name": "@polar/client-afm", + "version": "2.1.0", + "description": "POLAR Client AfM. This client has been put together for use in citizen participation platforms and for making applications (referring to legal documents, not executables).", + "keywords": [ + "OpenLayers", + "ol", + "POLAR", + "client" + ], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "main": "dist/polar-client.js", + "repository": { + "type": "git", + "url": "git+https://github.com/Dataport/polar.git", + "directory": "packages/clients/afm" + }, + "files": [ + "dist/**/**.*", + "docs/**/**.*", + "example/prod-example.html", + "example/polar-example.js", + "CHANGELOG.md", + "API.md" + ], + "scripts": { + "prepublishOnly": "npm run build", + "build": "rimraf dist && vite build && cd ../../.. && npm run docs:afm", + "dev": "vite --host" + }, + "devDependencies": { + "@polar/core": "^3.0.0", + "@polar/plugin-address-search": "^3.0.0", + "@polar/plugin-attributions": "^1.4.0", + "@polar/plugin-draw": "^3.0.0", + "@polar/plugin-export": "^1.2.2", + "@polar/plugin-gfi": "^3.0.1", + "@polar/plugin-icon-menu": "^1.3.1", + "@polar/plugin-layer-chooser": "^2.0.0", + "@polar/plugin-legend": "^1.1.2", + "@polar/plugin-loading-indicator": "^1.2.1", + "@polar/plugin-pins": "^3.0.0", + "@polar/plugin-reverse-geocoder": "^3.0.1", + "@polar/plugin-scale": "^3.0.0", + "@polar/plugin-toast": "^1.1.2", + "@polar/plugin-zoom": "^1.4.0" + }, + "peerDependencies": { + "@repositoryname/vuex-generators": "^1.1.2", + "vue": "^2.x", + "vuex": "^3.x", + "lodash.merge": "^4.6.2" + }, + "nx": { + "includedScripts": [ + "build", + "dev" + ] + } +} diff --git a/vue2/packages/clients/afm/src/polar-client.ts b/vue2/packages/clients/afm/src/polar-client.ts new file mode 100644 index 0000000000..9c14b8dedd --- /dev/null +++ b/vue2/packages/clients/afm/src/polar-client.ts @@ -0,0 +1,113 @@ +import polarCore, { setLayout, NineLayout, NineLayoutTag } from '@polar/core' +import PolarPluginAddressSearch from '@polar/plugin-address-search' +import PolarPluginAttributions from '@polar/plugin-attributions' +import PolarPluginDraw from '@polar/plugin-draw' +import PolarPluginExport from '@polar/plugin-export' +import PolarPluginGfi from '@polar/plugin-gfi' +import PolarPluginIconMenu from '@polar/plugin-icon-menu' +import PolarPluginLayerChooser from '@polar/plugin-layer-chooser' +import PolarPluginLegend from '@polar/plugin-legend' +import PolarPluginLoadingIndicator from '@polar/plugin-loading-indicator' +import PolarPluginPins from '@polar/plugin-pins' +import PolarPluginReverseGeocoder from '@polar/plugin-reverse-geocoder' +import PolarPluginScale from '@polar/plugin-scale' +import PolarPluginToast from '@polar/plugin-toast' +import PolarPluginZoom from '@polar/plugin-zoom' +import merge from 'lodash.merge' + +import packageInfo from '../package.json' + +// eslint-disable-next-line no-console +console.log(`AfM-POLAR-Client v${packageInfo.version}.`) + +const defaultOptions = { + displayComponent: true, + layoutTag: NineLayoutTag.TOP_LEFT, +} + +const iconMenu = PolarPluginIconMenu( + merge({}, defaultOptions, { + menus: [ + { + plugin: PolarPluginLayerChooser({}), + icon: 'fa-layer-group', + id: 'layerChooser', + }, + { + plugin: PolarPluginDraw({}), + icon: 'fa-pencil', + id: 'draw', + }, + ], + layoutTag: NineLayoutTag.TOP_RIGHT, + }) +) + +setLayout(NineLayout) + +polarCore.addPlugins([ + iconMenu, + PolarPluginAddressSearch( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.TOP_LEFT, + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + }) + ), + PolarPluginPins( + merge({}, defaultOptions, { + appearOnClick: { show: true, atZoomLevel: 6 }, + coordinateSource: 'plugin/addressSearch/chosenAddress', + }) + ), + PolarPluginLegend( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + maxWidth: 500, + }) + ), + PolarPluginAttributions( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + listenToChanges: [ + 'plugin/zoom/zoomLevel', + 'plugin/layerChooser/activeBackgroundId', + 'plugin/layerChooser/activeMaskIds', + ], + }) + ), + PolarPluginExport( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_LEFT, + }) + ), + PolarPluginGfi( + merge({}, defaultOptions, { + coordinateSources: ['plugin/addressSearch/chosenAddress'], + }) + ), + PolarPluginLoadingIndicator( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.MIDDLE_MIDDLE, + }) + ), + PolarPluginScale( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + }) + ), + PolarPluginToast( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_MIDDLE, + }) + ), + PolarPluginZoom( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.MIDDLE_RIGHT, + }) + ), + // @ts-expect-error | url has to be added with instantiation configuration + PolarPluginReverseGeocoder({}), +]) + +export default polarCore diff --git a/packages/clients/afm/vite.config.js b/vue2/packages/clients/afm/vite.config.js similarity index 100% rename from packages/clients/afm/vite.config.js rename to vue2/packages/clients/afm/vite.config.js diff --git a/packages/clients/stylePreview/CHANGELOG.md b/vue2/packages/clients/bgw/CHANGELOG.md similarity index 100% rename from packages/clients/stylePreview/CHANGELOG.md rename to vue2/packages/clients/bgw/CHANGELOG.md diff --git a/packages/clients/bgw/LICENSE b/vue2/packages/clients/bgw/LICENSE similarity index 100% rename from packages/clients/bgw/LICENSE rename to vue2/packages/clients/bgw/LICENSE diff --git a/packages/clients/bgw/README.md b/vue2/packages/clients/bgw/README.md similarity index 100% rename from packages/clients/bgw/README.md rename to vue2/packages/clients/bgw/README.md diff --git a/packages/clients/bgw/package.json b/vue2/packages/clients/bgw/package.json similarity index 100% rename from packages/clients/bgw/package.json rename to vue2/packages/clients/bgw/package.json diff --git a/packages/clients/bgw/src/addPlugins.ts b/vue2/packages/clients/bgw/src/addPlugins.ts similarity index 100% rename from packages/clients/bgw/src/addPlugins.ts rename to vue2/packages/clients/bgw/src/addPlugins.ts diff --git a/packages/clients/bgw/src/html/index.html b/vue2/packages/clients/bgw/src/html/index.html similarity index 100% rename from packages/clients/bgw/src/html/index.html rename to vue2/packages/clients/bgw/src/html/index.html diff --git a/packages/clients/bgw/src/index.html b/vue2/packages/clients/bgw/src/index.html similarity index 100% rename from packages/clients/bgw/src/index.html rename to vue2/packages/clients/bgw/src/index.html diff --git a/packages/clients/bgw/src/mapConfiguration.ts b/vue2/packages/clients/bgw/src/mapConfiguration.ts similarity index 100% rename from packages/clients/bgw/src/mapConfiguration.ts rename to vue2/packages/clients/bgw/src/mapConfiguration.ts diff --git a/packages/clients/bgw/src/plugins/Gfi/ActionButton.vue b/vue2/packages/clients/bgw/src/plugins/Gfi/ActionButton.vue similarity index 100% rename from packages/clients/bgw/src/plugins/Gfi/ActionButton.vue rename to vue2/packages/clients/bgw/src/plugins/Gfi/ActionButton.vue diff --git a/packages/clients/bgw/src/plugins/Gfi/Content.vue b/vue2/packages/clients/bgw/src/plugins/Gfi/Content.vue similarity index 100% rename from packages/clients/bgw/src/plugins/Gfi/Content.vue rename to vue2/packages/clients/bgw/src/plugins/Gfi/Content.vue diff --git a/packages/clients/bgw/src/polar-client.ts b/vue2/packages/clients/bgw/src/polar-client.ts similarity index 100% rename from packages/clients/bgw/src/polar-client.ts rename to vue2/packages/clients/bgw/src/polar-client.ts diff --git a/packages/clients/bgw/src/services.ts b/vue2/packages/clients/bgw/src/services.ts similarity index 100% rename from packages/clients/bgw/src/services.ts rename to vue2/packages/clients/bgw/src/services.ts diff --git a/packages/clients/bgw/src/store/module.ts b/vue2/packages/clients/bgw/src/store/module.ts similarity index 100% rename from packages/clients/bgw/src/store/module.ts rename to vue2/packages/clients/bgw/src/store/module.ts diff --git a/packages/clients/bgw/src/style.json b/vue2/packages/clients/bgw/src/style.json similarity index 100% rename from packages/clients/bgw/src/style.json rename to vue2/packages/clients/bgw/src/style.json diff --git a/packages/clients/bgw/src/utils/badestellenSearch.ts b/vue2/packages/clients/bgw/src/utils/badestellenSearch.ts similarity index 100% rename from packages/clients/bgw/src/utils/badestellenSearch.ts rename to vue2/packages/clients/bgw/src/utils/badestellenSearch.ts diff --git a/packages/clients/bgw/tsconfig.json b/vue2/packages/clients/bgw/tsconfig.json similarity index 100% rename from packages/clients/bgw/tsconfig.json rename to vue2/packages/clients/bgw/tsconfig.json diff --git a/packages/clients/bgw/vite.config.js b/vue2/packages/clients/bgw/vite.config.js similarity index 100% rename from packages/clients/bgw/vite.config.js rename to vue2/packages/clients/bgw/vite.config.js diff --git a/vue2/packages/clients/dish/CHANGELOG.md b/vue2/packages/clients/dish/CHANGELOG.md new file mode 100644 index 0000000000..72b4ec9064 --- /dev/null +++ b/vue2/packages/clients/dish/CHANGELOG.md @@ -0,0 +1,73 @@ +# CHANGELOG + +## 1.5.0 + +- Feature: Split gfi field "Flurstück" into "Flurstückszähler" and "Flurstücknenner". +- Fix: Use only `basemapGrau` as background service for `DishExportMap` Plugin. + +## 1.4.0 + +- Feature: Add client-specific `DishAttributions` plugin that wraps the standard Attributions plugin, adding a "Benutzungshinweise" link and a close button. +- Feature: Add 'Flur' to gfi and remove 'Flurstückskennzeichen' from it. +- Feature: Change highlighting Style for gfi. +- Feature: Use different background layer as default. +- Fix: Edit attributions due to current terms of use and add missing search services to static attributions. + +## 1.3.2 + +- Fix: Don't check service availability in `INTERN` mode because some services do not allow HEAD requests. + +## 1.3.1 + +- Fix: Correct parameter detection for `NewTab` in DishMapExport Plugin. + +## 1.3.0 + +- Feature: Monumental label layer toggles its visibility depending on visible monumental layer geometries. +- Feature: Alkis layer is switched to visible after parcel search result is picked. +- Feature: Search results for 'Flurstückssuche' are sorted by the server. +- Fix: Add new configuration parameters for DishExportMap to configure different host (backend host might differ from `internalHost`) and to simplify adjustments for backend changes. +- Fix: Add terms of use for internal map. +- Fix: Open links for BKG and their terms of use in new tab. +- Fix: Only a new Tab for the print-function if newTab is wanted. +- Fix: The search now returns results regardless of case(upper/lower). +- Chore: Edit urlParams configuration for new testing environment. +- Feature: Configuration changed. A maximum of 120 features per search (BKG (address search) results) are now displayed. +- Enhancement: Add 'Gemeinde' to the searchresults from the intern-Denkmal-search +- Enhancement: Changed search result to display 'ONR' before the Objektnummer +- Enhancement: The search results are now beautifully sorted, just like in DA Nord. +- Enhancement: It is now possible to search ('Flurstücksuche') for 'Gemeinde'. + +## 1.2.0 + +- Feature: If a user is geolocated outside the map's extent, the client will inform the user of why geolocation did not take effect via a textbox. +- Feature: The map can now be used for internal use with specific configurations. See the configuration section in the README for relevant configuration information. +- Feature: Add new searches for address and parcels. +- Feature: Add new background and specialist data layers. +- Feature: Add new plugin `DishExportMap` for intern mode use. +- Feature: Expand plugin `Gfi` for intern mode. +- Feature: dish search now disregards the character '/' in user inputs. +- Feature: Add new plugin `SelectObject` for intern mode use. +- Fix: Extend typing for search result function according to type package update. +- Fix: Import types `AddressSearchState` and `AddressSearchGetters` from correct position. +- Fix: Import enum `SearchResultSymbols` from correct position. +- Fix: The alt text to the "Landesdachmarke" for screen readers was missing. +- Fix: Image in gfi will only be shown if there is enough space for the minimum width. +- Chore: Change value of `pins.movable` configuration to `'drag'` as using a boolean has been deprecated in a future release. +- Chore: Upgrade `@masterportal/masterportalapi` from `2.8.0` to `2.45.0` and subsequently `ol` from `^7.1.0` to `^10.3.1`. +- Chore: Update `@polar`-dependencies to the latest versions. + +## 1.1.1 + +- Fix: The marker previously disappeared on being moved/reclicked on a second feature. This issue has been resolved. +- Fix: The pin colour was off. + +## 1.1.0 + +- Feature: Update icon of `layerChooser` in `iconMenu` to `fa-layer-group` to clear-up the content hidden behind the menu button. +- Chore: Various small changes to keep up with library updates. +- Chore: Changing internal URLs to new addresses. + +## 1.0.0 + +Initial release. diff --git a/packages/clients/dish/LICENSE b/vue2/packages/clients/dish/LICENSE similarity index 100% rename from packages/clients/dish/LICENSE rename to vue2/packages/clients/dish/LICENSE diff --git a/vue2/packages/clients/dish/README.md b/vue2/packages/clients/dish/README.md new file mode 100644 index 0000000000..4d58c456f2 --- /dev/null +++ b/vue2/packages/clients/dish/README.md @@ -0,0 +1,64 @@ +# POLAR client DISH + +## Content + +The DISH client is used to display information about monuments. It is versioned; updates require version updates. + +Please see the CHANGELOG.md for all changes after the initial release. + +## Usage + +The product can be used for two use cases. + +One is a hostable HTML page for the public. Usually, we do not deliver full pages, but rather components. Due to this, it's just that component, but full page width and height. + +The other one is a map that can be embedded in the dish application for internal use and as such is tailored for this specific use case. + +Add a query parameter, e.g. `?ObjektID=1506`, to the page's URL to initially focus a feature and display its feature information by ObjektID. + +Name and casing of "ObjektID" have been directly taken from the backend to avoid duplicate naming. + +For the internal map, another query parameter, e.g. `?NewTab=false`, can be given. This parameter is used in the URL for the plugin DishExportMap and defines if the backend creates a link to go back to the previous page (`NewTab=false`) or not (`NewTab=true`). + +## Configuration + +| fieldName | type | description | +| - | - | - | +| containerId | string | ID of the container the map is supposed to render itself to. | +| mode | enum["INTERN", "EXTERN"] | Defines the mode in which the map will be started. | +| urlParams | DishUrlParams? | Object to define the internalHost and internServicesBaseUrl for internal services. Mandatory for the mode 'INTERN'. | +| configOverride | object? | This can be used to override the configuration of any installed plugin; see full documentation. In this case, use this object with the plugin name 'gfi' as property to define the `internalHost` for these plugin. Mandatory for the mode 'INTERN'. | + +### urlParams + +| fieldName | type | description | +| - | - | - | +| internalHost | string | The URL of the server where the DISH software and the monument services are hosted. | +| internServicesBaseUrl | string | A combination of host, port and path to create a base URL that can be used for the monument services that run on the same server. | +| printHostDeegree |string | The URL of the backend server that is used to send requests for the DishExportMap plugin. | +| printServicesBaseUrl | string | The base URL for the backend WMS and WFS that are used in the DishExportMap plugin. | + +The `internalHost` is also needed as parameter for the gfi plugin. It displays photographs of the monuments and uses the parameter as path to the right folder on the server. + +### Example configuration + +```js +const urlParams = { + internalHost, + internServicesBaseUrl: `${internalHost}:${internalPort}/${internalPath}` + printHostDeegree, + printServicesBaseUrl: `${printHostDeegree}:${printHostPort}/${printPath}`, +} + +client.createMap({ + containerId: 'polarstern', + mode: 'INTERN', // INTERN, EXTERN + // only needed for internal map + urlParams, + configOverride: { + gfi: { + internalHost: urlParams.internalHost, + } + } +}) +``` \ No newline at end of file diff --git a/vue2/packages/clients/dish/package.json b/vue2/packages/clients/dish/package.json new file mode 100644 index 0000000000..7e9c15c234 --- /dev/null +++ b/vue2/packages/clients/dish/package.json @@ -0,0 +1,62 @@ +{ + "name": "@polar/client-dish", + "version": "1.5.0", + "description": "POLAR Client DISH. This client provides information about monuments in Schleswig-Holstein, Germany.", + "keywords": [ + "OpenLayers", + "ol", + "POLAR", + "client", + "DISH", + "monument", + "memorial" + ], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "repository": { + "type": "git", + "url": "git+https://github.com/Dataport/polar.git", + "directory": "packages/clients/dish" + }, + "main": "dist/client-dish.js", + "files": [ + "dist/**/**.*", + "CHANGELOG.md" + ], + "scripts": { + "prepublishOnly": "npm run build", + "build": "rimraf dist && vite build && copyfiles -f src/html/**/* dist", + "dev": "vite --host" + }, + "peerDependencies": { + "@repositoryname/vuex-generators": "^1.1.2" + }, + "devDependencies": { + "@masterportal/masterportalapi": "2.48.0", + "@polar/core": "^3.0.0", + "@polar/lib-custom-types": "^2.0.0", + "@polar/lib-get-features": "^3.2.0", + "@polar/plugin-address-search": "^3.3.0", + "@polar/plugin-attributions": "^1.4.0", + "@polar/plugin-geo-location": "^2.0.0", + "@polar/plugin-gfi": "^3.0.0", + "@polar/plugin-icon-menu": "^1.3.1", + "@polar/plugin-layer-chooser": "^2.0.0", + "@polar/plugin-legend": "^1.1.2", + "@polar/plugin-loading-indicator": "^1.2.1", + "@polar/plugin-pins": "^3.0.0", + "@polar/plugin-scale": "^3.0.0", + "@polar/plugin-toast": "^1.1.2", + "@polar/plugin-zoom": "^1.4.0", + "focus-trap": "^7.6.0", + "js-levenshtein": "^1.1.6", + "lodash.merge": "^4.6.2" + }, + "nx": { + "includedScripts": [ + "build", + "dev" + ] + } +} diff --git a/vue2/packages/clients/dish/src/addPlugins.ts b/vue2/packages/clients/dish/src/addPlugins.ts new file mode 100644 index 0000000000..75a3d70c94 --- /dev/null +++ b/vue2/packages/clients/dish/src/addPlugins.ts @@ -0,0 +1,190 @@ +import { setLayout, NineLayout, NineLayoutTag } from '@polar/core' +import PolarPluginAddressSearch from '@polar/plugin-address-search' +import PolarPluginAttributions from '@polar/plugin-attributions' +import PolarPluginDraw from '@polar/plugin-draw' +import PolarPluginExport from '@polar/plugin-export' +import PolarPluginFullscreen from '@polar/plugin-fullscreen' +import PolarPluginGeoLocation from '@polar/plugin-geo-location' +import PolarPluginGfi from '@polar/plugin-gfi' +import PolarPluginIconMenu from '@polar/plugin-icon-menu' +import PolarPluginLayerChooser from '@polar/plugin-layer-chooser' +import PolarPluginLegend from '@polar/plugin-legend' +import PolarPluginLoadingIndicator from '@polar/plugin-loading-indicator' +import PolarPluginPins from '@polar/plugin-pins' +import PolarPluginScale from '@polar/plugin-scale' +import PolarPluginToast from '@polar/plugin-toast' +import PolarPluginZoom from '@polar/plugin-zoom' + +import { + AddressSearchConfiguration, + GfiConfiguration, + SearchMethodFunction, +} from '@polar/lib-custom-types' +import { extendGfi } from './utils/extendGfi' +import { search } from './utils/search' +import { + autocomplete, + initializeAutocomplete, + selectResult, +} from './utils/autocomplete' +import { denkmalSearchResult } from './utils/denkmalSearchIntern' +import DishModal from './plugins/Modal' +import DishHeader from './plugins/Header' +import { MODE } from './enums' +import { DishGfiIntern, DishGfiExtern } from './plugins/Gfi' +import DishExportMap from './plugins/DishExportMap' +import SelectionObject from './plugins/SelectionObject' +import DishAttributions from './plugins/Attributions' +import { searchMethods } from './mapConfigurations/searchConfigParams' + +const gfiConfig = (mode: keyof typeof MODE) => { + const gfiConfig: GfiConfiguration = { + displayComponent: true, + layoutTag: NineLayoutTag.TOP_LEFT, + layers: {}, + coordinateSources: ['plugin/addressSearch/chosenAddress'], + gfiContentComponent: mode === MODE.EXTERN ? DishGfiExtern : DishGfiIntern, + } + if (mode === MODE.EXTERN) { + gfiConfig.afterLoadFunction = extendGfi + } + return gfiConfig +} + +const addressSearchConfig = (mode: keyof typeof MODE) => { + const addressSearchConfig: AddressSearchConfiguration = { + // These will be set later on + searchMethods: [], + displayComponent: true, + layoutTag: NineLayoutTag.TOP_LEFT, + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + customSelectResult: + mode === MODE.EXTERN + ? { [searchMethods.denkmalsucheAutocomplete.categoryId]: selectResult } + : { + [searchMethods.denkmalsucheDishIntern.categoryId]: + denkmalSearchResult, + }, + } + if (mode === MODE.EXTERN) { + initializeAutocomplete() + addressSearchConfig.customSearchMethods = { + dish: search as SearchMethodFunction, + autocomplete, + } + } + return addressSearchConfig +} + +const attributionsOptions = { + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + listenToChanges: [ + 'plugin/zoom/zoomLevel', + 'plugin/layerChooser/activeBackgroundId', + 'plugin/layerChooser/activeMaskIds', + ], +} + +const attributionsConfig = (mode: keyof typeof MODE) => { + if (mode === MODE.INTERN) { + return DishAttributions({ + icons: { open: 'fa-solid fa-info', close: 'fa-chevron-right' }, + ...attributionsOptions, + }) + } + return PolarPluginAttributions(attributionsOptions) +} + +export const addPlugins = (core, mode: keyof typeof MODE = 'EXTERN') => { + const internalMenu = [ + { + plugin: PolarPluginLayerChooser({}), + icon: 'fa-layer-group', + id: 'layerChooser', + }, + { + plugin: SelectionObject({ renderType: 'iconMenu' }), + id: 'selectionObject', + }, + { + plugin: PolarPluginDraw({}), + icon: 'fa-pencil', + id: 'draw', + }, + { + plugin: PolarPluginFullscreen({ renderType: 'iconMenu' }), + id: 'fullscreen', + }, + ] + const externalMenu = [ + { + plugin: PolarPluginLayerChooser({}), + icon: 'fa-layer-group', + id: 'layerChooser', + }, + ] + const iconMenu = PolarPluginIconMenu({ + displayComponent: true, + menus: mode === MODE.INTERN ? internalMenu : externalMenu, + layoutTag: NineLayoutTag.TOP_RIGHT, + }) + + setLayout(NineLayout) + + core.addPlugins([ + iconMenu, + DishModal({ + displayComponent: true, + layoutTag: NineLayoutTag.TOP_LEFT, + }), + DishHeader({ + displayComponent: mode === MODE.EXTERN, + layoutTag: NineLayoutTag.TOP_MIDDLE, + }), + PolarPluginAddressSearch( + addressSearchConfig(mode) as AddressSearchConfiguration + ), + PolarPluginPins({ + displayComponent: mode === MODE.EXTERN, + appearOnClick: { show: true, atZoomLevel: 6 }, + coordinateSource: 'plugin/addressSearch/chosenAddress', + layoutTag: NineLayoutTag.TOP_LEFT, + }), + PolarPluginLegend({ + displayComponent: mode === MODE.EXTERN, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + }), + attributionsConfig(mode), + PolarPluginGfi(gfiConfig(mode)), + PolarPluginLoadingIndicator({ + displayComponent: true, + layoutTag: NineLayoutTag.MIDDLE_MIDDLE, + }), + PolarPluginScale({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + }), + PolarPluginToast({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_MIDDLE, + }), + PolarPluginZoom({ + displayComponent: true, + layoutTag: NineLayoutTag.MIDDLE_RIGHT, + }), + PolarPluginGeoLocation({ + displayComponent: mode === MODE.EXTERN, + layoutTag: NineLayoutTag.MIDDLE_RIGHT, + }), + DishExportMap({ + displayComponent: mode === MODE.INTERN, + layoutTag: NineLayoutTag.BOTTOM_LEFT, + }), + PolarPluginExport({ + displayComponent: mode === MODE.INTERN, + layoutTag: NineLayoutTag.BOTTOM_LEFT, + }), + ]) +} diff --git a/packages/clients/dish/src/colors.ts b/vue2/packages/clients/dish/src/colors.ts similarity index 100% rename from packages/clients/dish/src/colors.ts rename to vue2/packages/clients/dish/src/colors.ts diff --git a/packages/clients/dish/src/enums.ts b/vue2/packages/clients/dish/src/enums.ts similarity index 100% rename from packages/clients/dish/src/enums.ts rename to vue2/packages/clients/dish/src/enums.ts diff --git a/packages/clients/dish/src/html/index.html b/vue2/packages/clients/dish/src/html/index.html similarity index 100% rename from packages/clients/dish/src/html/index.html rename to vue2/packages/clients/dish/src/html/index.html diff --git a/vue2/packages/clients/dish/src/index.html b/vue2/packages/clients/dish/src/index.html new file mode 100644 index 0000000000..60c6ab9c80 --- /dev/null +++ b/vue2/packages/clients/dish/src/index.html @@ -0,0 +1,61 @@ + + + + + + + DISH-Kartenklient + + + +
+
+ +
+
+ + + diff --git a/packages/clients/dish/src/locales.ts b/vue2/packages/clients/dish/src/locales.ts similarity index 100% rename from packages/clients/dish/src/locales.ts rename to vue2/packages/clients/dish/src/locales.ts diff --git a/packages/clients/dish/src/mapConfigurations/attributionsConfig.ts b/vue2/packages/clients/dish/src/mapConfigurations/attributionsConfig.ts similarity index 83% rename from packages/clients/dish/src/mapConfigurations/attributionsConfig.ts rename to vue2/packages/clients/dish/src/mapConfigurations/attributionsConfig.ts index 7399fbf883..92ab9c600d 100644 --- a/packages/clients/dish/src/mapConfigurations/attributionsConfig.ts +++ b/vue2/packages/clients/dish/src/mapConfigurations/attributionsConfig.ts @@ -4,16 +4,16 @@ export const denkmalAmtLink = 'Landesamt für Denkmalpflege' export const vermessungsAmtLink = - 'Geobasis-DE/LVermGeo SH' + 'GeoBasis-DE/LVermGeo SH' export const attributionsBasemapGrau = { id: basemapGrau, title: - 'Karte Basemap.de (Graustufen): basemap.de / BKG ', + 'Karte Basemap.de (Graustufen): basemap.de / BKG CC BY 4.0', } export const attributionsAlkisWms = { id: alkisWms, title: - 'Karte Flurstücke gemäss ALKIS-Objektartenkatalog © Geobasis-DE/LVermGeo SH ', + 'Karte Flurstücke gemäss ALKIS-Objektartenkatalog © Geobasis-DE/LVermGeo SH ', } diff --git a/packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts b/vue2/packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts similarity index 97% rename from packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts rename to vue2/packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts index d0c621df42..622a968578 100644 --- a/packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts +++ b/vue2/packages/clients/dish/src/mapConfigurations/layerConfigIntern.ts @@ -19,19 +19,19 @@ import { } from '../servicesConstants' import { scaleFromZoomLevel } from '../utils/calculateScaleFromResolution' -const alkisMinZoom = 10 -const beschriftungMinZoom = 9 +export const alkisMinZoom = 10 +export const beschriftungMinZoom = 9 const layersIntern: LayerConfiguration[] = [ { id: basemapGrau, - visibility: true, + visibility: false, type: 'background', name: 'Basemap.de Graustufen', }, { id: bddEin, - visibility: false, + visibility: true, type: 'background', name: 'Grundkarte Graustufen', }, @@ -47,15 +47,6 @@ const layersIntern: LayerConfiguration[] = [ type: 'background', name: 'Luftbild (Farbe)', }, - { - id: beschriftung, - visibility: true, - type: 'mask', - name: `Beschriftung (ab 1:${thousandsSeparator( - scaleFromZoomLevel(beschriftungMinZoom) - )})`, - minZoom: 9, - }, { id: denkmaelerWFS, visibility: false, @@ -157,6 +148,15 @@ const layersIntern: LayerConfiguration[] = [ )})`, minZoom: alkisMinZoom, }, + { + id: beschriftung, + visibility: true, + type: 'mask', + name: `Beschriftung (ab 1:${thousandsSeparator( + scaleFromZoomLevel(beschriftungMinZoom) + )})`, + minZoom: beschriftungMinZoom, + }, ] export default layersIntern diff --git a/packages/clients/dish/src/mapConfigurations/mapConfig.ts b/vue2/packages/clients/dish/src/mapConfigurations/mapConfig.ts similarity index 87% rename from packages/clients/dish/src/mapConfigurations/mapConfig.ts rename to vue2/packages/clients/dish/src/mapConfigurations/mapConfig.ts index 141b38cf78..ae9eadd0c4 100644 --- a/packages/clients/dish/src/mapConfigurations/mapConfig.ts +++ b/vue2/packages/clients/dish/src/mapConfigurations/mapConfig.ts @@ -28,7 +28,12 @@ const commonMapConfiguration: Partial = { export const getMapConfiguration = ( mode: string, - urlParams: DishUrlParams = { internalHost: '', internServicesBaseUrl: '' } + urlParams: DishUrlParams = { + internalHost: '', + internServicesBaseUrl: '', + printHostDeegree: '', + printServicesBaseUrl: '', + } ): DishMapConfig => ({ ...commonMapConfiguration, ...(mode === 'INTERN' ? mapConfigIntern(urlParams) : mapConfigExtern), diff --git a/packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts b/vue2/packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts similarity index 92% rename from packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts rename to vue2/packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts index 723597186f..6b24541bc6 100644 --- a/packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts +++ b/vue2/packages/clients/dish/src/mapConfigurations/mapConfigExtern.ts @@ -30,7 +30,6 @@ import { const alkisMinZoom = 10 export const mapConfigExtern: DishMapConfig = { - checkServiceAvailability: false, geoLocation: { checkLocationInitially: false, toastAction: 'plugin/toast/addToast', @@ -107,23 +106,25 @@ export const mapConfigExtern: DishMapConfig = { attributionsBasemapGrau, { id: bddEin, - title: `Digitale Topographische Karten (Graustufen): © ${vermessungsAmtLink} `, + title: `Digitale Topographische Karten (Graustufen): © ${vermessungsAmtLink}/CC BY-SA 4.0`, }, { id: bddCol, - title: `Digitale Topographische Karten (Farbe): © ${vermessungsAmtLink} `, + title: `Digitale Topographische Karten (Farbe): © ${vermessungsAmtLink}/CC BY-SA 4.0`, }, { id: dop20col, - title: `Karte Luftbilder (Farbe): © ${vermessungsAmtLink} `, + title: `Karte Luftbilder (Farbe): © ${vermessungsAmtLink}/CC BY-SA 4.0`, }, { id: denkmaelerWMS, - title: `Karte Kulturdenkmale (Denkmalliste): © ${denkmalAmtLink} `, + title: `Karte Kulturdenkmale (Denkmalliste): © ${denkmalAmtLink}`, }, attributionsAlkisWms, ], staticAttributions: [ + `Dienst für Adressuche: Geobasisdaten: © GeoBasis-DE / BKG Nutzungsbedingungen`, + `Dienst für Flurstückssuche: © ${vermessungsAmtLink}`, `
  • Kontakt diff --git a/packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts b/vue2/packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts similarity index 80% rename from packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts rename to vue2/packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts index 26345f4cad..f8e4ca23b3 100644 --- a/packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts +++ b/vue2/packages/clients/dish/src/mapConfigurations/mapConfigIntern.ts @@ -12,7 +12,7 @@ import { verwaltung, } from '../servicesConstants' import { shBlue } from '../colors' -import { DishMapConfig, DishUrlParams } from '../types' +import { DishMapConfig, DishUrlParams, backgroundLayer } from '../types' import { categoryProps, groupProperties, @@ -27,7 +27,6 @@ import { } from './attributionsConfig' export const mapConfigIntern = (urlParams: DishUrlParams): DishMapConfig => ({ - checkServiceAvailability: true, scale: { showScaleSwitcher: true, zoomMethod: 'plugin/zoom/setZoomLevel', @@ -51,15 +50,15 @@ export const mapConfigIntern = (urlParams: DishUrlParams): DishMapConfig => ({ }, { id: denkmaelerWMS, - title: `Karte Kulturdenkmale (Denkmalliste): © ${denkmalAmtLink} `, + title: `Karte Kulturdenkmale (Denkmalliste): © ${denkmalAmtLink}`, }, { id: kontrollbedarf, - title: `Karte Objekte mit Kontrollbedarf: © ${denkmalAmtLink} `, + title: `Karte Objekte mit Kontrollbedarf: © ${denkmalAmtLink}`, }, { id: verlust, - title: `Karte Verlust: © ${denkmalAmtLink} `, + title: `Karte Verlust: © ${denkmalAmtLink}`, }, { id: verwaltung, @@ -68,8 +67,8 @@ export const mapConfigIntern = (urlParams: DishUrlParams): DishMapConfig => ({ attributionsAlkisWms, ], staticAttributions: [ - `Geobasisdaten: © GeoBasis-DE / BKG 2024 Nutzungsbedingungen`, - 'Benutzungshinweise', + `Dienst für Adressuche: Geobasisdaten: © GeoBasis-DE / BKG Nutzungsbedingungen`, + `Dienst für Flurstückssuche: © ${vermessungsAmtLink}`, ], }, dishModal: { @@ -152,7 +151,7 @@ export const mapConfigIntern = (urlParams: DishUrlParams): DishMapConfig => ({ width: 3, }, fill: { - color: 'rgb(255, 255, 255, 0.7)', + color: 'rgb(255, 255, 255, 0)', }, }, }, @@ -177,6 +176,14 @@ export const mapConfigIntern = (urlParams: DishUrlParams): DishMapConfig => ({ propertyNameWFS: 'objektid', filterTypeWFS: 'EQUAL_TO', printImagePath: 'ContentMapsTmp', - urlParams, + wmsLayerUrl: `${urlParams.printServicesBaseUrl}/wms`, + wfsLayerUrl: `${urlParams.printServicesBaseUrl}/wfs`, + wfsLayerFeatureType: 'app:TBLGIS_ORA', + printImageUrlProd: `${urlParams.printHostDeegree}/Content/MapsTmp`, + exportMapAsPdfUrl: `${urlParams.printHostDeegree}/Content/Objekt/Kartenausgabe.aspx`, + backgroundLayer: { + url: 'https://sgx.geodatenzentrum.de/wms_basemapde', + layers: 'de_basemapde_web_raster_grau', + } as backgroundLayer, }, }) diff --git a/vue2/packages/clients/dish/src/mapConfigurations/searchConfigParams.ts b/vue2/packages/clients/dish/src/mapConfigurations/searchConfigParams.ts new file mode 100644 index 0000000000..f0dd722da6 --- /dev/null +++ b/vue2/packages/clients/dish/src/mapConfigurations/searchConfigParams.ts @@ -0,0 +1,183 @@ +import { AddressSearchGroupProperties } from '@polar/lib-custom-types' +import { BKGParameters } from '@polar/plugin-address-search' +import { + dishCloudBaseUrl, + dishBaseUrl, + denkmaelerWFS, + alkisWfs, +} from '../servicesConstants' +import { sortFeaturesByProperties } from '../utils/sortFeaturesByProperties' + +const groupDenkmalsuche = 'groupDenkmalsuche' +export const categoryIdAlkisSearch = 'categoryIdAlkisSearch' + +export const searchMethods = { + denkmalsucheAutocomplete: { + groupId: groupDenkmalsuche, + categoryId: 'categoryDenkmalsucheAutocomplete', + type: 'autocomplete', + // NOTE exotic, doesn't need URL + url: 'example.com', + queryParameters: { + maxFeatures: 120, + }, + }, + denkmalsucheDishExtern: { + groupId: groupDenkmalsuche, + categoryId: 'categoryDenkmalsucheDishExtern', + type: 'dish', + url: `${dishBaseUrl}/dish_service/service.aspx`, + queryParameters: { + wfsConfiguration: { + id: denkmaelerWFS, + srsName: 'EPSG:25832', + typeName: 'dish_shp', + fieldName: 'objektid', + featurePrefix: 'app', + xmlns: 'http://www.deegree.org/app', + }, + maxFeatures: 120, + searchKey: 'volltext', + addRightHandWildcard: true, + topic: null, + }, + }, + bkgSearch: { + groupId: groupDenkmalsuche, + categoryId: 'categoryIdBkgSearch', + queryParameters: { + maxFeatures: 120, + filter: { + bundesland: 'Schleswig-Holstein', + }, + } as BKGParameters, + type: 'bkg', + url: `${dishCloudBaseUrl}/search/geosearch.json`, + }, + denkmalsucheDishIntern: { + groupId: groupDenkmalsuche, + categoryId: 'categoryDenkmalsucheDishIntern', + type: 'wfs', + // url is in mapConfig due to variable setting, + queryParameters: { + id: denkmaelerWFS, + srsName: 'EPSG:25832', + typeName: 'TBLGIS_ORA', + featurePrefix: 'app', + xmlns: 'http://www.deegree.org/app', + useRightHandWildcard: true, + caseSensitive: false, + maxFeatures: 120, + patternKeys: { + hausnummer: '([0-9]+)', + strasse: '([A-Za-zäöüßÄÖÜ]+)', + objektansprache: '([A-Za-zäöüßÄÖÜ]+)', + kreis_kue: '([A-Za-zäöüßÄÖÜ]+)', + gemeinde: '([A-Za-zäöüßÄÖÜ]+)', + objektid: '([0-9]+)', + }, + patterns: [ + '{{objektansprache}}, {{strasse}} {{hausnummer}}, {{kreis_kue}}, {{gemeinde}}, ONR {{objektid}}', + '{{strasse}} {{hausnummer}}, {{kreis_kue}}, {{gemeinde}}', + '{{objektansprache}}, {{gemeinde}}, ONR {{objektid}}', + ], + }, + resultModifier: (featureCollection) => { + if ( + featureCollection.features === undefined || + featureCollection.features === null + ) { + return featureCollection + } + const featuresSorted = sortFeaturesByProperties( + featureCollection.features, + ['gemeinde', 'objektansprache', 'strasse', 'hausnummer', 'objektid'] + ) + return { + ...featureCollection, + features: featuresSorted, + } + }, + }, + + alkisSearch: { + groupId: groupDenkmalsuche, + categoryId: categoryIdAlkisSearch, + type: 'wfs', + // will be set later due to mode setting + url: null, + queryParameters: { + id: alkisWfs, + maxFeatures: 120, + srsName: 'EPSG:25832', + typeName: 'Flurstueck', + featurePrefix: 'ave', + xmlns: + 'http://repository.gdi-de.org/schemas/adv/produkt/alkis-vereinfacht/2.0', + patternKeys: { + flstnrnen: '([0-9]+)', + flstnrzae: '([0-9]+)', + gemarkung: '([A-Za-zäöüßÄÖÜ]+)', + gemeinde: '([A-Za-zäöüßÄÖÜ]+)', + flstkennz: '([0-9_]+)', + flur: '([0-9]+)', + }, + patterns: [ + '{{gemeinde}}, {{gemarkung}} {{flur}}, {{flstnrzae}}/{{flstnrnen}}, {{flstkennz}}', + '{{gemeinde}}, {{gemarkung}} {{flur}}, {{flstnrzae}}, {{flstkennz}}', + '{{flstkennz}}', + ], + sortBy: [ + { propertyName: 'gemeinde', direction: 'ASC' }, + { propertyName: 'gemarkung', direction: 'ASC' }, + { propertyName: 'flur', direction: 'ASC' }, + { propertyName: 'flstnrzae', direction: 'ASC' }, + { propertyName: 'flstnrnen', direction: 'ASC' }, + ], + }, + resultModifier: (featureCollection) => { + if ( + featureCollection.features === undefined || + featureCollection.features === null + ) { + return featureCollection + } + const featuresSorted = sortFeaturesByProperties( + featureCollection.features, + ['gemeinde', 'gemarkung', 'flur', 'flstnrzae', 'flstnrnen'], + ['flur', 'flstnrzae', 'flstnrnen'] + ) + return { + ...featureCollection, + features: featuresSorted, + } + }, + }, +} + +export const categoryProps = { + categoryDenkmalsucheAutocomplete: { + label: 'Denkmalsuche Stichworte Treffer', + }, + categoryDenkmalsucheDishExtern: { + label: 'Denkmalsuche Treffer', + }, + categoryDenkmalsucheDishIntern: { + label: 'Denkmalsuche Treffer', + }, + categoryIdBkgSearch: { + label: 'Adresssuche Treffer', + }, + categoryIdAlkisSearch: { + label: 'Flurstückssuche Treffer', + }, +} + +export const groupProperties: Record = { + [groupDenkmalsuche]: { + label: 'Suche Denkmal, Adresse, Flurstück', + hint: 'dish.addressSearchHint', + resultDisplayMode: 'categorized', + limitResults: 3, + }, +} diff --git a/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributionContent.vue b/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributionContent.vue new file mode 100644 index 0000000000..b0934d0699 --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributionContent.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributions.vue b/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributions.vue new file mode 100644 index 0000000000..d0271180b4 --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/Attributions/DishAttributions.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/vue2/packages/clients/dish/src/plugins/Attributions/index.ts b/vue2/packages/clients/dish/src/plugins/Attributions/index.ts new file mode 100644 index 0000000000..6c0c062da0 --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/Attributions/index.ts @@ -0,0 +1,14 @@ +import Vue from 'vue' +import { AttributionsConfiguration } from '@polar/lib-custom-types' +import locales from '@polar/plugin-attributions/src/locales' +import { makeStoreModule } from '@polar/plugin-attributions/src/store' +import DishAttributions from './DishAttributions.vue' + +export default (options: AttributionsConfiguration) => (instance: Vue) => + instance.$store.dispatch('addComponent', { + name: 'attributions', + plugin: DishAttributions, + locales, + storeModule: makeStoreModule(), + options, + }) diff --git a/vue2/packages/clients/dish/src/plugins/DishExportMap/DishExportMap.vue b/vue2/packages/clients/dish/src/plugins/DishExportMap/DishExportMap.vue new file mode 100644 index 0000000000..e5a1d0d5aa --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/DishExportMap/DishExportMap.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/vue2/packages/clients/dish/src/plugins/DishExportMap/README.md b/vue2/packages/clients/dish/src/plugins/DishExportMap/README.md new file mode 100644 index 0000000000..c46505ddfb --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/DishExportMap/README.md @@ -0,0 +1,62 @@ +# DishExportMap + +DishExportMap is a plugin that was created specifically for the internal use of the DISH client. It creates a URL to address a backend that triggeres a PDF print with information about a selected monument and a map section centering on the selected monument. + +The original print feature was part of the outdated map in the internal DISH software, so the focus was on recreating the old feature and implement it in the polar client. The backend expects specific values and does not leave much room for different configurations which is the reason for a lot of hardcoded values. Due to missing documentation of the original feature, it is not always clear as to why some values must be set or what their meaning is. The URL has to be composed in a certain way to address the backend so that the print works successfully. + +Please note that the WMS and WFS used for the print might not be the same as configured in the map and as to this moment cannot be changed. For this reason, they are configured here separately. + +## Plugin Configuration + +The following parameters for the plugin must be defined in the map configuration. As to this moment, most of these should not be changed due to the restricted backend. + +| parameter name | type | description | +| - | - | - | +| printApproach | string | No description available. | +| printRequester | string | No description available. | +| xPrint | number | No description available. | +| yPrint | number | No description available. | +| versionHintergrund | string | Version for background service. | +| proxyHintergrund | string | No description available. | +| versionWMS | string | The version of the configured WMS. | +| layerNameWMS | string | Layers from the WMS to print. Since they differ from the monument configuration, they are hardcoded and taken from the configuration of the original application to recreate the right look for the map section. | +| versionWFS | string | Version for WFS. | +| propertyNameWFS | string | No description available. | +| filterTypeWFS | string | No description available. | +| printImageUrlProd | internalHost + '/Content/MapsTmp' | Probably the URL to the created map section. The internalHost is set in the urlParams. | +| wmsLayerUrl | string | The url to the WMS used for the print. | +| wfsLayerUrl | string | The url to the WFS used for the print. | +| wfsLayerFeatureType | string | The feature type of the wfs configured in wfsLayerUrl. | +| printImagePath | string | Probably the relative path to the created map section. | +| backgroundLayer | backgroundLayer | An object with `url` and `layers` properties. `url` specifies the WMS service URL for the background layer, and `layers` specifies the layer names to display. | + + +### example configuration + +```js +dishExportMap: { + printApproach: 'scale', + printRequester: 'client', + xPrint: 18, + yPrint: 20, + versionHintergrund: '1.1.1' // ⚠️ Do not change + proxyHintergrund: 'y', + versionWMS: '1.1.1', + layerNameWMS: + '0,9,1,10,2,11,3,12,4,13,25,27,24,26,6,15,19,30,20,31,21,32,22,33,23,34,29,36,28,35', + versionWFS: '1.1.0' // ⚠️ Do not change + propertyNameWFS: 'objektid', + filterTypeWFS: 'EQUAL_TO', + printImagePath: 'ContentMapsTmp', + wmsLayerUrl: 'http://10.61.63.54:8081/dish-deegree-3.5.0/services/wms', // ⚠️ Do not change + wfsLayerUrl: 'http://10.61.63.54:8081/dish-deegree-3.5.0/services/wfs', // ⚠️ Do not change + wfsLayerFeatureType: 'app:TBLGIS_ORA', + printImageUrlProd: `${urlParams.internalHost}/Content/MapsTmp`, + exportMapAsPdfUrl: `${urlParams.internalHost}/Content/Objekt/Kartenausgabe.aspx`, + backgroundLayer: {url: 'https://sgx.geodatenzentrum.de/wms_basemapde', layers: 'de_basemapde_web_raster_grau'} +}, +``` + +## Usage + +The user selects a feature from the monument WMS within the map that they want to print as a pdf. This selection activates the button "Kartendruck PDF". After pressing the button, a dialog with the editable title for the PDF and a rectangular overlay to show the extent for the map section is displayed. If the user confirms with pressing "Karte drucken", the browser opens a new tab while addressing the backend with the composed URL. The PDF-to-print is shown in this new browser tab. \ No newline at end of file diff --git a/packages/clients/dish/src/plugins/DishExportMap/index.ts b/vue2/packages/clients/dish/src/plugins/DishExportMap/index.ts similarity index 100% rename from packages/clients/dish/src/plugins/DishExportMap/index.ts rename to vue2/packages/clients/dish/src/plugins/DishExportMap/index.ts diff --git a/packages/clients/dish/src/plugins/DishExportMap/locales.ts b/vue2/packages/clients/dish/src/plugins/DishExportMap/locales.ts similarity index 100% rename from packages/clients/dish/src/plugins/DishExportMap/locales.ts rename to vue2/packages/clients/dish/src/plugins/DishExportMap/locales.ts diff --git a/packages/clients/dish/src/plugins/Gfi/ActionButton.vue b/vue2/packages/clients/dish/src/plugins/Gfi/ActionButton.vue similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/ActionButton.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/ActionButton.vue diff --git a/packages/clients/dish/src/plugins/Gfi/ContentExtern.vue b/vue2/packages/clients/dish/src/plugins/Gfi/ContentExtern.vue similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/ContentExtern.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/ContentExtern.vue diff --git a/packages/clients/dish/src/plugins/Gfi/ContentIntern.vue b/vue2/packages/clients/dish/src/plugins/Gfi/ContentIntern.vue similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/ContentIntern.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/ContentIntern.vue diff --git a/packages/clients/dish/src/plugins/Gfi/MonumentContent.vue b/vue2/packages/clients/dish/src/plugins/Gfi/MonumentContent.vue similarity index 93% rename from packages/clients/dish/src/plugins/Gfi/MonumentContent.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/MonumentContent.vue index fe2ddf645b..ba9fa465ec 100644 --- a/packages/clients/dish/src/plugins/Gfi/MonumentContent.vue +++ b/vue2/packages/clients/dish/src/plugins/Gfi/MonumentContent.vue @@ -64,9 +64,10 @@ export default Vue.extend({ infoFieldsAdress: ['strasse', 'hausnummer', 'hausnrzusatz'], infoFieldsParcels: [ { key: 'gemarkung', label: 'Gemarkung' }, - { key: 'flstkennz', label: 'Flurstückskennzeichen' }, + { key: 'flur', label: 'Flur' }, + { key: 'flstnrzae', label: 'Flurstückszähler' }, + { key: 'flstnrnen', label: 'Flurstücksnenner' }, ], - infoFieldsParcelNumber: ['flstnrzae', 'flstnrnen'], }), computed: { ...mapGetters([ @@ -144,13 +145,6 @@ export default Vue.extend({ currentProperties: Record ): Array { const tableData = prepareData(currentProperties, this.infoFieldsParcels) - const parcelNumber = createComposedField( - this.infoFieldsParcelNumber, - currentProperties, - 'Flurstück', - '/' - ) - if (parcelNumber) addComposedField(parcelNumber, 'Gemarkung', tableData) return tableData }, diff --git a/packages/clients/dish/src/plugins/Gfi/SharedContent.vue b/vue2/packages/clients/dish/src/plugins/Gfi/SharedContent.vue similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/SharedContent.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/SharedContent.vue diff --git a/packages/clients/dish/src/plugins/Gfi/SwitchButton.vue b/vue2/packages/clients/dish/src/plugins/Gfi/SwitchButton.vue similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/SwitchButton.vue rename to vue2/packages/clients/dish/src/plugins/Gfi/SwitchButton.vue diff --git a/packages/clients/dish/src/plugins/Gfi/index.ts b/vue2/packages/clients/dish/src/plugins/Gfi/index.ts similarity index 100% rename from packages/clients/dish/src/plugins/Gfi/index.ts rename to vue2/packages/clients/dish/src/plugins/Gfi/index.ts diff --git a/packages/clients/dish/src/plugins/Header/Header.vue b/vue2/packages/clients/dish/src/plugins/Header/Header.vue similarity index 100% rename from packages/clients/dish/src/plugins/Header/Header.vue rename to vue2/packages/clients/dish/src/plugins/Header/Header.vue diff --git a/packages/clients/dish/src/plugins/Header/index.ts b/vue2/packages/clients/dish/src/plugins/Header/index.ts similarity index 100% rename from packages/clients/dish/src/plugins/Header/index.ts rename to vue2/packages/clients/dish/src/plugins/Header/index.ts diff --git a/packages/clients/dish/src/plugins/Header/locales.ts b/vue2/packages/clients/dish/src/plugins/Header/locales.ts similarity index 100% rename from packages/clients/dish/src/plugins/Header/locales.ts rename to vue2/packages/clients/dish/src/plugins/Header/locales.ts diff --git a/packages/clients/dish/src/plugins/Modal/Hints.vue b/vue2/packages/clients/dish/src/plugins/Modal/Hints.vue similarity index 100% rename from packages/clients/dish/src/plugins/Modal/Hints.vue rename to vue2/packages/clients/dish/src/plugins/Modal/Hints.vue diff --git a/vue2/packages/clients/dish/src/plugins/Modal/HintsIntern.vue b/vue2/packages/clients/dish/src/plugins/Modal/HintsIntern.vue new file mode 100644 index 0000000000..fb62e1ad3a --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/Modal/HintsIntern.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/clients/dish/src/plugins/Modal/Modal.vue b/vue2/packages/clients/dish/src/plugins/Modal/Modal.vue similarity index 100% rename from packages/clients/dish/src/plugins/Modal/Modal.vue rename to vue2/packages/clients/dish/src/plugins/Modal/Modal.vue diff --git a/packages/clients/dish/src/plugins/Modal/SharedHints.vue b/vue2/packages/clients/dish/src/plugins/Modal/SharedHints.vue similarity index 89% rename from packages/clients/dish/src/plugins/Modal/SharedHints.vue rename to vue2/packages/clients/dish/src/plugins/Modal/SharedHints.vue index 2a1d471864..01e5a3717d 100644 --- a/packages/clients/dish/src/plugins/Modal/SharedHints.vue +++ b/vue2/packages/clients/dish/src/plugins/Modal/SharedHints.vue @@ -25,7 +25,7 @@ @@ -75,4 +75,9 @@ export default Vue.extend({ justify-content: center !important; } } + +.closeButton { + // transparent border to prevent button from jumping while hover or focus + border: solid #ffffffff; +} diff --git a/packages/clients/dish/src/plugins/Modal/Welcome.vue b/vue2/packages/clients/dish/src/plugins/Modal/Welcome.vue similarity index 100% rename from packages/clients/dish/src/plugins/Modal/Welcome.vue rename to vue2/packages/clients/dish/src/plugins/Modal/Welcome.vue diff --git a/packages/clients/dish/src/plugins/Modal/index.ts b/vue2/packages/clients/dish/src/plugins/Modal/index.ts similarity index 100% rename from packages/clients/dish/src/plugins/Modal/index.ts rename to vue2/packages/clients/dish/src/plugins/Modal/index.ts diff --git a/packages/clients/dish/src/plugins/Modal/landesdachmarke_denkmalpflege.jpg b/vue2/packages/clients/dish/src/plugins/Modal/landesdachmarke_denkmalpflege.jpg similarity index 100% rename from packages/clients/dish/src/plugins/Modal/landesdachmarke_denkmalpflege.jpg rename to vue2/packages/clients/dish/src/plugins/Modal/landesdachmarke_denkmalpflege.jpg diff --git a/vue2/packages/clients/dish/src/plugins/Modal/locales.ts b/vue2/packages/clients/dish/src/plugins/Modal/locales.ts new file mode 100644 index 0000000000..63ca21987c --- /dev/null +++ b/vue2/packages/clients/dish/src/plugins/Modal/locales.ts @@ -0,0 +1,55 @@ +import { Locale } from '@polar/lib-custom-types' + +export const dishModalDe = { + modal: { + welcome: { + header: 'Willkommen in der Denkmalkarte Schleswig-Holstein', + landesdachmarkeAlt: + 'Logo der Landesdachmarke "Schleswig-Holstein. Der echte Norden."', + p1: 'Kulturdenkmale sind gesetzlich geschützt, und nachrichtlich in ein Verzeichnis, die sogenannte Denkmalliste, aufzunehmen. Die Denkmaleigenschaft ist nicht von der Eintragung in die Denkmalliste, oder von der Darstellung in der Denkmalkarte abhängig. Auch Objekte, die nicht hier verzeichnet sind, können als Kulturdenkmale kraft Gesetz (Ipsa Lege) geschützt sein, wenn sie die gesetzlichen Kriterien für die Denkmaleigenschaft erfüllen.', + p2: 'In der Denkmalkarte werden Kulturdenkmale in der Zuständigkeit des Landesamtes für Denkmalpflege Schleswig-Holstein (Baudenkmale, Gründenkmale, Schutzzonen vom Typ Denkmalbereich) dargestellt, mit Ausnahme der Hansestadt Lübeck.', + p3: 'Die Darstellungen in der Denkmalkarte haben informatorischen Charakter. Sie sind nicht rechtsverbindlich. Für tagesaktuelle, rechtsverbindliche Auskünfte wenden Sie sich bitte an:', + link1: 'Landesamt für Denkmalpflege Schleswig-Holstein', + link2: 'Planungs- und Genehmigungsverfahren', + p4: 'Die Nutzung der Denkmalkarte ersetzt nicht die förmliche Beteiligung der jeweils zuständigen Denkmalbehörde in', + confirmRead: + 'Hiermit bestätige ich, dass ich die Informationen zur Kenntnis genommen habe.', + closeInfo: "Los geht's!", + }, + hints: { + mainTitle: 'Denkmalkarte Schleswig-Holstein', + title: 'Benutzungshinweise', + subTitle: 'Allgemeine Informationen', + }, + hintsIntern: { + mainTitle: 'Interne Denkmalkarte Schleswig-Holstein', + title: + 'Nutzungsregeln für die Verwendung von DISH durch die Mitarbeiterinnen und Mitarbeiter der Unteren Denkmalschutzbehörden', + }, + }, +} as const + +const locales: Locale[] = [ + { + type: 'de', + resources: { + plugins: { + dish: { + ...dishModalDe, + }, + }, + }, + }, + { + type: 'en', + resources: { + plugins: { + dish: { + modal: {}, + }, + }, + }, + }, +] + +export default locales diff --git a/packages/clients/dish/src/plugins/Modal/store.ts b/vue2/packages/clients/dish/src/plugins/Modal/store.ts similarity index 100% rename from packages/clients/dish/src/plugins/Modal/store.ts rename to vue2/packages/clients/dish/src/plugins/Modal/store.ts diff --git a/packages/clients/dish/src/plugins/SelectionObject/SelectionObject.vue b/vue2/packages/clients/dish/src/plugins/SelectionObject/SelectionObject.vue similarity index 100% rename from packages/clients/dish/src/plugins/SelectionObject/SelectionObject.vue rename to vue2/packages/clients/dish/src/plugins/SelectionObject/SelectionObject.vue diff --git a/packages/clients/dish/src/plugins/SelectionObject/index.ts b/vue2/packages/clients/dish/src/plugins/SelectionObject/index.ts similarity index 100% rename from packages/clients/dish/src/plugins/SelectionObject/index.ts rename to vue2/packages/clients/dish/src/plugins/SelectionObject/index.ts diff --git a/packages/clients/dish/src/plugins/SelectionObject/locales.ts b/vue2/packages/clients/dish/src/plugins/SelectionObject/locales.ts similarity index 100% rename from packages/clients/dish/src/plugins/SelectionObject/locales.ts rename to vue2/packages/clients/dish/src/plugins/SelectionObject/locales.ts diff --git a/packages/clients/dish/src/plugins/SelectionObject/store.ts b/vue2/packages/clients/dish/src/plugins/SelectionObject/store.ts similarity index 100% rename from packages/clients/dish/src/plugins/SelectionObject/store.ts rename to vue2/packages/clients/dish/src/plugins/SelectionObject/store.ts diff --git a/vue2/packages/clients/dish/src/polar-client.ts b/vue2/packages/clients/dish/src/polar-client.ts new file mode 100644 index 0000000000..98796beaf2 --- /dev/null +++ b/vue2/packages/clients/dish/src/polar-client.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import client, { MapInstance } from '@polar/core' +import merge from 'lodash.merge' +import { getWfsFeatures } from '@polar/lib-get-features' +import { getPointCoordinate } from '@polar/plugin-pins' +import { Feature } from 'ol' +import { FeatureCollection, Geometry, GeometryCollection } from 'geojson' +import { GeoJSON } from 'ol/format' +import packageInfo from '../package.json' +import { navigateToDenkmal } from './utils/navigateToDenkmal' +import { watchActiveMaskIds } from './utils/watchActiveMaksIds' +import { watchSearchResultForAlkisSearch } from './utils/watchSearchResultForAlkisSearch' +import { addPlugins } from './addPlugins' +import { services } from './services' +import { getMapConfiguration } from './mapConfigurations/mapConfig' +import { CONTENT_ENUM } from './plugins/Modal/store' +import './styles.css' +import selectionLayer from './selectionLayer' +import { DishUrlParams } from './types' + +// eslint-disable-next-line no-console +console.log(`DISH map client running in version ${packageInfo.version}.`) + +export default { + createMap: async ({ containerId, mode, urlParams, configOverride }) => { + addPlugins(client, mode) + const layerConf = services(mode, urlParams) + const mapConfiguration = getMapConfiguration(mode, urlParams) + + const map = await client.createMap({ + containerId, + mapConfiguration: merge( + { + ...mapConfiguration, + layerConf, + }, + configOverride || {} + ), + }) + const parameters = new URL(document.location as unknown as string) + .searchParams + // using naming from backend to avoid multiple names for same thing + const objektId = parameters.get('ObjektID') + if (mode === 'INTERN') { + subscribeToExportedMap(map) + // watch for changes in activeMaskIds to update beschriftung layer + watchActiveMaskIds(map) + watchSearchResultForAlkisSearch(map) + map.$store.commit('plugin/selectionObject/setObjectId', objektId) + if (typeof objektId === 'string') { + zoomToInternalFeature(map, objektId, urlParams) + } + } else if (typeof objektId === 'string' && mode === 'EXTERN') { + navigateToDenkmal(map, objektId) + } + if (mode === 'EXTERN') { + map.$store.commit('plugin/modal/setClosed', false) + } + // @ts-expect-error | intentionally expand window; no environment affected + window.openBenutzungshinweise = function (isIntern = false) { + map.$store.commit( + 'plugin/modal/setContent', + isIntern ? CONTENT_ENUM.HINTSINTERN : CONTENT_ENUM.HINTS + ) + map.$store.commit('plugin/modal/setClosed', false) + } + }, +} + +function subscribeToExportedMap(instance: MapInstance) { + instance.subscribe('plugin/export/exportedMap', (screenshot) => { + if (screenshot) { + const newWindow = window.open() + newWindow?.document.write( + `KartenausgabeScreenshot` + ) + } + }) +} + +function zoomToInternalFeature( + instance: MapInstance, + objektId: string, + urlParams: DishUrlParams +) { + getWfsFeatures(null, `${urlParams.internServicesBaseUrl}/wfs`, objektId, { + fieldName: 'objektid', + featurePrefix: 'app', + typeName: 'TBLGIS_ORA', + xmlns: 'http://www.deegree.org/app', + useRightHandWildcard: false, + }) + .then((featureCollection: FeatureCollection) => { + const { features } = featureCollection + if (features.length === 0) { + throw Error(`No features for ID ${objektId} found.`) + } + if (features.length > 1) { + console.warn( + `@polar/client-dish: More than one feature found for id ${objektId}. Arbitrarily using first-returned.` + ) + } + const feature = features[0] + const geometry = feature.geometry as Exclude + const centerCoord = getPointCoordinate( + 'EPSG:25832', + 'EPSG:25832', + geometry.type, + geometry.coordinates + ) + instance.$store.getters.map.getView().setCenter(centerCoord) + instance.$store.getters.map.getView().setZoom(9) + selectionLayer + .getSource() + ?.addFeature(new GeoJSON().readFeature(feature) as Feature) + instance.$store.getters.map.addLayer(selectionLayer) + }) + .catch((error) => { + console.error('@polar/client-dish', error) + instance.$store.dispatch('plugin/toast/addToast', { + type: 'warning', + text: 'dish.idNotFound', + }) + }) +} diff --git a/packages/clients/dish/src/selectionLayer.ts b/vue2/packages/clients/dish/src/selectionLayer.ts similarity index 100% rename from packages/clients/dish/src/selectionLayer.ts rename to vue2/packages/clients/dish/src/selectionLayer.ts diff --git a/packages/clients/dish/src/services.ts b/vue2/packages/clients/dish/src/services.ts similarity index 100% rename from packages/clients/dish/src/services.ts rename to vue2/packages/clients/dish/src/services.ts diff --git a/packages/clients/dish/src/servicesConstants.ts b/vue2/packages/clients/dish/src/servicesConstants.ts similarity index 100% rename from packages/clients/dish/src/servicesConstants.ts rename to vue2/packages/clients/dish/src/servicesConstants.ts diff --git a/packages/clients/dish/src/servicesIntern.ts b/vue2/packages/clients/dish/src/servicesIntern.ts similarity index 82% rename from packages/clients/dish/src/servicesIntern.ts rename to vue2/packages/clients/dish/src/servicesIntern.ts index b4bc2b32d9..351664fa01 100644 --- a/packages/clients/dish/src/servicesIntern.ts +++ b/vue2/packages/clients/dish/src/servicesIntern.ts @@ -30,7 +30,7 @@ const denkmaelerWmService = { layers: '0,1,2,3,4,6,24,25', } -const beschriftungService = { +export const beschriftungService = { ...commonConfigDenkmaelWMS, id: beschriftung, name: 'Beschriftung', @@ -106,13 +106,40 @@ const dop20ColInternService = { } export const servicesIntern = [ + beschriftungService, denkmaelerWfService, denkmaelerWmService, kontrollbedarfService, verlustService, - beschriftungService, verwaltungsGrenzenService, bddEinInternService, bddColInternService, dop20ColInternService, ] + +export const labeledLayerServices = [ + denkmaelerWmService, + kontrollbedarfService, + verlustService, +] + +// Map of geom layername to label layername of denkmal WMS service +export const layerLabelMap = new Map([ + ['0', '9'], + ['1', '10'], + ['2', '11'], + ['3', '12'], + ['4', '13'], + ['6', '15'], + ['7', '16'], + ['8', '17'], + ['19', '30'], + ['20', '31'], + ['21', '32'], + ['22', '33'], + ['23', '34'], + ['24', '26'], + ['25', '27'], + ['28', '35'], + ['29', '36'], +]) diff --git a/packages/clients/dish/src/styles.css b/vue2/packages/clients/dish/src/styles.css similarity index 100% rename from packages/clients/dish/src/styles.css rename to vue2/packages/clients/dish/src/styles.css diff --git a/vue2/packages/clients/dish/src/types.ts b/vue2/packages/clients/dish/src/types.ts new file mode 100644 index 0000000000..2490f0e33e --- /dev/null +++ b/vue2/packages/clients/dish/src/types.ts @@ -0,0 +1,149 @@ +// naming convention doesn't hold since backend names are used in Beschreibung +/* eslint-disable @typescript-eslint/naming-convention */ + +import { FeatureCollection, GeometryObject } from 'geojson' +import { + AddressSearchConfiguration, + MapConfig, + QueryParameters, + PluginOptions, + RenderType, +} from '@polar/lib-custom-types' + +/* Search backend documentation: + * https://efi2.schleswig-holstein.de/dish/dish_service/help.html + */ + +/** as specified by efi search backend */ +export interface EfiSearchFeature { + Date: string | null + anriss: string + beschreibung: string // not yet parsed + index: number + link: string + linkText: string + rating: string + titel: '' // always empty string ✨ +} + +/** as specified by efi information backend */ +export interface EfiBeschreibung { + Wert: string + ObjektID: string + objektansprache: string + Kreis: string + Gemeinde: string + wohnplatz: string + strasse: string + objektart: string + objektfunktion: string + objektplz: string + tbldlisteinaKurzBeschreibungen: string + tbldlisteinaBeschreibungen: string +} + +/** after beschreibung is parsed ... */ +export interface ParsedEfiSearchFeature + extends Omit { + beschreibung: EfiBeschreibung + geometry?: GeometryObject +} + +/** used for wfs sub-configuration of dish search */ +export interface WfsConfiguration { + id: string + srsName: string + typeName: string + fieldName: string + featurePrefix: string + xmlns: string +} + +export interface DishParameters extends QueryParameters { + searchKey: string + wfsConfiguration: WfsConfiguration + // topic: EfiTopic + addRightHandWildcard: boolean + volltexttyp?: 'CONTAINS' | 'FREETEXT' +} + +export type DishAutocompleteFunction = ( + signal: AbortSignal, + url: string, + inputValue: string +) => Promise + +export interface DishFeaturePropertiesOnSuccess { + Bezeichnung: string // ehem. "Name" + Foto: string + Kreis: string + Gemeinde: string + PLZ: string + Straße: string + Objektnummer: string + Detailinformationen: string + Export: string +} + +export interface DishFeaturePropertiesOnError { + Information: string +} + +export type DishFeatureProperties = + | DishFeaturePropertiesOnSuccess + | DishFeaturePropertiesOnError + +export interface ModalState { + confirmed: boolean + closed: boolean + content: number +} + +export interface DishUrlParams { + internalHost: string + internServicesBaseUrl: string + printHostDeegree: string + printServicesBaseUrl: string +} + +export interface DishMapConfig + extends Omit { + addressSearch: AddressSearchConfiguration + dishModal?: { + isInternMap: boolean + } + dishExportMap?: { + printApproach: string + printRequester: string + xPrint: number + yPrint: number + versionHintergrund: string + proxyHintergrund: string + versionWMS: string + layerNameWMS: string + versionWFS: string + propertyNameWFS: string + filterTypeWFS: string + printImagePath: string + wmsLayerUrl: string + wfsLayerUrl: string + wfsLayerFeatureType: string + printImageUrlProd: string + exportMapAsPdfUrl: string + backgroundLayer: backgroundLayer + } +} + +export interface backgroundLayer { + url: string + layers: string +} + +export interface SelectionObjectState { + objectId: string +} + +export interface SelectionObjectOptions extends PluginOptions { + renderType?: RenderType + targetContainerId?: string +} diff --git a/packages/clients/dish/src/utils/autocomplete.ts b/vue2/packages/clients/dish/src/utils/autocomplete.ts similarity index 100% rename from packages/clients/dish/src/utils/autocomplete.ts rename to vue2/packages/clients/dish/src/utils/autocomplete.ts diff --git a/packages/clients/dish/src/utils/calculateScaleFromResolution.ts b/vue2/packages/clients/dish/src/utils/calculateScaleFromResolution.ts similarity index 100% rename from packages/clients/dish/src/utils/calculateScaleFromResolution.ts rename to vue2/packages/clients/dish/src/utils/calculateScaleFromResolution.ts diff --git a/packages/clients/dish/src/utils/denkmalSearchIntern.ts b/vue2/packages/clients/dish/src/utils/denkmalSearchIntern.ts similarity index 100% rename from packages/clients/dish/src/utils/denkmalSearchIntern.ts rename to vue2/packages/clients/dish/src/utils/denkmalSearchIntern.ts diff --git a/packages/clients/dish/src/utils/extendGfi.ts b/vue2/packages/clients/dish/src/utils/extendGfi.ts similarity index 100% rename from packages/clients/dish/src/utils/extendGfi.ts rename to vue2/packages/clients/dish/src/utils/extendGfi.ts diff --git a/packages/clients/dish/src/utils/navigateToDenkmal.ts b/vue2/packages/clients/dish/src/utils/navigateToDenkmal.ts similarity index 100% rename from packages/clients/dish/src/utils/navigateToDenkmal.ts rename to vue2/packages/clients/dish/src/utils/navigateToDenkmal.ts diff --git a/packages/clients/dish/src/utils/prepareGfiDataIntern.ts b/vue2/packages/clients/dish/src/utils/prepareGfiDataIntern.ts similarity index 100% rename from packages/clients/dish/src/utils/prepareGfiDataIntern.ts rename to vue2/packages/clients/dish/src/utils/prepareGfiDataIntern.ts diff --git a/packages/clients/dish/src/utils/search.ts b/vue2/packages/clients/dish/src/utils/search.ts similarity index 100% rename from packages/clients/dish/src/utils/search.ts rename to vue2/packages/clients/dish/src/utils/search.ts diff --git a/vue2/packages/clients/dish/src/utils/sortFeaturesByProperties.ts b/vue2/packages/clients/dish/src/utils/sortFeaturesByProperties.ts new file mode 100644 index 0000000000..3a61a2fdf4 --- /dev/null +++ b/vue2/packages/clients/dish/src/utils/sortFeaturesByProperties.ts @@ -0,0 +1,29 @@ +import { Feature } from 'geojson' + +export const sortFeaturesByProperties = ( + features: Feature[], + sortKeys: string[], + numericKeys: string[] = [] +): Feature[] => { + return features.sort((a, b) => { + for (const key of sortKeys) { + const valueA = a.properties?.[key] ?? '' + const valueB = b.properties?.[key] ?? '' + + let comparison = 0 + + if (numericKeys.includes(key)) { + const numA = parseFloat(String(valueA)) || 0 + const numB = parseFloat(String(valueB)) || 0 + comparison = numA - numB + } else { + comparison = String(valueA).localeCompare(String(valueB)) + } + + if (comparison !== 0) { + return comparison + } + } + return 0 + }) +} diff --git a/vue2/packages/clients/dish/src/utils/watchActiveMaksIds.ts b/vue2/packages/clients/dish/src/utils/watchActiveMaksIds.ts new file mode 100644 index 0000000000..6c496aa5c5 --- /dev/null +++ b/vue2/packages/clients/dish/src/utils/watchActiveMaksIds.ts @@ -0,0 +1,100 @@ +import { MapInstance } from '@polar/core' +import { + beschriftungService, + labeledLayerServices, + layerLabelMap, +} from '../servicesIntern' +import { beschriftungMinZoom } from '../mapConfigurations/layerConfigIntern' + +function getOlLabelLayer(instance) { + const map = instance.$store.getters.map + return map + .getLayers() + .getArray() + .find((l) => l.get('id') === beschriftungService.id) +} + +export function watchActiveMaskIds(instance: MapInstance) { + let previousLayers = '' + + const updateLabelLayers = () => { + const activeLayerIds = + instance.$store.getters['plugin/layerChooser/activeLayerIds'] + const activeMaskIds = + instance.$store.getters['plugin/layerChooser/activeMaskIds'] + const masks = instance.$store.getters['plugin/layerChooser/masks'] + + const allActiveLabelLayers = getAllActiveLabelLayers( + activeLayerIds, + activeMaskIds + ) + const LAYERS = allActiveLabelLayers.join(',') + + if (LAYERS === previousLayers) { + return + } + + previousLayers = LAYERS + + if (LAYERS !== '') { + updateBeschriftungsLayer(instance, LAYERS) + setBeschriftungVisibilityInMenu(masks, instance, false) + } else { + setBeschriftungVisibilityInMenu(masks, instance, true) + getOlLabelLayer(instance)?.setVisible(false) + } + } + + instance.$store.watch( + (_, getters) => ({ + activeLayerIds: getters['plugin/layerChooser/activeLayerIds'], + activeMaskIds: getters['plugin/layerChooser/activeMaskIds'], + }), + updateLabelLayers, + { immediate: true } + ) +} + +function getAllActiveLabelLayers(activeLayerIds, activeMaskIds) { + const activeLabeledLayers = Object.entries(activeLayerIds) + .filter( + ([key]) => + labeledLayerServices.map((service) => service.id).includes(key) && + activeMaskIds.includes(key) + ) + .map(([, value]) => value) + .flat() + return activeLabeledLayers + .map((l) => { + return layerLabelMap.get(l as string) + }) + .filter((s) => s) +} + +function updateBeschriftungsLayer(instance: MapInstance, LAYERS: string) { + const olLabelLayer = getOlLabelLayer(instance) + const olSource = olLabelLayer?.getSource() + if (olSource) { + const updatedParams = { ...olSource.getParams(), LAYERS } + olSource.updateParams(updatedParams) + } + const currentZoom = instance.$store.getters.map.getView().getZoom() + if (currentZoom >= beschriftungMinZoom) { + olLabelLayer?.setVisible(true) + } else { + olLabelLayer?.setVisible(false) + } +} + +function setBeschriftungVisibilityInMenu( + masks, + instance: MapInstance, + hiddenInMenu: boolean +) { + const masksArray = masks.map((mask) => + mask.id === beschriftungService.id + ? { ...mask, hideInMenu: hiddenInMenu } + : mask + ) + instance.$store.commit('plugin/layerChooser/setMasks', masksArray) +} diff --git a/vue2/packages/clients/dish/src/utils/watchSearchResultForAlkisSearch.ts b/vue2/packages/clients/dish/src/utils/watchSearchResultForAlkisSearch.ts new file mode 100644 index 0000000000..46bd03a1a0 --- /dev/null +++ b/vue2/packages/clients/dish/src/utils/watchSearchResultForAlkisSearch.ts @@ -0,0 +1,27 @@ +import { MapInstance } from '@polar/core' +import { alkisWms } from '../servicesConstants' +import { alkisMinZoom } from '../mapConfigurations/layerConfigIntern' + +export function watchSearchResultForAlkisSearch(instance: MapInstance) { + instance.subscribe( + 'plugin/addressSearch/chosenAddress', + (chosenAddress: any) => { + if (chosenAddress?.properties?.idflurst) { + const zoomLevel = instance.$store.getters['plugin/zoom/zoomLevel'] + + if (zoomLevel <= alkisMinZoom) { + instance.$store.getters.map.getView().setZoom(alkisMinZoom) + } + + const activeMaskIds = + instance.$store.getters['plugin/layerChooser/activeMaskIds'] + if (!activeMaskIds.includes(alkisWms)) { + instance.$store.dispatch('plugin/layerChooser/setActiveMaskIds', [ + ...activeMaskIds, + alkisWms, + ]) + } + } + } + ) +} diff --git a/packages/clients/dish/src/utils/zoomToFeatureById.ts b/vue2/packages/clients/dish/src/utils/zoomToFeatureById.ts similarity index 100% rename from packages/clients/dish/src/utils/zoomToFeatureById.ts rename to vue2/packages/clients/dish/src/utils/zoomToFeatureById.ts diff --git a/packages/clients/dish/vite.config.js b/vue2/packages/clients/dish/vite.config.js similarity index 100% rename from packages/clients/dish/vite.config.js rename to vue2/packages/clients/dish/vite.config.js diff --git a/vue2/packages/clients/meldemichel/API.md b/vue2/packages/clients/meldemichel/API.md new file mode 100644 index 0000000000..4af4771f04 --- /dev/null +++ b/vue2/packages/clients/meldemichel/API.md @@ -0,0 +1,198 @@ +# Meldemichel MapClient API 🗺️ `@polar/client-meldemichel` + +This client is based on [POLAR](https://github.com/Dataport/polar) and subsequently the [masterportalAPI](https://bitbucket.org/geowerkstatt-hamburg/masterportalapi/src/master/). The following documentation only contains how this specific client can be used, and the minimal information required to get it running. + +For all additional details, check the [full documentation](https://dataport.github.io/polar/docs/meldemichel/client-meldemichel.html). + +For the development test deployments, [see here](./example/index.html). + +## Basic usage + +The NPM package `@polar/client-meldemichel` can be installed via NPM or downloaded from the [release page](https://github.com/Dataport/polar/releases). When using `import mapClient from '@polar/client-meldemichel'`, the object `mapClient` contains a method `createMap`. This is the main method required to get the client up and running. Should you use another import method, check the package's `dist` folder for available files. + +The method expects a single object with the following parameters. + +| fieldName | type | description | +| - | - | - | +| containerId | string | ID of the container the map is supposed to render itself to. | +| mode | enum["REPORT", "SINGLE", "COMPLETE"] | See chapters below for an overview of the modes. | +| stadtwaldActive | boolean? | This layer only works in 'SINGLE' mode and should not be activated in the others. If not set, any existing previous state is kept; off by default. | +| afmUrl | string? | `COMPLETE` mode only. The URL used here is the URL of the AfM service to open to create a new damage report. | +| reportServiceId | string? | `COMPLETE` mode only. ID of the report layer to display. Both the Filter and the Feature List will work with this layer. The client will also provide tooltips and cluster the features. | +| configOverride | object? | This can be used to override the configuration of any installed plugin; see full documentation. It is also used to set initial pins in `SINGLE` mode. See documentation of `SINGLE` further below. | + +It returns a Promise of a map instance. This returned instance is required to retrieve information from the map. + +The package also includes a `style.css` and an `index.html` file. The `style.css`'s relative path must, if it isn't the default value `'./style.css'`, be included in the `configOverride` as follows: + +```js +{ + // ... + configOverride: { + stylePath: '../the/relative/path/style.css' + } +} +``` + +The value to `stylePath` is the same as as a `link` tag would have in its `href`. + +The `index.html` is used in `COMPLETE` mode, which is not run in the AfM. You may, however, use it for testing or inspecting an example. + +### Instance reuse + +The `mapInstance` and its HTML environment are kept in the client; it is returned and rerendered on subsequent `createMap` calls to a div with the given `id`. Due to this, everything will appear to the user as it was previously left, including opened menus. + +Since in `SINGLE` mode, changes to the pins are required between renders, hence the parameters in `configOverride.pins` are used to update the client. Should additional updates be required, please let us know. + +Calling `watch`/`subscribe` on the client will return an `unwatch`/`unsubscribe` method. It should be called on leaving the map's page; depending on framework/library in e.g. the `beforeDestroy` or `beforeUnmount` method. + +## Rendering in SINGLE or REPORT mode + +A document rendering the map client could e.g. look like this: + +```html + + + + REPORT EXAMPLE + + + +
    + + +
    + + + +``` + +## Rendering COMPLETE mode (full page) + +The `index.html` included in the package's `dist` folder has been prepared for this mode and must merely be hosted. + +Please see the table in chapter `Basic usage` about configuration options. + +## Rendering COMPLETE mode (embedded element) + +To embed the COMPLETE mode map on any page, provide a div with an id like `meldemichel-map-client`; you may choose any id you like. + +The following script tag can then be used to render the productive services of the Meldemichel map client. + +```html + +``` + +POLAR will rebuild the given div to contain a ShadowDOM that hosts the map. The outer div will change to have the id `meldemichel-map-client-wrapper` (resp. `${yourId}-wrapper`) and can be used to style the map's height and width with, for example: + +```css +#meldemichel-map-client-wrapper { + /* "position: relative;" is the minimum required styling */ + position: relative; + height: 400px; + width: 100%; +} +``` + +To also serve users with JS disabled some content, this fragment is common: + +```html +
    + + +
    +``` + +For a complete example, you may also check [the running embedded scenario](https://dataport.github.io/polar/docs/meldemichel/example/complete_embedded.html) or its [source file](https://github.com/Dataport/polar/blob/main/packages/clients/meldemichel/example/complete_embedded.html). diff --git a/vue2/packages/clients/meldemichel/CHANGELOG.md b/vue2/packages/clients/meldemichel/CHANGELOG.md new file mode 100644 index 0000000000..d65b1b8cb1 --- /dev/null +++ b/vue2/packages/clients/meldemichel/CHANGELOG.md @@ -0,0 +1,112 @@ +# CHANGELOG + +## 1.3.0 + +- Feature: Add a Jenfeld client with reduced network traffic and scope. +- Chore: Update `@polar/address-search` dependency to minimum required version. + +## 1.2.2 + +- Fix: Use updated layer id for the aerial photo layer. +- Chore: Update `@polar`-dependencies to the latest versions. + +## 1.2.1 + +- Feature: Add `stadtwaldActive` as startup parameter for `createMap` object and `meldemichel/setMapState` action. Refer to the API.md regarding further details. +- Fix: Import type `MpApiParameters` from correct position. +- Chore: Change value of `pins.movable` configuration to `'drag'` as using a boolean has been deprecated in a future release. +- Chore: Update `@polar`-dependencies to the latest versions. + +## 1.1.2 + +- Chore: Fix bugs via dependency updates. + - resolve filter bugs for features of unknown filter categories; they were sometimes visible + +## 1.1.1 + +- Chore: Fix bugs via dependency updates. + - restore Safari 15 compatibility + - resolve time filter bugs where selected time frame was applied incorrectly + +## 1.1.0 + +- Change: The search now no longer focuses on the first result after a successful search. + +## 1.0.0 + +Initial release. 🎉 + +## 1.0.0-beta.7 + +- Fix: Use `@polar/plugins-pins@1.3.1` to fix the map getting dragged along with the pin in some situations. + +## 1.0.0-beta.6 + +- Feature: Add `@polar/plugin-address-search` and `@polar/plugin-reverse-geocoder` to mode `SINGLE`. + +## 1.0.0-beta.5 + +- Fix: Add missing API.md change. No further changes to previous version. + +## 1.0.0-beta.4 + +- Fix: The previously proclaimed SPA-readiness was erroneous in that a memory leak occurred due to the client not being garbage-collectable. This has been resolved by reusing the map entirely instead of setting up a new one. Given configuration regarding pins is interpreted despite reuse. +- Change: The main library file is no longer published as `.mjs`, but as `.js` file. This is due to internal tooling updates. A rename of the import suffices. + +## 1.0.0-beta.3 + +- Fix: The gazetteer search returned confusing results. This was due to a wildcard option that allowed the return of imprecise matches. This has been deactivated since it tended to result in more confusion than it was helpful. The search now works as it does in the previous Meldemichel implementation. + +## 1.0.0-beta.2 + +- Feature: This client now supports the `@polar/core`'s field `stylePath`. The usage is documented in the API.md file. +- Feature: Update icon of `layerChooser` in `iconMenu` to `fa-layer-group` to clear-up the content hidden behind the menu button. +- Feature: Update the close-button of the GFI window to indicate more clearly that it leads to the FeatureList. +- Feature: Move attributions from the `iconMenu` to the bottom-right, use a smaller icon and a different colour to clear-up the secondary nature of the content. +- Feature: The client is now SPA-ready. The `API.md` has been extended with example code. +- Fix: Size and colours of GFI navigation arrows have been aligned to neighbouring items in mobile mode. +- Fix: Fixed an issue on narrow devices sometimes showing undefined content in the gfi after clicking somewhere in the map where no feature is present. + +## 1.0.0-beta.1 + +- Fix: The modes `SINGLE` and `REPORT` falsely ran the GFI plugin dependent upon configuration only available in `COMPLETE` mode. This issue has been resolved. + +## 1.0.0-beta.0 + +Beta release. Feature-complete, but some known (and unknown?) bugs remain. + +- Feature: Add features for `COMPLETE` mode: + - Feature List + - AfM Button + - Filter + - Mobile views for small devices +- Fix: The listenable `mapState` field `vendor_maps_position` has been changed in its formatting. It now matches the formatting of the neighbouring `mapCenter` field (`number,number`) instead of being an array. +- Fix: The listenable `mapState` field `vendor_maps_address_to` has been renamed to `vendor_maps_distance_to` to match the previous name. + +### Dependency updates + +Please check the package changelogs regarding details. + +|Package|Previous|Current| +|-|-|-| +| `@polar/core | ^1.1.0 | ^1.2.1 | +| `@polar/lib-custom-types | ^1.1.0 | ^1.2.0 | +| `@polar/lib-invisible-style | * | ^1.0.0 | +| `@polar/plugin-address-search | ^1.0.0 | ^1.1.0 | +| `@polar/plugin-attributions | ^1.0.0 | ^1.1.0 | +| `@polar/plugin-filter | * | ^1.0.0 | +| `@polar/plugin-fullscreen | ^1.0.0 | ^1.1.0 | +| `@polar/plugin-geo-location | ^1.1.0 | ^1.2.0 | +| `@polar/plugin-gfi | ^1.0.0 | ^1.1.0 | +| `@polar/plugin-icon-menu | ^1.0.1 | ^1.1.0 | +| `@polar/plugin-layer-chooser | ^1.0.0 | ^1.1.0 | +| `@polar/plugin-loading-indicator | ^1.0.0 | ^1.0.1 | +| `@polar/plugin-pins | ^1.1.0 | ^1.1.1 | +| `@polar/plugin-reverse-geocoder | ^1.0.0 | ^1.0.1 | +| `@polar/plugin-scale | ^1.0.0 | ^1.0.1 | +| `@polar/plugin-toast | ^1.0.0 | ^1.0.1 | +| `@polar/plugin-zoom | ^1.0.0 | ^1.1.0 | + +## 1.0.0-alpaka.0 + +Test release. Feature-complete for AfM integration. diff --git a/packages/clients/generic/LICENSE b/vue2/packages/clients/meldemichel/LICENSE similarity index 100% rename from packages/clients/generic/LICENSE rename to vue2/packages/clients/meldemichel/LICENSE diff --git a/packages/clients/meldemichel/README.md b/vue2/packages/clients/meldemichel/README.md similarity index 100% rename from packages/clients/meldemichel/README.md rename to vue2/packages/clients/meldemichel/README.md diff --git a/packages/clients/meldemichel/example/complete.html b/vue2/packages/clients/meldemichel/example/complete.html similarity index 100% rename from packages/clients/meldemichel/example/complete.html rename to vue2/packages/clients/meldemichel/example/complete.html diff --git a/packages/clients/meldemichel/example/complete_embedded.html b/vue2/packages/clients/meldemichel/example/complete_embedded.html similarity index 100% rename from packages/clients/meldemichel/example/complete_embedded.html rename to vue2/packages/clients/meldemichel/example/complete_embedded.html diff --git a/vue2/packages/clients/meldemichel/example/index.html b/vue2/packages/clients/meldemichel/example/index.html new file mode 100644 index 0000000000..263121f40b --- /dev/null +++ b/vue2/packages/clients/meldemichel/example/index.html @@ -0,0 +1,67 @@ + + + + + Meldemichel Example Hub (Build Test) + + + + + + +

    Meldemichel Example Hub

    +

    This is a minimal test page. Stage services are used.

    +
    +
    "REPORT" (Meldungsansicht)
    +
    + Meldemichel Map Client as element in a larger page. Rendered in mode + 'REPORT'. +
    + +
    + "SINGLE" (Sachbearbeitung) +
    +
    + Meldemichel Map Client as element in a larger page. Rendered in mode + 'SINGLE' with parameter `movable` settable to 'drag' or 'none'. +
    + +
    + "COMPLETE" (Übersichtskarte) +
    +
    Meldemichel Map Client rendered fullpage in mode COMPLETE.
    + +
    + "JENFELD" (Übersichtskarte) +
    +
    Meldemichel Map Client rendered fullpage in mode JENFELD.
    + +
    + "COMPLETE_EMBEDDED" (Übersichtskarte eingebettet) +
    +
    Meldemichel Map Client rendered fullpage in mode COMPLETE.
    + +
    + "COMPLETE" (Übersichtskarte) – Production Mode Test +
    +
    + Meldemichel Map Client rendered fullpage in mode COMPLETE. Productive + feature service set, but still linking to stage AfM. +
    +
    +
    + Legal Notice (Impressum) + + diff --git a/vue2/packages/clients/meldemichel/example/jenfeld.html b/vue2/packages/clients/meldemichel/example/jenfeld.html new file mode 100644 index 0000000000..7e6f6db184 --- /dev/null +++ b/vue2/packages/clients/meldemichel/example/jenfeld.html @@ -0,0 +1,60 @@ + + + + + + + MML JENFELD (Build Test) + + + +
    +
    +
    + + + diff --git a/packages/clients/meldemichel/example/report.html b/vue2/packages/clients/meldemichel/example/report.html similarity index 99% rename from packages/clients/meldemichel/example/report.html rename to vue2/packages/clients/meldemichel/example/report.html index dab93a5488..1234e76def 100644 --- a/packages/clients/meldemichel/example/report.html +++ b/vue2/packages/clients/meldemichel/example/report.html @@ -85,7 +85,7 @@

    Setting data

    vendor_maps_address_str: 'Mümmelmannsberg', vendor_maps_address_hnr: '72', mapZoomLevel: 6, - mapBaseLayer: 452, + mapBaseLayer: 34127, mapCenter: '566808.8386735287,5935896.23173797', }), } diff --git a/packages/clients/meldemichel/example/simulateRecreate.js b/vue2/packages/clients/meldemichel/example/simulateRecreate.js similarity index 100% rename from packages/clients/meldemichel/example/simulateRecreate.js rename to vue2/packages/clients/meldemichel/example/simulateRecreate.js diff --git a/packages/clients/meldemichel/example/single.html b/vue2/packages/clients/meldemichel/example/single.html similarity index 100% rename from packages/clients/meldemichel/example/single.html rename to vue2/packages/clients/meldemichel/example/single.html diff --git a/vue2/packages/clients/meldemichel/package.json b/vue2/packages/clients/meldemichel/package.json new file mode 100644 index 0000000000..ea3dccdf7d --- /dev/null +++ b/vue2/packages/clients/meldemichel/package.json @@ -0,0 +1,60 @@ +{ + "name": "@polar/client-meldemichel", + "version": "1.3.0", + "description": "POLAR Client Meldemichel. This client aids in crowd-sourcing the detection of Hamburg's infrastructure in need of repair or maintenance.", + "keywords": [ + "OpenLayers", + "ol", + "POLAR", + "client", + "Meldemichel", + "reporting", + "infrastructure" + ], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "main": "dist/client-meldemichel.js", + "repository": { + "type": "git", + "url": "git+https://github.com/Dataport/polar.git", + "directory": "packages/clients/meldemichel" + }, + "files": [ + "dist/**/**.*", + "docs/**/**.*", + "example/**/**.*", + "CHANGELOG.md", + "API.md" + ], + "scripts": { + "prepublishOnly": "npm run build", + "build": "rimraf dist && vite build && copyfiles -f src/html/**/* dist", + "dev": "vite --host" + }, + "devDependencies": { + "@polar/core": "^3.2.1", + "@polar/lib-custom-types": "^2.2.0", + "@polar/lib-invisible-style": "^3.0.0", + "@polar/plugin-address-search": "^3.2.0", + "@polar/plugin-attributions": "^1.5.0", + "@polar/plugin-filter": "^3.0.1", + "@polar/plugin-fullscreen": "^1.2.3", + "@polar/plugin-geo-location": "^2.0.1", + "@polar/plugin-gfi": "^3.1.0", + "@polar/plugin-icon-menu": "^1.5.0", + "@polar/plugin-layer-chooser": "^2.2.0", + "@polar/plugin-loading-indicator": "^1.2.1", + "@polar/plugin-pins": "^3.1.0", + "@polar/plugin-reverse-geocoder": "^3.0.1", + "@polar/plugin-scale": "^3.1.0", + "@polar/plugin-toast": "^1.1.2", + "@polar/plugin-zoom": "^1.5.0" + }, + "nx": { + "includedScripts": [ + "build", + "dev" + ] + } +} diff --git a/vue2/packages/clients/meldemichel/src/addPlugins.ts b/vue2/packages/clients/meldemichel/src/addPlugins.ts new file mode 100644 index 0000000000..2635750b58 --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/addPlugins.ts @@ -0,0 +1,75 @@ +import { setLayout, NineLayout, NineLayoutTag } from '@polar/core' +import AddressSearch from '@polar/plugin-address-search' +import Attributions from '@polar/plugin-attributions' +import GeoLocation from '@polar/plugin-geo-location' +import IconMenu from '@polar/plugin-icon-menu' +import LoadingIndicator from '@polar/plugin-loading-indicator' +import Pins from '@polar/plugin-pins' +import ReverseGeocoder from '@polar/plugin-reverse-geocoder' +import Scale from '@polar/plugin-scale' +import Toast from '@polar/plugin-toast' +import Zoom from '@polar/plugin-zoom' + +import { MODE } from './enums' +import createMenus from './utils/createMenus' + +export const addPlugins = (core, mode: keyof typeof MODE) => { + const iconMenu = IconMenu({ + initiallyOpen: 'layerChooser', + displayComponent: true, + menus: createMenus(mode), + layoutTag: NineLayoutTag.TOP_RIGHT, + }) + + setLayout(NineLayout) + + core.addPlugins( + [ + AddressSearch({ + displayComponent: mode !== MODE.SINGLE, + layoutTag: NineLayoutTag.TOP_LEFT, + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + searchMethods: [], + }), + Pins({ + appearOnClick: { show: true, atZoomLevel: 0 }, + coordinateSource: 'plugin/addressSearch/chosenAddress', + toastAction: 'plugin/toast/addToast', + }), + iconMenu, + // adding hidden for Jenfeld since zoom's store is needed for AfmButton + mode === MODE.JENFELD && Zoom({ displayComponent: false }), + Attributions({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + windowWidth: 550, + listenToChanges: [ + 'plugin/zoom/zoomLevel', + 'plugin/layerChooser/activeBackgroundId', + 'plugin/layerChooser/activeMaskIds', + ], + }), + LoadingIndicator({ + displayComponent: true, + layoutTag: NineLayoutTag.MIDDLE_MIDDLE, + }), + Scale({ displayComponent: true, layoutTag: NineLayoutTag.BOTTOM_RIGHT }), + Toast({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_MIDDLE, + }), + mode !== MODE.SINGLE && + GeoLocation({ + displayComponent: false, + toastAction: 'plugin/toast/addToast', + }), + ReverseGeocoder({ + url: 'https://geodienste.hamburg.de/HH_WPS', + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + zoomTo: 7, + }), + ].filter((x) => x /* remove `false` entries */) + ) +} diff --git a/packages/clients/meldemichel/src/enums.ts b/vue2/packages/clients/meldemichel/src/enums.ts similarity index 95% rename from packages/clients/meldemichel/src/enums.ts rename to vue2/packages/clients/meldemichel/src/enums.ts index 67850954d4..b06bc68d04 100644 --- a/packages/clients/meldemichel/src/enums.ts +++ b/vue2/packages/clients/meldemichel/src/enums.ts @@ -4,6 +4,8 @@ export const MODE = { // display everything COMPLETE: 'COMPLETE', + // display Jenfeld contents + JENFELD: 'JENFELD', // do not display AfmButton, reports, filter, list REPORT: 'REPORT', /* do not display AfmButton, reports, filter, list diff --git a/vue2/packages/clients/meldemichel/src/html/index.html b/vue2/packages/clients/meldemichel/src/html/index.html new file mode 100644 index 0000000000..d3f810a11d --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/html/index.html @@ -0,0 +1,39 @@ + + + + + + + Meldemichel Übersichtskarte + + + +
    +
    + +
    +
    + + + diff --git a/vue2/packages/clients/meldemichel/src/index.html b/vue2/packages/clients/meldemichel/src/index.html new file mode 100644 index 0000000000..9e97c8f3cb --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/index.html @@ -0,0 +1,104 @@ + + + + + + + + Meldemichel (Dev Mode) + + + +
    +
    + +
    +
    + + + diff --git a/vue2/packages/clients/meldemichel/src/locales.ts b/vue2/packages/clients/meldemichel/src/locales.ts new file mode 100644 index 0000000000..f883d3b195 --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/locales.ts @@ -0,0 +1,186 @@ +// SKAT modeled by their ID; no semantic value for client +/* eslint-disable @typescript-eslint/naming-convention */ +import { Locale } from '@polar/lib-custom-types' +import { REPORT_STATUS, TIME_FILTER, SKAT } from './enums' + +const skat = { + 100: 'Wege und Straßen', + 101: 'Schlagloch und Wegeschaden', + 102: 'Verunreinigung und Vandalismus', + 103: 'Wildwuchs und Überwuchs', + 104: 'Beschädigtes Verkehrszeichen', + 105: 'Beschädigte Brücke, Tunnel, Mauer, Treppe', + 106: 'Beschädigte Geländer, Poller, Fahrradständer, Sitzgelegenheit', + 111: 'Schrottfahrräder', + 112: 'Abgemeldete Fahrzeuge', + 113: 'Radverkehr', + 114: 'Stadtwald Hamburg', + 115: 'Stadtwald: Schäden am Baumbestand', + 116: 'Stadtwald: Schäden an Einrichtungen', + 117: 'Stadtwald: Wegeschäden', + 118: 'Stadtwald: Verschmutzung / Müll', + 119: 'Stadtwald: Illegale Aktivitäten', + 120: 'Stadtwald: Sonstige Schäden', + 200: 'Ampeln und Leuchten', + 202: 'Ampel gestört', + 203: 'beleuchtetes Schild gestört', + 204: 'Straßenbeleuchtung ausgefallen', + 205: 'Straßenbeleuchtung tagsüber in Betrieb', + 400: 'Grünanlagen und Spielplätze', + 401: 'Baumschaden', + 402: 'Spielgeräteschaden', + 500: 'Siele und Gewässer', + 501: 'Gully-Schaden', + 502: 'Graben', + 503: 'Gewässerverunreinigung', +} + +const status = { + [REPORT_STATUS[0]]: 'In Bearbeitung', + [REPORT_STATUS[1]]: 'Bearbeitet', +} + +const filterCategory = { + skat, + statu: status, + title: { skat: 'Kategorien', statu: 'Status' }, +} + +export const meldemichelDe = { + attributions: { + stadtplan: + 'Kartografie Stadtplan: Landesbetrieb Geoinformation und Vermessung', + stadtwald: + 'Kartografie Stadtwald: Freie und Hansestadt Hamburg, Behörde für Umwelt, Klima, Energie und Agrarwirtschaft (BUKEA)', + luftbilder: + 'Kartografie Luftbilder: Landesbetrieb Geoinformation und Vermessung', + reports: 'Meldungen durch Bürger', + }, + gfi: { + title: 'Meldung', + skat: 'Kategorie', + beschr: 'Beschreibung', + rueck: 'Rückmeldung', + start: 'Gemeldet am', + statu: 'Status', + tooltip: { + multiHeader: 'Mehrere Anliegen', + multiBody: 'Klick zum Zoomen', + multiBodyUnresolvable: 'Klick zum Öffnen', + }, + }, + layers: { + stadtplan: 'Stadtplan', + stadtwald: 'Stadtwald', + luftbilder: 'Luftbildansicht', + reports: 'Meldungen', + hamburgBorder: 'Stadtgrenze Hamburg', + }, + skat, + status, + time: { + [TIME_FILTER.NONE]: 'Keine Einschränkung', + [TIME_FILTER.DAYS_7]: 'Die letzten 7 Tage', + [TIME_FILTER.DAYS_30]: 'Die letzten 30 Tage', + [TIME_FILTER.SELECTABLE]: 'Zeitraum wählen', + }, +} as const + +const locales: Locale[] = [ + { + type: 'de', + resources: { + meldemichel: meldemichelDe, + plugins: { + filter: { + layerName: { + 6059: 'Meldungen — Filter', + 6061: 'Meldungen (Stage) — Filter', + 'anliegen-jenfeld': 'Meldungen Jenfeld — Filter', + }, + category: { + 6059: filterCategory, + 6061: filterCategory, + 'anliegen-jenfeld': filterCategory, + }, + }, + geoLocation: { + toast: { + notInBoundary: + 'Das System konnte Sie leider nicht in Hamburg verorten. Bitte benutzen Sie Karte und Suche, um einen Schaden innerhalb von Hamburg zu melden.', + boundaryError: + 'Die Verortung ist fehlgeschlagen. Bitte benutzen Sie Karte und Suche, um einen Schaden innerhalb von Hamburg zu melden.', + }, + }, + gfi: { + header: { + close: 'Zurück zur Listenansicht der Meldungen', + }, + list: { + header: 'Meldungsliste', + entry: 'Meldung', + emptyView: + 'Im aktuellen Kartenausschnitt sind keine Meldungen enthalten.', + pagination: { + currentPage: + 'Aktuelle Seite, Seite {{page}} von {{maxPage}} der Schadensmeldungen', + page: 'Öffne Seite {{page}} von {{maxPage}} der Schadensmeldungen', + next: 'Nächste Seite öffnen', + previous: 'Vorherige Seite öffnen', + wrapper: 'Seitenauswahl', + }, + }, + noActiveLayer: + 'Die Meldungen sind derzeit ausgeschaltet. Sie können Sie über die Kartenauswahl (Buch-Symbol in der Werkzeugleiste) wieder einschalten.', + }, + iconMenu: { + hints: { + filter: 'Filter', + gfi: 'Meldungsliste', + }, + }, + pins: { + toast: { + notInBoundary: + 'Es können nur Koordinaten innerhalb von Hamburg gewählt werden.', + }, + }, + }, + }, + }, +] + +// test for enum/locale synchronity; error on mismatch +locales.forEach((locale) => { + const knownLocaleSKAT = Object.keys(skat) + const knownEnumSKAT = SKAT.map((n) => String(n)) + if (knownLocaleSKAT.sort().join(',') !== knownEnumSKAT.sort().join(',')) { + throw new Error( + `POLAR Meldemichel: Error in locales.ts/enums.ts: SKAT and Locales not in sync for language "${ + locale.type + }". Affected SKAT: ${knownLocaleSKAT + .filter((x) => !knownEnumSKAT.includes(x)) + .concat(knownEnumSKAT.filter((x) => !knownLocaleSKAT.includes(x)))}` + ) + } +}) + +export default locales + +export const jenfeldLocales = JSON.parse(JSON.stringify(locales)) + +jenfeldLocales[0].resources.plugins.geoLocation.toast.notInBoundary = + jenfeldLocales[0].resources.plugins.geoLocation.toast.notInBoundary.replaceAll( + 'Hamburg', + 'Jenfeld' + ) +jenfeldLocales[0].resources.plugins.geoLocation.toast.boundaryError = + jenfeldLocales[0].resources.plugins.geoLocation.toast.boundaryError.replaceAll( + 'Hamburg', + 'Jenfeld' + ) +jenfeldLocales[0].resources.plugins.pins.toast.notInBoundary = + jenfeldLocales[0].resources.plugins.pins.toast.notInBoundary.replaceAll( + 'Hamburg', + 'Jenfeld' + ) diff --git a/vue2/packages/clients/meldemichel/src/mapConfigurations.ts b/vue2/packages/clients/meldemichel/src/mapConfigurations.ts new file mode 100644 index 0000000000..93a27c981e --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/mapConfigurations.ts @@ -0,0 +1,402 @@ +import { + AddressSearchConfiguration, + Attribution, + AttributionsConfiguration, + FilterConfiguration, + GeoLocationConfiguration, + LayerConfiguration, + MapConfig, + PinsConfiguration, + ReverseGeocoderConfiguration, +} from '@polar/lib-custom-types' +import { MpApiParameters } from '@polar/plugin-address-search' +import { MODE, SKAT, REPORT_STATUS } from './enums' +import locales, { jenfeldLocales } from './locales' +import { MeldemichelCreateMapParams } from './types' +import { showTooltip } from './utils/showTooltip' +import { jenfeldBoundaryId } from './utils/jenfeld/addJenfeldBoundary' + +export const stadtwald = '18746' +const stadtplan = '453' +const luftbilder = '34127' +export const hamburgBorder = '1693' // boundary layer for pins / geolocalization +const hamburgWhite = '#ffffff' +const hamburgDarkBlue = '#003063' +const hamburgRed = '#ff0019' + +const commonMapConfiguration: Partial = { + checkServiceAvailability: false, // service register too long + locales, + vuetify: { + theme: { + themes: { + light: { + primary: hamburgDarkBlue, + primaryContrast: hamburgWhite, + secondary: hamburgWhite, + secondaryContrast: hamburgDarkBlue, + }, + }, + }, + }, +} + +const commonLayers: LayerConfiguration[] = [ + { + id: stadtplan, + visibility: true, + type: 'background', + name: 'meldemichel.layers.stadtplan', + }, + { + id: luftbilder, + type: 'background', + name: 'meldemichel.layers.luftbilder', + }, + { + id: hamburgBorder, + visibility: true, + hideInMenu: true, + type: 'mask', + name: 'meldemichel.layers.hamburgBorder', + }, +] + +const commonAttributions: Partial = { + initiallyOpen: false, + layerAttributions: [ + { + id: stadtplan, + title: 'meldemichel.attributions.stadtplan', + }, + { + id: luftbilder, + title: 'meldemichel.attributions.luftbilder', + }, + ], +} + +const addressSearch: AddressSearchConfiguration = { + searchMethods: [ + { + queryParameters: { + searchAddress: true, + searchStreets: true, + searchHouseNumbers: true, + } as MpApiParameters, + type: 'mpapi', + url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', + }, + ], + minLength: 3, + waitMs: 300, +} + +const commonPins: Partial = { + toZoomLevel: 7, + movable: 'drag', + style: { + fill: hamburgRed, + }, + boundaryLayerId: hamburgBorder, +} + +const reverseGeocoder: Partial = { + coordinateSource: 'plugin/pins/transformedCoordinate', + addressTarget: 'plugin/addressSearch/selectResult', +} + +const getFilterConfiguration = (id: string): FilterConfiguration => ({ + layers: { + [id]: { + categories: [ + { + selectAll: true, + targetProperty: 'skat', + knownValues: [...SKAT], + }, + { + targetProperty: 'statu', + knownValues: [...REPORT_STATUS], + }, + ], + time: { + targetProperty: 'start', + pattern: 'YYYYMMDD', + last: [ + { + amounts: [7, 30], + }, + ], + freeSelection: { + now: 'until', + }, + }, + }, + }, +}) + +const geoLocation: Partial = { + checkLocationInitially: true, + zoomLevel: 7, + boundaryLayerId: hamburgBorder, + boundaryOnError: 'strict', + showTooltip: true, +} + +const mapConfigurations: Record< + keyof typeof MODE, + (reportServiceId: string, afmUrl: string) => object +> = { + [MODE.COMPLETE]: (reportServiceId: string, afmUrl: string) => { + return { + ...commonMapConfiguration, + extendedMasterportalapiMarkers: { + layers: [reportServiceId], + defaultStyle: { + stroke: '#FFFFFF', + fill: '#005CA9', + }, + hoverStyle: { + stroke: '#46688E', + fill: '#8BA1B8', + }, + selectionStyle: { + stroke: '#FFFFFF', + fill: '#E10019', + }, + clusterClickZoom: true, + dispatchOnMapSelect: ['plugin/iconMenu/openMenuById', 'gfi'], + }, + addressSearch, + layers: [ + ...commonLayers, + { + id: reportServiceId, + visibility: true, + type: 'mask', + name: 'meldemichel.layers.reports', + } as LayerConfiguration, + ], + attributions: { + ...commonAttributions, + layerAttributions: [ + ...(commonAttributions.layerAttributions as Attribution[]), + { + id: reportServiceId, + title: 'meldemichel.attributions.reports', + }, + ], + staticAttributions: [ + 'Impressum', + ], + }, + filter: getFilterConfiguration(reportServiceId), + geoLocation, + gfi: { + mode: 'bboxDot', + activeLayerPath: 'plugin/layerChooser/activeMaskIds', + layers: { + [reportServiceId]: { + geometry: false, + window: true, + // translation in meldemichel's local gfi override + properties: [ + 'str', + 'hsnr', + 'pic', + 'skat', + 'beschr', + 'rueck', + 'start', + 'statu', + ], + showTooltip, + }, + }, + }, + pins: commonPins, + reverseGeocoder, + meldemichel: { + afmButton: { afmUrl }, + }, + } + }, + [MODE.JENFELD]: (reportServiceId: string, afmUrl: string) => { + return { + ...commonMapConfiguration, + locales: jenfeldLocales, + startResolution: 0.6614579761460262, + startCenter: [574779.93, 5936743.88], + extent: [573113.0, 5935603.15, 576367.37, 5938307.19], + options: [{ resolution: 0.6614579761460262, scale: 2500, zoomLevel: 7 }], + extendedMasterportalapiMarkers: { + layers: [reportServiceId], + defaultStyle: { + stroke: '#FFFFFF', + fill: '#005CA9', + }, + hoverStyle: { + stroke: '#46688E', + fill: '#8BA1B8', + }, + selectionStyle: { + stroke: '#FFFFFF', + fill: '#E10019', + }, + clusterClickZoom: true, + dispatchOnMapSelect: ['plugin/iconMenu/openMenuById', 'gfi'], + }, + addressSearch: { + ...addressSearch, + searchMethods: [ + { + ...addressSearch.searchMethods[0], + resultModifier: (featureCollection) => ({ + ...featureCollection, + features: featureCollection.features.filter((feature) => { + // for return type 'street' + if (feature.properties.postOrtsteil) { + return ( + // avoid e.g. "Rodigallee" (also in Jenfeld, but center is outside of it) as result by ignoring arrays + feature.properties.postOrtsteil === 'Jenfeld' + ) + } + // for return type 'houseNumbersForStreet', 'addressUnaffixed', and maybe more + if (feature.properties.geographicIdentifier) { + // ._. <(^_^<) aw don't be sad mr. smiley man + return feature.properties.geographicIdentifier._.includes( + '(OT Jenfeld)' + ) + } + + console.warn( + '@polar/client-meldemichel: AddressSearch.resultFilter found unfilterable feature; skipping.', + feature + ) + + // when in doubt, assume it's invalid to prevent users from flying to the map's edge + return false + }), + }), + }, + ], + }, + layers: [ + ...commonLayers.filter(({ id }) => id !== hamburgBorder), + { + id: reportServiceId, + visibility: true, + type: 'mask', + name: 'meldemichel.layers.reports', + } as LayerConfiguration, + ], + attributions: { + ...commonAttributions, + layerAttributions: [ + ...(commonAttributions.layerAttributions as Attribution[]), + { + id: reportServiceId, + title: 'meldemichel.attributions.reports', + }, + ], + staticAttributions: [ + 'Impressum', + ], + }, + filter: getFilterConfiguration(reportServiceId), + geoLocation: { + ...geoLocation, + boundaryLayerId: jenfeldBoundaryId, + }, + gfi: { + mode: 'bboxDot', + activeLayerPath: 'plugin/layerChooser/activeMaskIds', + layers: { + [reportServiceId]: { + geometry: false, + window: true, + // translation in meldemichel's local gfi override + properties: [ + 'str', + 'hsnr', + 'pic', + 'skat', + 'beschr', + 'rueck', + 'start', + 'statu', + ], + showTooltip, + }, + }, + }, + pins: { + ...commonPins, + boundaryLayerId: jenfeldBoundaryId, + }, + reverseGeocoder, + meldemichel: { + afmButton: { afmUrl }, + }, + } + }, + [MODE.REPORT]: () => ({ + ...commonMapConfiguration, + addressSearch, + layers: commonLayers, + attributions: { + ...commonAttributions, + }, + geoLocation, + pins: commonPins, + reverseGeocoder, + }), + [MODE.SINGLE]: () => ({ + ...commonMapConfiguration, + addressSearch, + layers: [ + ...commonLayers, + { + id: stadtwald, + visibility: false, + type: 'mask', + name: 'meldemichel.layers.stadtwald', + } as LayerConfiguration, + ], + attributions: { + ...commonAttributions, + layerAttributions: [ + ...(commonAttributions.layerAttributions as Attribution[]), + { + id: stadtwald, + title: 'meldemichel.attributions.stadtwald', + }, + ], + }, + pins: commonPins, + reverseGeocoder, + }), +} + +export const getMapConfiguration = ({ + mode, + afmUrl, + reportServiceId, +}: Pick< + MeldemichelCreateMapParams, + 'mode' | 'afmUrl' | 'reportServiceId' +>): Partial => { + if ( + (mode === MODE.COMPLETE || mode === MODE.JENFELD) && + typeof reportServiceId === 'undefined' + ) { + throw new Error( + `POLAR Meldemichel Client: Missing reportServiceId configuration in mode ${mode}.` + ) + } + return { + // @ts-expect-error | reportServiceId might be undefined, but that's caught above for relevant cases + ...mapConfigurations[mode](reportServiceId, afmUrl), + } +} diff --git a/packages/clients/meldemichel/src/plugins/AfmButton/AfmButton.vue b/vue2/packages/clients/meldemichel/src/plugins/AfmButton/AfmButton.vue similarity index 100% rename from packages/clients/meldemichel/src/plugins/AfmButton/AfmButton.vue rename to vue2/packages/clients/meldemichel/src/plugins/AfmButton/AfmButton.vue diff --git a/packages/clients/meldemichel/src/plugins/AfmButton/index.ts b/vue2/packages/clients/meldemichel/src/plugins/AfmButton/index.ts similarity index 100% rename from packages/clients/meldemichel/src/plugins/AfmButton/index.ts rename to vue2/packages/clients/meldemichel/src/plugins/AfmButton/index.ts diff --git a/packages/clients/meldemichel/src/plugins/AfmButton/locales.ts b/vue2/packages/clients/meldemichel/src/plugins/AfmButton/locales.ts similarity index 100% rename from packages/clients/meldemichel/src/plugins/AfmButton/locales.ts rename to vue2/packages/clients/meldemichel/src/plugins/AfmButton/locales.ts diff --git a/packages/clients/meldemichel/src/plugins/Gfi/ActionButtons.vue b/vue2/packages/clients/meldemichel/src/plugins/Gfi/ActionButtons.vue similarity index 100% rename from packages/clients/meldemichel/src/plugins/Gfi/ActionButtons.vue rename to vue2/packages/clients/meldemichel/src/plugins/Gfi/ActionButtons.vue diff --git a/packages/clients/meldemichel/src/plugins/Gfi/Feature.vue b/vue2/packages/clients/meldemichel/src/plugins/Gfi/Feature.vue similarity index 100% rename from packages/clients/meldemichel/src/plugins/Gfi/Feature.vue rename to vue2/packages/clients/meldemichel/src/plugins/Gfi/Feature.vue diff --git a/packages/clients/meldemichel/src/plugins/Gfi/index.ts b/vue2/packages/clients/meldemichel/src/plugins/Gfi/index.ts similarity index 100% rename from packages/clients/meldemichel/src/plugins/Gfi/index.ts rename to vue2/packages/clients/meldemichel/src/plugins/Gfi/index.ts diff --git a/vue2/packages/clients/meldemichel/src/polar-client.ts b/vue2/packages/clients/meldemichel/src/polar-client.ts new file mode 100644 index 0000000000..2ca95eed4b --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/polar-client.ts @@ -0,0 +1,134 @@ +import core, { NineLayoutTag } from '@polar/core' +import merge from 'lodash.merge' +import { Vector } from 'ol/layer' +import { Map } from 'ol' +import { MapInstance } from '@polar/core/src/types' +import packageInfo from '../package.json' +import { MODE } from './enums' +import { addPlugins } from './addPlugins' +import { getMapConfiguration, hamburgBorder } from './mapConfigurations' +import { setBackgroundImage } from './utils/setBackgroundImage' +import { MeldemichelCreateMapParams } from './types' +import meldemichelModule from './store/module' +import './styles/index.css' +import AfmButton from './plugins/AfmButton' +import { enableClustering } from './utils/enableClustering' +import { clipWithJenfeldBoundary } from './utils/jenfeld/clipWithJenfeldBoundary' +import { services as localServices } from './utils/jenfeld/services' +import { + addJenfeldBoundary, + jenfeldBoundaryId, +} from './utils/jenfeld/addJenfeldBoundary' + +// eslint-disable-next-line no-console +console.log(`POLAR Meldemichel loaded in version ${packageInfo.version}.`) + +const serviceRegister = + 'https://geoportal-hamburg.de/lgv-config/services-internet.json' + +// can't be configured "visible: false" – wouldn't load at all then +const hideBorder = (map: Map) => { + ;( + map + .getLayers() + .getArray() + .find((layer) => + [jenfeldBoundaryId, hamburgBorder].includes(layer.get('id')) + ) as Vector + ).setStyle(null) +} + +const registerAfmButton = (client, mode) => { + if (mode === MODE.COMPLETE || mode === MODE.JENFELD) { + // late setup due to dependency to meldemichelModule + AfmButton({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + })(client) + } +} + +const memory: { + wrapper: HTMLElement | null + client: MapInstance | null +} = { + wrapper: null, + client: null, +} + +const rerender = (containerId, configOverride, stadtwaldActive) => { + document + .getElementById(containerId) + ?.replaceWith(memory.wrapper as HTMLElement) + if (configOverride.pins && memory.client) { + // update may be required on rerender + const client = memory.client + client.$store.commit('setConfiguration', { + ...client.$store.state.configuration, + pins: configOverride.pins, + }) + client.$store.dispatch('plugin/pins/setupInitial') + } + if (typeof stadtwaldActive === 'boolean' && memory.client) { + memory.client.$store.dispatch('meldemichel/setMapState', { + stadtwaldActive, + }) + } + return memory.client +} + +export default { + createMap: ({ + containerId, + mode, + afmUrl, + stadtwaldActive, + reportServiceId, + configOverride, + }: MeldemichelCreateMapParams) => + new Promise((resolve) => { + if (memory.wrapper) { + return resolve(rerender(containerId, configOverride, stadtwaldActive)) + } + if (!Object.keys(MODE).includes(mode)) { + console.error( + `@polar/client-meldemichel: Critical error. Unknown mode "${mode}" configured. Please use 'COMPLETE', 'REPORT', or 'SINGLE'.` + ) + } + const meldemichelCore = { ...core } + addPlugins(meldemichelCore, mode) + // NOTE initializeLayerList is async in this scenario + meldemichelCore.rawLayerList.initializeLayerList( + mode === MODE.JENFELD ? localServices : serviceRegister, + async (layerConf) => { + enableClustering(layerConf, reportServiceId) + const client = await meldemichelCore.createMap({ + containerId, + mapConfiguration: merge( + { + ...getMapConfiguration({ mode, afmUrl, reportServiceId }), + layerConf, + }, + configOverride || {} + ), + }) + client.$store.registerModule('meldemichel', meldemichelModule) + registerAfmButton(client, mode) + if (mode === MODE.JENFELD) { + clipWithJenfeldBoundary(client.$store.getters.map) + addJenfeldBoundary(client.$store.getters.map) + } + hideBorder(client.$store.getters.map) + setBackgroundImage(containerId) + if (typeof stadtwaldActive === 'boolean') { + client.$store.dispatch('meldemichel/setMapState', { + stadtwaldActive, + }) + } + memory.wrapper = document.getElementById(`${containerId}-wrapper`) + memory.client = client + resolve(client) + } + ) + }), +} diff --git a/packages/clients/meldemichel/src/store/module.ts b/vue2/packages/clients/meldemichel/src/store/module.ts similarity index 100% rename from packages/clients/meldemichel/src/store/module.ts rename to vue2/packages/clients/meldemichel/src/store/module.ts diff --git a/packages/clients/meldemichel/src/styles/index.css b/vue2/packages/clients/meldemichel/src/styles/index.css similarity index 100% rename from packages/clients/meldemichel/src/styles/index.css rename to vue2/packages/clients/meldemichel/src/styles/index.css diff --git a/packages/clients/meldemichel/src/types.ts b/vue2/packages/clients/meldemichel/src/types.ts similarity index 100% rename from packages/clients/meldemichel/src/types.ts rename to vue2/packages/clients/meldemichel/src/types.ts diff --git a/packages/clients/meldemichel/src/utils/createMenus.ts b/vue2/packages/clients/meldemichel/src/utils/createMenus.ts similarity index 91% rename from packages/clients/meldemichel/src/utils/createMenus.ts rename to vue2/packages/clients/meldemichel/src/utils/createMenus.ts index 37a97fe1ee..37fdcccc31 100644 --- a/packages/clients/meldemichel/src/utils/createMenus.ts +++ b/vue2/packages/clients/meldemichel/src/utils/createMenus.ts @@ -14,14 +14,14 @@ export default function (mode: keyof typeof MODE): Menu[] { icon: 'fa-layer-group', id: 'layerChooser', }, - mode === MODE.COMPLETE && { + (mode === MODE.COMPLETE || mode === MODE.JENFELD) && { plugin: Filter({ layers: {}, }), icon: 'fa-filter', id: 'filter', }, - mode === MODE.COMPLETE && { + (mode === MODE.COMPLETE || mode === MODE.JENFELD) && { plugin: Gfi({ layers: {}, gfiContentComponent: MeldemichelGfiFeature, @@ -44,7 +44,7 @@ export default function (mode: keyof typeof MODE): Menu[] { icon: 'fa-location-pin', id: 'gfi', }, - { + mode !== MODE.JENFELD && { plugin: Zoom({ renderType: 'iconMenu' }), id: 'zoom', }, diff --git a/packages/clients/meldemichel/src/utils/enableClustering.ts b/vue2/packages/clients/meldemichel/src/utils/enableClustering.ts similarity index 100% rename from packages/clients/meldemichel/src/utils/enableClustering.ts rename to vue2/packages/clients/meldemichel/src/utils/enableClustering.ts diff --git a/vue2/packages/clients/meldemichel/src/utils/jenfeld.ts b/vue2/packages/clients/meldemichel/src/utils/jenfeld.ts new file mode 100644 index 0000000000..48168d4cac --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/utils/jenfeld.ts @@ -0,0 +1,441 @@ +import { Coordinate } from 'ol/coordinate' + +/* + * taken from service 1694 of https://geodienste.hamburg.de/services-internet.json + * with URL https://geodienste.hamburg.de/HH_WFS_Verwaltungsgrenzen + */ +export const jenfeldCoordinates: Coordinate[][] = [ + [ + [574738.058, 5935980.614], + [574731.997, 5935980.334], + [574728.354, 5935980.166], + [574694.858, 5935978.446], + [574659.502, 5935976.362], + [574659.197, 5935974.824], + [574655.91, 5935974.675], + [574652.83, 5935974.535], + [574634.246, 5935973.172], + [574597.987, 5935970.517], + [574558.427, 5935966.412], + [574533.363, 5935963.509], + [574507.98, 5935960.02], + [574482.962, 5935956.046], + [574435.291, 5935948.264], + [574386.594, 5935940.051], + [574353.174, 5935934.176], + [574335.293, 5935931.031], + [574315.213, 5935924.738], + [574264.717, 5935914.769], + [574214.184, 5935905.065], + [574163.326, 5935895.275], + [574136.496, 5935890.146], + [574113.652, 5935885.779], + [574065.177, 5935876.461], + [574017.149, 5935867.247], + [573980.697, 5935860.928], + [573964.711, 5935858.157], + [573934.504, 5935852.788], + [573890.632, 5935845.204], + [573883.887, 5935844.03], + [573876.885, 5935842.813], + [573867.375, 5935841.16], + [573844.706, 5935837.22], + [573810.818, 5935831.288], + [573790.511, 5935827.628], + [573748.782, 5935820.108], + [573705.828, 5935812.806], + [573652.927, 5935803.853], + [573605.52, 5935795.876], + [573600.213, 5935794.983], + [573549.48, 5935787.053], + [573399.663, 5935763.711], + [573352.437, 5935755.841], + [573347.473, 5935755.014], + [573343.642, 5935770.077], + [573324.16, 5935838.197], + [573305.941, 5935910.105], + [573268.658, 5936068.402], + [573243.625, 5936175.107], + [573240.292, 5936186.882], + [573215.335, 5936275.052], + [573208.96, 5936304.125], + [573204.29, 5936325.705], + [573172.852, 5936463.989], + [573167.35, 5936490.725], + [573162.686, 5936513.391], + [573138.105, 5936598.733], + [573126.758, 5936658.001], + [573106.224, 5936739.652], + [573084.084, 5936827.145], + [573069.535, 5936884.637], + [573063.813, 5936907.808], + [573075.685, 5936914.17], + [573092.015, 5936922.921], + [573124.041, 5936941.2], + [573155.718, 5936960.077], + [573187.036, 5936979.545], + [573240.662, 5937015.171], + [573298.871, 5937056.658], + [573318.64, 5937071.85], + [573337.965, 5937087.602], + [573356.832, 5937103.901], + [573424.001, 5937163.339], + [573431.425, 5937169.908], + [573490.727, 5937086.339], + [573496.733, 5937078.15], + [573503.356, 5937070.453], + [573510.558, 5937063.292], + [573518.293, 5937056.713], + [573519.777, 5937055.52], + [573521.282, 5937054.355], + [573522.81, 5937053.219], + [573526.422, 5937050.702], + [573530.139, 5937048.345], + [573533.956, 5937046.151], + [573605.819, 5937005.808], + [573612.137, 5937002.351], + [573618.723, 5936999.438], + [573625.53, 5936997.089], + [573632.511, 5936995.32], + [573639.616, 5936994.144], + [573646.794, 5936993.569], + [573702.824, 5936987.702], + [573713.224, 5936986.702], + [573723.525, 5936984.951], + [573733.672, 5936982.458], + [573738.184, 5936982.113], + [573742.709, 5936982.107], + [573747.222, 5936982.44], + [573751.809, 5936983.131], + [573756.33, 5936984.173], + [573760.757, 5936985.559], + [573765.064, 5936987.281], + [573769.227, 5936989.328], + [573773.22, 5936991.689], + [573800.468, 5937005.625], + [573823.19, 5937017.303], + [573851.862, 5937032.038], + [573887.476, 5937050.342], + [573971.529, 5937092.629], + [573971.731, 5937092.731], + [574020.422, 5937117.227], + [574130.973, 5937174.161], + [574229.399, 5937223.215], + [574258.967, 5937239.073], + [574287.299, 5937257.047], + [574314.243, 5937277.042], + [574339.655, 5937298.951], + [574363.399, 5937322.657], + [574374.486, 5937334.976], + [574385.123, 5937347.686], + [574395.294, 5937360.771], + [574430.647, 5937408.199], + [574490.204, 5937493.083], + [574543.829, 5937569.47], + [574557.938, 5937589.746], + [574572.538, 5937609.672], + [574587.62, 5937629.236], + [574608.417, 5937654.684], + [574630.03, 5937679.444], + [574652.435, 5937703.489], + [574711.742, 5937764.519], + [574725.614, 5937777.677], + [574740.528, 5937789.641], + [574756.381, 5937800.33], + [574773.065, 5937809.67], + [574865.603, 5937854.083], + [574904.224, 5937867.76], + [574948.505, 5937893.583], + [575012.781, 5937930.838], + [575018.59, 5937936.852], + [575022.166, 5937940.494], + [575095.006, 5937995.07], + [575106.485, 5938003.984], + [575117.38, 5938013.603], + [575127.647, 5938023.89], + [575195.556, 5938090.252], + [575207.042, 5938101.011], + [575219.337, 5938110.836], + [575232.366, 5938119.665], + [575246.047, 5938127.444], + [575250.646, 5938129.751], + [575255.303, 5938131.939], + [575260.014, 5938134.006], + [575333.954, 5938166.999], + [575415.711, 5938196.83], + [575549.495, 5938245.832], + [575558.83, 5938249.761], + [575566.939, 5938253.216], + [575659.353, 5938285.888], + [575661.616, 5938280.379], + [575682.611, 5938287.887], + [575694.72, 5938269.588], + [575706.4, 5938278.769], + [575748.589, 5938214.711], + [575748.594, 5938211.86], + [575748.623, 5938196.731], + [575748.64, 5938188.104], + [575749.163, 5938092.605], + [575752.185, 5938065.971], + [575751.977, 5938035.863], + [575752.406, 5937993.092], + [575760.053, 5937992.472], + [575759.732, 5937974.208], + [575759.17, 5937942.068], + [575783.42, 5937935.669], + [575778.774, 5937918.17], + [575769.527, 5937918.228], + [575769.657, 5937858.754], + [575774.931, 5937850.54], + [575774.967, 5937850.484], + [575780.391, 5937842.038], + [575780.393, 5937842.039], + [575796.431, 5937817.06], + [575796.44, 5937814.56], + [575796.803, 5937710.501], + [575826.441, 5937711.065], + [575813.506, 5937635.873], + [575826.917, 5937635.843], + [575830.033, 5937607.533], + [575839.788, 5937518.934], + [575840.444, 5937513.52], + [575951.761, 5937536.883], + [575954.356, 5937531.032], + [575958.426, 5937519.829], + [575960.625, 5937512.536], + [575963.865, 5937503.566], + [575970.545, 5937485.08], + [575976.539, 5937468.489], + [575982.62, 5937451.663], + [575988.912, 5937434.255], + [575996.637, 5937412.783], + [576001.636, 5937398.614], + [576006.631, 5937384.455], + [576011.62, 5937370.315], + [576016.612, 5937356.165], + [576021.6, 5937342.025], + [576026.595, 5937327.866], + [576027.766, 5937324.548], + [576052.333, 5937321.301], + [576053.014, 5937321.211], + [576060.61, 5937320.207], + [576065.753, 5937304.375], + [576071.932, 5937285.356], + [576078.098, 5937266.375], + [576084.273, 5937247.371], + [576088.748, 5937233.046], + [576090.821, 5937225.543], + [576091.356, 5937223.604], + [576096.977, 5937203.271], + [576088.604, 5937195.158], + [576007.612, 5937104.003], + [576015.92, 5937087.87], + [576023.281, 5937073.576], + [576058.738, 5937073.444], + [576088.673, 5937059.449], + [576118.737, 5937045.613], + [576134.271, 5937043.305], + [576153.168, 5937041.081], + [576178.42, 5937035.874], + [576181.037, 5937034.369], + [576185.26, 5937026.847], + [576245.012, 5937000.735], + [576263.084, 5936992.096], + [576260.601, 5936975.351], + [576259.562, 5936960.947], + [576258.472, 5936949.631], + [576256.402, 5936935.207], + [576255.723, 5936932.283], + [576255.043, 5936929.359], + [576256.662, 5936919.733], + [576252.074, 5936906.408], + [576249.764, 5936895.633], + [576250.534, 5936890.055], + [576251.384, 5936885.997], + [576251.643, 5936870.523], + [576251.253, 5936864.445], + [576252.703, 5936857.428], + [576253.692, 5936850.611], + [576253.582, 5936846.472], + [576253.162, 5936840.235], + [576253.552, 5936833.138], + [576255.191, 5936825.291], + [576258.059, 5936813.923], + [576259.359, 5936808.767], + [576260.599, 5936800.83], + [576260.699, 5936795.732], + [576261.698, 5936787.736], + [576263.947, 5936781.058], + [576267.146, 5936773.551], + [576270.235, 5936764.235], + [576271.041, 5936759.532], + [576271.124, 5936759.047], + [576272.334, 5936750.08], + [576273.663, 5936740.624], + [576273.273, 5936730.788], + [576274.243, 5936721.772], + [576275.542, 5936715.904], + [576276.342, 5936709.926], + [576276.911, 5936703.899], + [576276.012, 5936697.541], + [576279.74, 5936690.154], + [576280.56, 5936686.236], + [576283.299, 5936679.658], + [576281.979, 5936662.965], + [576281.839, 5936652.629], + [576282.878, 5936649.23], + [576283.925, 5936645.683], + [576286.377, 5936637.375], + [576286.167, 5936627.159], + [576284.897, 5936620.652], + [576285.907, 5936616.743], + [576282.771, 5936599.72], + [576280.806, 5936592.718], + [576276.438, 5936572.919], + [576274.971, 5936560.085], + [576274.65, 5936557.046], + [576271.303, 5936548.636], + [576270.871, 5936542.058], + [576268.673, 5936538.963], + [576262.657, 5936530.234], + [576256.601, 5936519.436], + [576256.266, 5936518.923], + [576252.084, 5936512.38], + [576250.047, 5936507.758], + [576244.978, 5936497.742], + [576241.15, 5936489.455], + [576235.974, 5936475.31], + [576234.028, 5936467.283], + [576233.566, 5936465.592], + [576229.768, 5936447.952], + [576226.351, 5936436.437], + [576223.856, 5936428.298], + [576222.792, 5936418.734], + [576221.865, 5936415.356], + [576215.949, 5936406.08], + [576211.088, 5936399.113], + [576206.142, 5936385.42], + [576204.222, 5936378.423], + [576199.394, 5936367.808], + [576195.895, 5936355.801], + [576190.291, 5936347.476], + [576183.014, 5936338.32], + [576179.391, 5936331.524], + [576179.548, 5936328.136], + [576186.969, 5936305.093], + [576189.626, 5936293.988], + [576187.89, 5936292.395], + [576190.185, 5936286.576], + [576191.185, 5936282.669], + [576192.453, 5936279.728], + [576194.216, 5936275.244], + [576194.284, 5936275.072], + [576197.885, 5936269.364], + [576199.334, 5936263.256], + [576198.918, 5936258.725], + [576198.692, 5936256.255], + [576199.114, 5936248.35], + [576196.803, 5936242.753], + [576194.152, 5936230.245], + [576190.692, 5936219.51], + [576190.501, 5936211.254], + [576187.782, 5936198.498], + [576183.351, 5936191.409], + [576179.697, 5936189.817], + [576178.927, 5936187.951], + [576181.818, 5936187.125], + [576179.679, 5936183.077], + [576176.582, 5936180.299], + [576171.305, 5936172.401], + [576169.633, 5936168.19], + [576162.438, 5936156.138], + [576160.28, 5936152.058], + [576157.548, 5936143.979], + [576154.776, 5936139.54], + [576154.419, 5936137.303], + [576152.988, 5936135.871], + [576151.791, 5936135.0], + [576129.106, 5936118.426], + [576123.693, 5936113.958], + [576113.883, 5936105.86], + [576093.222, 5936088.443], + [576089.064, 5936085.236], + [576087.674, 5936084.164], + [576086.105, 5936081.425], + [576079.807, 5936076.486], + [576070.482, 5936071.186], + [576068.032, 5936068.677], + [576063.008, 5936062.565], + [576062.255, 5936061.649], + [576055.288, 5936052.961], + [576043.144, 5936036.575], + [576037.866, 5936029.727], + [576033.938, 5936022.88], + [576033.413, 5936022.352], + [576033.063, 5936021.706], + [576033.468, 5936021.416], + [576069.607, 5935995.543], + [576066.747, 5935967.654], + [576038.729, 5935951.71], + [576026.127, 5935934.087], + [576001.504, 5935895.486], + [576000.943, 5935894.607], + [575998.576, 5935891.216], + [575996.682, 5935882.602], + [575997.778, 5935856.533], + [575999.009, 5935827.23], + [576000.461, 5935809.119], + [576008.807, 5935705.019], + [576013.42, 5935673.432], + [576013.596, 5935672.222], + [576014.175, 5935668.257], + [576003.234, 5935672.163], + [575681.638, 5935791.463], + [575682.7, 5935800.001], + [575671.647, 5935803.775], + [575659.255, 5935808.007], + [575655.965, 5935800.316], + [575581.053, 5935828.267], + [575534.781, 5935847.093], + [575487.929, 5935866.335], + [575475.58, 5935870.878], + [575471.243, 5935875.939], + [575465.963, 5935880.209], + [575459.967, 5935877.566], + [575454.961, 5935877.203], + [575391.097, 5935896.106], + [575371.399, 5935900.163], + [575343.547, 5935909.079], + [575316.911, 5935917.079], + [575291.736, 5935923.78], + [575264.855, 5935929.699], + [575241.94, 5935934.378], + [575165.677, 5935947.398], + [575149.818, 5935950.179], + [575103.886, 5935954.889], + [575104.735, 5935959.802], + [575105.345, 5935963.336], + [575093.677, 5935965.394], + [575086.946, 5935966.086], + [575077.624, 5935967.045], + [575059.783, 5935968.88], + [575059.571, 5935965.253], + [575059.339, 5935962.276], + [575045.855, 5935963.85], + [575020.835, 5935967.386], + [574995.834, 5935970.541], + [574970.849, 5935973.134], + [574945.689, 5935975.306], + [574920.459, 5935977.103], + [574895.365, 5935978.601], + [574870.047, 5935979.63], + [574859.07, 5935979.866], + [574844.584, 5935980.168], + [574819.487, 5935980.296], + [574793.906, 5935979.983], + [574783.169, 5935980.188], + [574768.574, 5935980.465], + [574743.618, 5935980.895], + [574738.058, 5935980.614], + ], +] diff --git a/vue2/packages/clients/meldemichel/src/utils/jenfeld/addJenfeldBoundary.ts b/vue2/packages/clients/meldemichel/src/utils/jenfeld/addJenfeldBoundary.ts new file mode 100644 index 0000000000..339f6d2029 --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/utils/jenfeld/addJenfeldBoundary.ts @@ -0,0 +1,23 @@ +import Map from 'ol/Map' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' +import Feature from 'ol/Feature' +import Polygon from 'ol/geom/Polygon' + +import { jenfeldCoordinates } from '../jenfeld' + +export const jenfeldBoundaryId = 'hamburgBorder' + +export const addJenfeldBoundary = (map: Map) => + map.addLayer( + new VectorLayer({ + source: new VectorSource({ + features: [ + new Feature({ + geometry: new Polygon(jenfeldCoordinates), + }), + ], + }), + properties: { id: jenfeldBoundaryId }, + }) + ) diff --git a/vue2/packages/clients/meldemichel/src/utils/jenfeld/clipWithJenfeldBoundary.ts b/vue2/packages/clients/meldemichel/src/utils/jenfeld/clipWithJenfeldBoundary.ts new file mode 100644 index 0000000000..f42ef81ae1 --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/utils/jenfeld/clipWithJenfeldBoundary.ts @@ -0,0 +1,47 @@ +import Polygon from 'ol/geom/Polygon' +import Map from 'ol/Map' +import TileLayer from 'ol/layer/Tile' +import { jenfeldCoordinates } from '../jenfeld' + +const jenfeldBoundaryPolygon = new Polygon(jenfeldCoordinates) +const simplifiedBoundary = ( + jenfeldBoundaryPolygon.simplify(5) as Polygon +).getCoordinates()[0] + +export function clipWithJenfeldBoundary(map: Map) { + const backgroundLayers = map + .getLayers() + .getArray() + .filter((l) => l.get('type') !== 'mask') as TileLayer[] + + backgroundLayers.forEach((layer) => { + layer.on('prerender', (event) => { + if (!layer.getVisible() || !event.context) { + return + } + + const pixelCoords = simplifiedBoundary.map((coord) => + map.getPixelFromCoordinate(coord) + ) + + const context = event.context as CanvasRenderingContext2D + context.save() + + context.beginPath() + context.moveTo(pixelCoords[0][0], pixelCoords[0][1]) + for (let i = 1; i < pixelCoords.length; i++) { + context.lineTo(pixelCoords[i][0], pixelCoords[i][1]) + } + context.closePath() + context.clip() + }) + + layer.on('postrender', (event) => { + if (!layer.getVisible() || !event.context) { + return + } + + ;(event.context as CanvasRenderingContext2D).restore() + }) + }) +} diff --git a/vue2/packages/clients/meldemichel/src/utils/jenfeld/services.js b/vue2/packages/clients/meldemichel/src/utils/jenfeld/services.js new file mode 100644 index 0000000000..2ebafbf818 --- /dev/null +++ b/vue2/packages/clients/meldemichel/src/utils/jenfeld/services.js @@ -0,0 +1,101 @@ +// minimal service set for Jenfeld client to save us about 7MB +export const services = [ + { + id: 'anliegen-jenfeld', + name: 'MML Anliegen (Jenfeld)', + url: 'https://geodienste.hamburg.de/lgv-config/anliegen_extern_jenfeld.json', + typ: 'GeoJSON', + format: 'XML', + version: '1.0', + minScale: '0', + maxScale: '2500000', + gfiAttributes: 'showAll', + gfiTheme: 'mml', + layerAttribution: 'nicht vorhanden', + legendURL: '', + datasets: [], + urlIsVisible: true, + }, + { + id: '453', + name: 'Geobasiskarten (HamburgDE)', + url: 'https://geodienste.hamburg.de/HH_WMS_HamburgDE', + typ: 'WMS', + layers: 'geobasiskarten_hhde', + format: 'image/png', + version: '1.3.0', + singleTile: false, + transparent: true, + transparency: 0, + urlIsVisible: true, + tilesize: 512, + gutter: 0, + minScale: '0', + maxScale: '2500000', + gfiAttributes: 'ignore', + gfiTheme: 'default', + layerAttribution: 'nicht vorhanden', + legendURL: + 'https://geodienste.hamburg.de/HH_WMS_HamburgDE?language=ger&version=1.3.0&service=WMS&request=GetLegendGraphic&sld_version=1.1.0&layer=geobasiskarten_hhde&format=image/png&STYLE=default', + cache: false, + featureCount: 1, + datasets: [ + { + md_id: 'B6A59A2B-2D40-4676-9094-0EB73039ED34', + csw_url: 'https://metaver.de/csw', + show_doc_url: + 'https://metaver.de/trefferanzeige?cmd=doShowDocument&docuuid=', + rs_id: + 'https://registry.gdi-de.org/id/de.hh/001719df-6619-40b7-aefe-32e8aaf49337', + md_name: 'GeoBasisKarten Hamburg', + bbox: '466251.4292773354,5844577.894672247,661887.1257872104,6042030.30978004', + kategorie_opendata: ['Umwelt'], + kategorie_inspire: ['kein INSPIRE-Thema'], + kategorie_organisation: 'Landesbetrieb Geoinformation und Vermessung', + }, + ], + notSupportedFor3DNeu: false, + }, + { + id: '34127', + name: 'DOP Zeitreihe belaubt', + url: 'https://geodienste.hamburg.de/wms_dop_zeitreihe_belaubt', + typ: 'WMS', + layers: 'dop_zeitreihe_belaubt', + format: 'image/png', + version: '1.3.0', + singleTile: false, + transparent: true, + transparency: 0, + urlIsVisible: true, + tilesize: 512, + gutter: 0, + minScale: '0', + maxScale: '2500000', + gfiAttributes: 'ignore', + gfiTheme: 'default', + layerAttribution: 'nicht vorhanden', + legendURL: + 'https://geodienste.hamburg.de/wms_dop_zeitreihe_belaubt?language=ger&version=1.3.0&service=WMS&request=GetLegendGraphic&sld_version=1.1.0&layer=dop_zeitreihe_belaubt&format=image/png&STYLE=default', + cache: false, + featureCount: 1, + datasets: [ + { + md_id: '5DF0990B-9195-41E7-9960-9214BC85B4DA', + csw_url: 'https://metaver.de/csw', + show_doc_url: + 'https://metaver.de/trefferanzeige?cmd=doShowDocument&docuuid=', + rs_id: + 'https://registry.gdi-de.org/id/de.hh/970151d1-575a-488e-99b6-054039c8f57d', + md_name: 'Luftbilder Hamburg - DOP Zeitreihe belaubt', + bbox: '8.420551 53.394985,10.326304 53.964153', + kategorie_opendata: ['Umwelt'], + kategorie_inspire: ['Orthofotografie'], + kategorie_organisation: + 'Landesbetrieb Geoinformation und Vermessung (LGV) Hamburg', + }, + ], + notSupportedFor3DNeu: false, + time: true, + }, +] diff --git a/packages/clients/meldemichel/src/utils/setBackgroundImage.ts b/vue2/packages/clients/meldemichel/src/utils/setBackgroundImage.ts similarity index 100% rename from packages/clients/meldemichel/src/utils/setBackgroundImage.ts rename to vue2/packages/clients/meldemichel/src/utils/setBackgroundImage.ts diff --git a/packages/clients/meldemichel/src/utils/showTooltip.ts b/vue2/packages/clients/meldemichel/src/utils/showTooltip.ts similarity index 100% rename from packages/clients/meldemichel/src/utils/showTooltip.ts rename to vue2/packages/clients/meldemichel/src/utils/showTooltip.ts diff --git a/packages/clients/meldemichel/vite.config.js b/vue2/packages/clients/meldemichel/vite.config.js similarity index 100% rename from packages/clients/meldemichel/vite.config.js rename to vue2/packages/clients/meldemichel/vite.config.js diff --git a/vue2/packages/clients/snowbox/package.json b/vue2/packages/clients/snowbox/package.json new file mode 100644 index 0000000000..fdb1146dd6 --- /dev/null +++ b/vue2/packages/clients/snowbox/package.json @@ -0,0 +1,39 @@ +{ + "name": "@polar/client-snowbox", + "private": true, + "description": "Snow❄️📦box (Test Environment)", + "keywords": ["OpenLayers", "ol", "POLAR", "client", "Snowbox", "testing"], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "scripts": { + "build": "rimraf dist && vite build && copyfiles -f src/html/**/* dist", + "dev": "vite --host" + }, + "//": "Don't push versions but '*'. Do locally as you desire.", + "devDependencies": { + "@polar/core": "*", + "@polar/lib-custom-types": "*", + "@polar/plugin-address-search": "*", + "@polar/plugin-attributions": "*", + "@polar/plugin-draw": "*", + "@polar/plugin-export": "*", + "@polar/plugin-fullscreen": "*", + "@polar/plugin-geo-location": "*", + "@polar/plugin-gfi": "*", + "@polar/plugin-icon-menu": "*", + "@polar/plugin-layer-chooser": "*", + "@polar/plugin-legend": "*", + "@polar/plugin-loading-indicator": "*", + "@polar/plugin-pointer-position": "*", + "@polar/plugin-pins": "*", + "@polar/plugin-reverse-geocoder": "*", + "@polar/plugin-routing": "*", + "@polar/plugin-scale": "*", + "@polar/plugin-toast": "*", + "@polar/plugin-zoom": "*" + }, + "nx": { + "includedScripts": ["build", "dev"] + } +} diff --git a/vue2/packages/clients/snowbox/src/addPlugins.ts b/vue2/packages/clients/snowbox/src/addPlugins.ts new file mode 100644 index 0000000000..94cd045d7c --- /dev/null +++ b/vue2/packages/clients/snowbox/src/addPlugins.ts @@ -0,0 +1,140 @@ +import merge from 'lodash.merge' +import { setLayout, NineLayout, NineLayoutTag } from '@polar/core' +import AddressSearch from '@polar/plugin-address-search' +import Attributions from '@polar/plugin-attributions' +import Draw from '@polar/plugin-draw' +import Export from '@polar/plugin-export' +import Fullscreen from '@polar/plugin-fullscreen' +import GeoLocation from '@polar/plugin-geo-location' +import Gfi from '@polar/plugin-gfi' +import IconMenu from '@polar/plugin-icon-menu' +import LayerChooser from '@polar/plugin-layer-chooser' +import Legend from '@polar/plugin-legend' +import LoadingIndicator from '@polar/plugin-loading-indicator' +import Pins from '@polar/plugin-pins' +import PointerPosition from '@polar/plugin-pointer-position' +import ReverseGeocoder from '@polar/plugin-reverse-geocoder' +import Routing from '@polar/plugin-routing' +import Scale from '@polar/plugin-scale' +import Toast from '@polar/plugin-toast' +import Zoom from '@polar/plugin-zoom' + +const defaultOptions = { + displayComponent: true, + layoutTag: NineLayoutTag.TOP_LEFT, +} + +export const addPlugins = (core) => { + setLayout(NineLayout) + + const iconMenu = IconMenu({ + menus: [ + { + plugin: LayerChooser({}), + icon: 'fa-layer-group', + id: 'layerChooser', + }, + { + plugin: Draw({}), + icon: 'fa-pencil', + id: 'draw', + }, + { + plugin: Zoom({ renderType: 'iconMenu' }), + id: 'zoom', + }, + { + plugin: Fullscreen({ renderType: 'iconMenu' }), + id: 'fullscreen', + }, + { + plugin: GeoLocation({ renderType: 'iconMenu' }), + id: 'geoLocation', + }, + { + plugin: Routing({ + // Will be set later + apiKey: '', + url: 'https://api.openrouteservice.org/v2/directions/', + format: 'geojson', + type: 'ors', + }), + icon: 'fa-route', + id: 'routing', + }, + { + plugin: Attributions({ + renderType: 'iconMenu', + listenToChanges: [ + 'plugin/zoom/zoomLevel', + 'plugin/layerChooser/activeBackgroundId', + 'plugin/layerChooser/activeMaskIds', + ], + }), + icon: 'fa-regular fa-copyright', + id: 'attributions', + }, + ], + displayComponent: true, + initiallyOpen: 'layerChooser', + layoutTag: NineLayoutTag.TOP_RIGHT, + }) + + core.addPlugins([ + AddressSearch( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.TOP_LEFT, + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + }) + ), + iconMenu, + Export( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_LEFT, + }) + ), + LoadingIndicator( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.MIDDLE_MIDDLE, + }) + ), + Legend({ + displayComponent: true, + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + }), + Scale( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_RIGHT, + }) + ), + Toast( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_MIDDLE, + }) + ), + Pins({ + appearOnClick: { show: true, atZoomLevel: 6 }, + coordinateSource: 'plugin/addressSearch/chosenAddress', + toastAction: 'plugin/toast/addToast', + }), + Gfi( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.MIDDLE_LEFT, + }) + ), + ReverseGeocoder({ + url: 'https://geodienste.hamburg.de/HH_WPS', + addLoading: 'plugin/loadingIndicator/addLoadingKey', + removeLoading: 'plugin/loadingIndicator/removeLoadingKey', + coordinateSource: 'plugin/pins/transformedCoordinate', + addressTarget: 'plugin/addressSearch/selectResult', + zoomTo: 7, + }), + PointerPosition( + merge({}, defaultOptions, { + layoutTag: NineLayoutTag.BOTTOM_LEFT, + }) + ), + ]) +} diff --git a/packages/clients/snowbox/src/authentication.ts b/vue2/packages/clients/snowbox/src/authentication.ts similarity index 100% rename from packages/clients/snowbox/src/authentication.ts rename to vue2/packages/clients/snowbox/src/authentication.ts diff --git a/packages/clients/snowbox/src/exampleFeatureInformation.ts b/vue2/packages/clients/snowbox/src/exampleFeatureInformation.ts similarity index 100% rename from packages/clients/snowbox/src/exampleFeatureInformation.ts rename to vue2/packages/clients/snowbox/src/exampleFeatureInformation.ts diff --git a/vue2/packages/clients/snowbox/src/html/index.html b/vue2/packages/clients/snowbox/src/html/index.html new file mode 100644 index 0000000000..3ab2746c2e --- /dev/null +++ b/vue2/packages/clients/snowbox/src/html/index.html @@ -0,0 +1,142 @@ + + + + POLAR Snowbox ❄️📦 Dev + + + + + + + +

    POLAR Snowbox ❄️📦 Dev

    +

    Developer playground.

    +

    📚 Documentation

    +

    + Please mind you have to generate it locally first with `npm run docs:snowbox`, or use the public version here. +

    +

    🔒 Login Example

    +
    + + +
    + +
    +

    +

    🗺️ Map

    +

    In this example, the map client is used as an element on a website.

    + +
    + +
    +

    Example for programmatic information binding

    +

    + This illustrates which kind of data can be retrieved from the map client. +

    +

    Current zoom level:

    +

    GFI information:

    +

    PIN coordinates:

    +

    + Address search result: +

    
    +    

    +

    + Drawing: +

    
    +    

    +

    + Drawing revision: +

    
    +    

    +

    + Map export: + +

    + + + + diff --git a/vue2/packages/clients/snowbox/src/index.html b/vue2/packages/clients/snowbox/src/index.html new file mode 100644 index 0000000000..803346cc3d --- /dev/null +++ b/vue2/packages/clients/snowbox/src/index.html @@ -0,0 +1,132 @@ + + + + POLAR Snowbox ❄️📦 Dev + + + + + + + +

    POLAR Snowbox ❄️📦 Dev

    +

    Developer playground.

    +

    📚 Documentation

    +

    + Please mind you have to generate it locally first with `npm run docs:snowbox`, or use the public version here. +

    +

    🔒 Login example

    +
    + + +
    + +
    +

    +

    🗺️ Map

    +

    In this example, the map client is used as an element on a website.

    +
    + +
    +

    Example for programmatic information binding

    + +

    + This illustrates which kind of data can be retrieved from the map client. +

    +

    Current zoom level:

    +

    GFI information:

    +

    PIN coordinates:

    +

    + Address search result: +

    
    +    

    +

    + Drawing: +

    
    +    

    +

    + Drawing revision: +

    
    +    

    +

    + Map export: + +

    + + + + diff --git a/packages/clients/snowbox/src/locales.ts b/vue2/packages/clients/snowbox/src/locales.ts similarity index 100% rename from packages/clients/snowbox/src/locales.ts rename to vue2/packages/clients/snowbox/src/locales.ts diff --git a/packages/clients/snowbox/src/mapConfiguration.ts b/vue2/packages/clients/snowbox/src/mapConfiguration.ts similarity index 85% rename from packages/clients/snowbox/src/mapConfiguration.ts rename to vue2/packages/clients/snowbox/src/mapConfiguration.ts index 8255095086..012d9bdeb2 100644 --- a/packages/clients/snowbox/src/mapConfiguration.ts +++ b/vue2/packages/clients/snowbox/src/mapConfiguration.ts @@ -96,6 +96,37 @@ export const mapConfiguration = { type: 'mpapi', url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', }, + { + queryParameters: { + typeName: 'Flurstueck', + featurePrefix: 'adv', + xmlns: + 'http://repository.gdi-de.org/schemas/adv/produkt/alkis-vereinfacht/2.0', + maxFeatures: 20, + patterns: [ + '{{gemarkung}} {{flur}} {{flstnrzae}}/{{flstnrnen}}, {{flstkennz}}', + '{{gemarkung}} {{flur}} {{flstnrzae}}, {{flstkennz}}', + '{{gemarkung}} {{flstnrzae}}/{{flstnrnen}}, {{flstkennz}}', + '{{gemarkung}} {{flstnrzae}}, {{flstkennz}}', + '{{gemarkung}} {{flstnrzae}}', + '{{flstkennz}}', + '{{flstnrzae}}', + ], + patternKeys: { + // only capturing group content is used + gemarkung: '([^0-9]+)', + flur: '([0-9]+)', + flstnrzae: '([0-9]+)', + flstnrnen: '([0-9]+)', + flstkennz: '([0-9_]+)$', + }, + }, + placeholder: 'Schiffbek 996', + type: 'wfs', + groupId: 'wfs_search', + label: 'Flurstückssuche', + url: 'https://geodienste.hamburg.de/WFS_HH_ALKIS_vereinfacht', + }, ], minLength: 3, waitMs: 300, @@ -295,6 +326,10 @@ export const mapConfiguration = { fill: '#ff0019', }, }, + routing: { + displayPreferences: true, + displayRouteTypesToAvoid: true, + }, pointerPosition: { projections: [ { code: 'EPSG:4326' }, diff --git a/vue2/packages/clients/snowbox/src/polar-client.ts b/vue2/packages/clients/snowbox/src/polar-client.ts new file mode 100644 index 0000000000..f200f670c7 --- /dev/null +++ b/vue2/packages/clients/snowbox/src/polar-client.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import polarCore from '@polar/core' +// NOTE bad pattern, but probably fine for a test client +import { enableClustering } from '../../meldemichel/src/utils/enableClustering' +import { addPlugins } from './addPlugins' +import { flurstuecke, mapConfiguration, reports } from './mapConfiguration' +import { exampleFeatureInformation } from './exampleFeatureInformation' +import { validateForm } from './validateForm' + +addPlugins(polarCore) + +const createMap = (layerConf) => { + // NOTE This seems to be missing in the layer specs + const flurLayer = layerConf.find(({ id }) => id === flurstuecke) + if (flurLayer) { + flurLayer.crs = 'http://www.opengis.net/def/crs/EPSG/0/25832' + flurLayer.bboxCrs = 'http://www.opengis.net/def/crs/EPSG/0/25832' + } + + polarCore + .createMap({ + containerId: 'polarstern', + mapConfiguration: { + ...mapConfiguration, + layerConf: (enableClustering(layerConf, reports), layerConf), + }, + }) + .then((map) => { + // @ts-expect-error | adding it intentionally for e2e testing + window.mapInstance = map + + const loginButton = document.getElementById( + 'login-button' + ) as HTMLButtonElement + loginButton.onclick = () => + validateForm((token) => map.$store.commit('setOidcToken', token)) + + addStoreSubscriptions( + ['plugin/zoom/zoomLevel', 'vuex-target-zoom'], + [ + 'plugin/gfi/featureInformation', + 'vuex-target-gfi', + (featureInformation) => JSON.stringify(featureInformation, null, 2), + ], + ['plugin/pins/transformedCoordinate', 'vuex-target-pin-coordinate'], + [ + 'plugin/addressSearch/chosenAddress', + 'vuex-target-address-search-result', + (address) => JSON.stringify(address, null, 2), + ], + [ + 'plugin/export/exportedMap', + null, + (screenshot) => + document + .getElementById('vuex-target-export-result')! + .setAttribute('src', screenshot), + ], + [ + 'plugin/draw/featureCollection', + 'vuex-target-draw-result', + (featureCollection) => JSON.stringify(featureCollection, null, 2), + ], + [ + 'plugin/draw/revisedFeatureCollection', + 'vuex-target-draw-revision-result', + (featureCollection) => JSON.stringify(featureCollection, null, 2), + ] + )(map) + }) +} + +const addStoreSubscriptions = + (...subscriptions) => + (map) => + subscriptions.forEach(([actionName, targetId, callback = (x) => x]) => + map.subscribe(actionName, (value) => + targetId + ? (document.getElementById(targetId)!.innerHTML = callback(value)) + : callback(value) + ) + ) + +polarCore.rawLayerList.initializeLayerList( + // using hamburg's service register as an example + 'https://geodienste.hamburg.de/services-internet.json', + createMap +) + +document.getElementById('vuex-target-clicky')!.addEventListener('click', () => + // @ts-expect-error | added for e2e testing + window.mapInstance.$store.dispatch( + 'plugin/gfi/setFeatureInformation', + exampleFeatureInformation + ) +) diff --git a/packages/clients/snowbox/src/style.json b/vue2/packages/clients/snowbox/src/style.json similarity index 100% rename from packages/clients/snowbox/src/style.json rename to vue2/packages/clients/snowbox/src/style.json diff --git a/packages/clients/snowbox/src/validateForm.ts b/vue2/packages/clients/snowbox/src/validateForm.ts similarity index 100% rename from packages/clients/snowbox/src/validateForm.ts rename to vue2/packages/clients/snowbox/src/validateForm.ts diff --git a/vue2/packages/clients/stylePreview/API.md b/vue2/packages/clients/stylePreview/API.md new file mode 100644 index 0000000000..2bd138ed3d --- /dev/null +++ b/vue2/packages/clients/stylePreview/API.md @@ -0,0 +1,104 @@ +# POLAR StylePreview client + +For all additional details, check the [full documentation](https://dataport.github.io/polar/docs/stylePreview/client-stylePreview.html). + +For our example client, [see here](./example/prod-example.html). + +## Setup + +### Start + +Run e.g. the following lines to get the client running: + +```js +import MapClient from "@polar/client-style-preview" + +const servicesUrl = 'https://geodienste.hamburg.de/services-internet.json' + +MapClient.rawLayerList.initializeLayerList(servicesUrl, (layerConf) => + MapClient + .createMap({ + containerId: 'polarstern', + mapConfiguration: { + ...mapConfiguration, // see client docs for full info + layerConf, + }, + }) + .then((mapInstance) => { + // run mapInstance.updateStyles(nextStyle) to update map style; see docs below + }) +) +``` + +### updateStyles + +This expects an object of the following format: + +```json +{ + "point": { /* ... */ }, + "lineString": { /* ... */ }, + "polygon": { /* ... */ }, + "text": { /* ... */ } +} +``` + +The nested objects are the parameter objects to the [OpenLayers Style Class](https://openlayers.org/en/latest/apidoc/module-ol_style_Style-Style.html) with a twist: All class indicators that are met on the way will be constructed. (For implemented cases, that is.) + +For example, + +```json +{ + "polygon": { + "fill": { + "color": "#000000" + } + } +} +``` + +will produce the following style for polygons: + +```js +new Style({ + fill: new Fill({ + color: '#000000' + }) +}) +``` + +The following keys are implemented classes: + +* `Fill` (from `"fill"` key) +* `Stroke` (from `"stroke"` key) +* `Text` (from `"text"` key) + +Sometimes, multiple classes may fit. + +* `"imageCircle"` will create a `Circle` class instance in the `"image"` key +* `"imageIcon"` will create an `Icon` class instance in the `"image"` key + +Also, there are hatches for polygons that can be used in `Fill`. + +```json +{ + "polygon": { + "fill": { + "hatch": { /* hatchParams */ } + } + } +} +``` + +will be turned into + +```js +new Style({ + fill: new Fill({ + // Hatch returns renderable color in OL's sense + color: new Hatch({ /* hatchParams */ }) + }) +}) +``` + +The [hatch parameters](https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev/docs/User/Global-Config/style.json.md#polygonpolygonfillhatch) are defined in the Masterportal documentation. diff --git a/packages/lib/idx/CHANGELOG.md b/vue2/packages/clients/stylePreview/CHANGELOG.md similarity index 100% rename from packages/lib/idx/CHANGELOG.md rename to vue2/packages/clients/stylePreview/CHANGELOG.md diff --git a/packages/clients/meldemichel/LICENSE b/vue2/packages/clients/stylePreview/LICENSE similarity index 100% rename from packages/clients/meldemichel/LICENSE rename to vue2/packages/clients/stylePreview/LICENSE diff --git a/packages/clients/stylePreview/README.md b/vue2/packages/clients/stylePreview/README.md similarity index 100% rename from packages/clients/stylePreview/README.md rename to vue2/packages/clients/stylePreview/README.md diff --git a/packages/clients/stylePreview/example/index.html b/vue2/packages/clients/stylePreview/example/index.html similarity index 100% rename from packages/clients/stylePreview/example/index.html rename to vue2/packages/clients/stylePreview/example/index.html diff --git a/packages/clients/stylePreview/example/polar-example.js b/vue2/packages/clients/stylePreview/example/polar-example.js similarity index 100% rename from packages/clients/stylePreview/example/polar-example.js rename to vue2/packages/clients/stylePreview/example/polar-example.js diff --git a/packages/clients/stylePreview/example/prod-example.html b/vue2/packages/clients/stylePreview/example/prod-example.html similarity index 100% rename from packages/clients/stylePreview/example/prod-example.html rename to vue2/packages/clients/stylePreview/example/prod-example.html diff --git a/packages/clients/stylePreview/example/style.css b/vue2/packages/clients/stylePreview/example/style.css similarity index 100% rename from packages/clients/stylePreview/example/style.css rename to vue2/packages/clients/stylePreview/example/style.css diff --git a/packages/clients/stylePreview/package.json b/vue2/packages/clients/stylePreview/package.json similarity index 100% rename from packages/clients/stylePreview/package.json rename to vue2/packages/clients/stylePreview/package.json diff --git a/packages/clients/stylePreview/src/polar-client.ts b/vue2/packages/clients/stylePreview/src/polar-client.ts similarity index 100% rename from packages/clients/stylePreview/src/polar-client.ts rename to vue2/packages/clients/stylePreview/src/polar-client.ts diff --git a/packages/clients/stylePreview/src/stylePreview/features.ts b/vue2/packages/clients/stylePreview/src/stylePreview/features.ts similarity index 100% rename from packages/clients/stylePreview/src/stylePreview/features.ts rename to vue2/packages/clients/stylePreview/src/stylePreview/features.ts diff --git a/packages/clients/stylePreview/src/stylePreview/index.ts b/vue2/packages/clients/stylePreview/src/stylePreview/index.ts similarity index 100% rename from packages/clients/stylePreview/src/stylePreview/index.ts rename to vue2/packages/clients/stylePreview/src/stylePreview/index.ts diff --git a/packages/clients/stylePreview/src/stylePreview/updatePositions.ts b/vue2/packages/clients/stylePreview/src/stylePreview/updatePositions.ts similarity index 100% rename from packages/clients/stylePreview/src/stylePreview/updatePositions.ts rename to vue2/packages/clients/stylePreview/src/stylePreview/updatePositions.ts diff --git a/packages/clients/stylePreview/src/stylePreview/updateStyles.ts b/vue2/packages/clients/stylePreview/src/stylePreview/updateStyles.ts similarity index 100% rename from packages/clients/stylePreview/src/stylePreview/updateStyles.ts rename to vue2/packages/clients/stylePreview/src/stylePreview/updateStyles.ts diff --git a/packages/clients/stylePreview/vite.config.js b/vue2/packages/clients/stylePreview/vite.config.js similarity index 100% rename from packages/clients/stylePreview/vite.config.js rename to vue2/packages/clients/stylePreview/vite.config.js diff --git a/packages/clients/textLocator/API.md b/vue2/packages/clients/textLocator/API.md similarity index 100% rename from packages/clients/textLocator/API.md rename to vue2/packages/clients/textLocator/API.md diff --git a/packages/clients/textLocator/CHANGELOG.md b/vue2/packages/clients/textLocator/CHANGELOG.md similarity index 100% rename from packages/clients/textLocator/CHANGELOG.md rename to vue2/packages/clients/textLocator/CHANGELOG.md diff --git a/packages/clients/snowbox/LICENSE b/vue2/packages/clients/textLocator/LICENSE similarity index 100% rename from packages/clients/snowbox/LICENSE rename to vue2/packages/clients/textLocator/LICENSE diff --git a/packages/clients/textLocator/README.md b/vue2/packages/clients/textLocator/README.md similarity index 100% rename from packages/clients/textLocator/README.md rename to vue2/packages/clients/textLocator/README.md diff --git a/packages/clients/textLocator/package.json b/vue2/packages/clients/textLocator/package.json similarity index 100% rename from packages/clients/textLocator/package.json rename to vue2/packages/clients/textLocator/package.json diff --git a/packages/clients/textLocator/src/addPlugins.ts b/vue2/packages/clients/textLocator/src/addPlugins.ts similarity index 100% rename from packages/clients/textLocator/src/addPlugins.ts rename to vue2/packages/clients/textLocator/src/addPlugins.ts diff --git a/packages/clients/textLocator/src/components/ResultInfo.vue b/vue2/packages/clients/textLocator/src/components/ResultInfo.vue similarity index 100% rename from packages/clients/textLocator/src/components/ResultInfo.vue rename to vue2/packages/clients/textLocator/src/components/ResultInfo.vue diff --git a/packages/clients/textLocator/src/html/index.html b/vue2/packages/clients/textLocator/src/html/index.html similarity index 100% rename from packages/clients/textLocator/src/html/index.html rename to vue2/packages/clients/textLocator/src/html/index.html diff --git a/packages/clients/textLocator/src/html/index.js b/vue2/packages/clients/textLocator/src/html/index.js similarity index 100% rename from packages/clients/textLocator/src/html/index.js rename to vue2/packages/clients/textLocator/src/html/index.js diff --git a/packages/clients/textLocator/src/index.html b/vue2/packages/clients/textLocator/src/index.html similarity index 100% rename from packages/clients/textLocator/src/index.html rename to vue2/packages/clients/textLocator/src/index.html diff --git a/packages/clients/textLocator/src/locales.ts b/vue2/packages/clients/textLocator/src/locales.ts similarity index 100% rename from packages/clients/textLocator/src/locales.ts rename to vue2/packages/clients/textLocator/src/locales.ts diff --git a/packages/clients/textLocator/src/mapConfig.ts b/vue2/packages/clients/textLocator/src/mapConfig.ts similarity index 100% rename from packages/clients/textLocator/src/mapConfig.ts rename to vue2/packages/clients/textLocator/src/mapConfig.ts diff --git a/packages/clients/textLocator/src/palettes.ts b/vue2/packages/clients/textLocator/src/palettes.ts similarity index 100% rename from packages/clients/textLocator/src/palettes.ts rename to vue2/packages/clients/textLocator/src/palettes.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/Action.vue b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/Action.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/Action.vue rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/Action.vue diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/DrawMode.vue b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/DrawMode.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/DrawMode.vue rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/DrawMode.vue diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/GeometrySearch.vue b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/GeometrySearch.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/GeometrySearch.vue rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/GeometrySearch.vue diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/Tree.vue b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/Tree.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/Tree.vue rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/Tree.vue diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/ViewToggle.vue b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/ViewToggle.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/ViewToggle.vue rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/ViewToggle.vue diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/components/index.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/index.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/components/index.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/components/index.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/index.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/index.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/index.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/index.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/locales.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/locales.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/locales.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/locales.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts similarity index 79% rename from packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts index 34a2d0f7af..f552b36c84 100644 --- a/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts +++ b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupDrawReaction.ts @@ -1,17 +1,10 @@ -import { - PolarActionContext, - PolarActionHandler, - PolarStore, -} from '@polar/lib-custom-types' +import { PolarActionContext, PolarStore } from '@polar/lib-custom-types' import debounce from 'lodash.debounce' import { Feature } from 'ol' import VectorSource, { VectorSourceEvent } from 'ol/source/Vector' import { GeometrySearchGetters, GeometrySearchState } from '../../types' -let debouncedSearchGeometry: PolarActionHandler< - GeometrySearchState, - GeometrySearchGetters -> +let debouncedSearchGeometry export function setupDrawReaction( this: PolarStore, @@ -35,7 +28,6 @@ export function setupDrawReaction( lastFeature = nextFeature drawSource.clear() drawSource.addFeature(nextFeature) - // @ts-expect-error | The function is bound and hence already has context. debouncedSearchGeometry(nextFeature) } }) diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupTooltip.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupTooltip.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupTooltip.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/setupTooltip.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/updateFrequencies.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/updateFrequencies.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/updateFrequencies.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/actions/updateFrequencies.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/store/index.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/index.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/store/index.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/store/index.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/types.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/types.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/types.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/types.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/utils/makeTreeView.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/makeTreeView.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/utils/makeTreeView.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/makeTreeView.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorDisplay.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorDisplay.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorDisplay.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorDisplay.ts diff --git a/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorStyles.ts b/vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorStyles.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorStyles.ts rename to vue2/packages/clients/textLocator/src/plugins/GeometrySearch/utils/vectorStyles.ts diff --git a/packages/clients/textLocator/src/plugins/Header/Header.vue b/vue2/packages/clients/textLocator/src/plugins/Header/Header.vue similarity index 100% rename from packages/clients/textLocator/src/plugins/Header/Header.vue rename to vue2/packages/clients/textLocator/src/plugins/Header/Header.vue diff --git a/packages/clients/textLocator/src/plugins/Header/index.ts b/vue2/packages/clients/textLocator/src/plugins/Header/index.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/Header/index.ts rename to vue2/packages/clients/textLocator/src/plugins/Header/index.ts diff --git a/packages/clients/textLocator/src/plugins/Header/locales.ts b/vue2/packages/clients/textLocator/src/plugins/Header/locales.ts similarity index 100% rename from packages/clients/textLocator/src/plugins/Header/locales.ts rename to vue2/packages/clients/textLocator/src/plugins/Header/locales.ts diff --git a/packages/clients/textLocator/src/polar-client.ts b/vue2/packages/clients/textLocator/src/polar-client.ts similarity index 100% rename from packages/clients/textLocator/src/polar-client.ts rename to vue2/packages/clients/textLocator/src/polar-client.ts diff --git a/packages/clients/textLocator/src/services.ts b/vue2/packages/clients/textLocator/src/services.ts similarity index 100% rename from packages/clients/textLocator/src/services.ts rename to vue2/packages/clients/textLocator/src/services.ts diff --git a/packages/clients/textLocator/src/styles.css b/vue2/packages/clients/textLocator/src/styles.css similarity index 100% rename from packages/clients/textLocator/src/styles.css rename to vue2/packages/clients/textLocator/src/styles.css diff --git a/packages/clients/textLocator/src/types.ts b/vue2/packages/clients/textLocator/src/types.ts similarity index 100% rename from packages/clients/textLocator/src/types.ts rename to vue2/packages/clients/textLocator/src/types.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/common.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/common.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/common.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/common.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/getAllPages.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/getAllPages.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/getAllPages.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/getAllPages.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/getPrimaryName.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/getPrimaryName.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/getPrimaryName.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/getPrimaryName.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/makeRequestBody.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/makeRequestBody.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/makeRequestBody.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/makeRequestBody.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/responseInterpreter.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/responseInterpreter.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/responseInterpreter.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/responseInterpreter.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/searchGeometry.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/searchGeometry.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/searchGeometry.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/searchGeometry.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/searchToponym.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/searchToponym.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/searchToponym.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/searchToponym.ts diff --git a/packages/clients/textLocator/src/utils/coastalGazetteer/types.ts b/vue2/packages/clients/textLocator/src/utils/coastalGazetteer/types.ts similarity index 100% rename from packages/clients/textLocator/src/utils/coastalGazetteer/types.ts rename to vue2/packages/clients/textLocator/src/utils/coastalGazetteer/types.ts diff --git a/packages/clients/textLocator/src/utils/common.ts b/vue2/packages/clients/textLocator/src/utils/common.ts similarity index 100% rename from packages/clients/textLocator/src/utils/common.ts rename to vue2/packages/clients/textLocator/src/utils/common.ts diff --git a/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/searchLiterature.ts b/vue2/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/searchLiterature.ts similarity index 100% rename from packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/searchLiterature.ts rename to vue2/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/searchLiterature.ts diff --git a/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/selectLiterature.ts b/vue2/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/selectLiterature.ts similarity index 100% rename from packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/selectLiterature.ts rename to vue2/packages/clients/textLocator/src/utils/textLocatorBackend/findLiterature/selectLiterature.ts diff --git a/packages/clients/textLocator/src/utils/textLocatorBackend/literatureByToponym.ts b/vue2/packages/clients/textLocator/src/utils/textLocatorBackend/literatureByToponym.ts similarity index 100% rename from packages/clients/textLocator/src/utils/textLocatorBackend/literatureByToponym.ts rename to vue2/packages/clients/textLocator/src/utils/textLocatorBackend/literatureByToponym.ts diff --git a/packages/clients/textLocator/src/utils/textLocatorBackend/toponymByLiterature.ts b/vue2/packages/clients/textLocator/src/utils/textLocatorBackend/toponymByLiterature.ts similarity index 100% rename from packages/clients/textLocator/src/utils/textLocatorBackend/toponymByLiterature.ts rename to vue2/packages/clients/textLocator/src/utils/textLocatorBackend/toponymByLiterature.ts diff --git a/packages/clients/textLocator/src/utils/textLocatorBackend/urlSuffix.ts b/vue2/packages/clients/textLocator/src/utils/textLocatorBackend/urlSuffix.ts similarity index 100% rename from packages/clients/textLocator/src/utils/textLocatorBackend/urlSuffix.ts rename to vue2/packages/clients/textLocator/src/utils/textLocatorBackend/urlSuffix.ts diff --git a/packages/clients/textLocator/vite.config.js b/vue2/packages/clients/textLocator/vite.config.js similarity index 100% rename from packages/clients/textLocator/vite.config.js rename to vue2/packages/clients/textLocator/vite.config.js diff --git a/vue2/packages/core/README.md b/vue2/packages/core/README.md new file mode 100644 index 0000000000..025888033d --- /dev/null +++ b/vue2/packages/core/README.md @@ -0,0 +1,253 @@ +# Core + +## Scope + +The client's core is the base package to create clients in the POLAR environment. + +It offers this functionality: + +- Plugin mechanism +- @masterportal/masterportalapi functionality +- Localization mechanism + +## Interaction + +If a client is rendered as part of another page, the zoom and drag-pan behaviour is different to if the client is rendered as complete page. +If it's part of another page, drag-panning on mobile devices is only usable if at least two fingers are being used while on desktop clients the user can only zoom if using the respective platform modifier key (e.g. CTRL). + +It is important to note that the behaviour will be desktop-like on larger touchscreen devices (e.g. tablets). + +## Initialization / Configuration + +It depends on the client how exactly the initialization will take place for the embedding programmer. However, the core mechanism remains the same. + +The exported default object is an extended masterportalapi, adding the `addPlugins` and extending the `createMap` functions. For masterportalapi details, [see their repository](https://bitbucket.org/geowerkstatt-hamburg/masterportalapi/src/master/). + +#### mapConfiguration + +| fieldName | type | description | +| - | - | - | +| <...masterportalapi.fields> | various | Multiple different parameters are required by the masterportalapi to be able to create the map. Also, some fields are optional but relevant and thus described here as well. For all additional options, refer to the documentation of the masterportalapi itself. | +| | various? | Fields for configuring plugins added with `addPlugins`. Refer to each plugin's documentation for specific fields and options. Global plugin parameters are described [below](#global-plugin-parameters). | + +
    +Example configuration + +```ts +import locales from './locales' + +const mapConfiguration = { + stylePath: '../dist/polar-client.css', + checkServiceAvailability: true, + language: 'de', + locales, // the locales object will normally be outsourced to another file + layerConf, // the layerConf object will normally be outsourced to another file - for more information, see the relevant chapter + layers: [ + // parts of the layer configuration can be outsourced to another file + // and referenced in the mapConfiguration by id like the second layer + { + id: 'backgroundmap', + name: 'WMS DE BASEMAP.DE WEB RASTER', + url: 'https://sgx.geodatenzentrum.de/wms_basemapde', + typ: 'WMS', + layers: 'de_basemapde_web_raster_grau', + format: 'image/png', + version: '1.3.0', + singleTile: false, + transparent: true, + }, + { + id: '1561', + visibility: true, + type: 'mask', + name: 'Building Plans', + minZoom: 2, + }, + ], + addressSearch: { + displayComponent: false, + }, + export: { + showPdf: false, + }, + ... +} +``` + +
    + +##### mapConfiguration.featureStyles + +Vector Layers (GeoJSON and WFS) can also be styled on the client side. +Configuration and implementation is based on [style.json](https://bitbucket.org/geowerkstatt-hamburg/masterportal/src/dev_vue/docs/User/Global-Config/style.json.md) of `@masterportal/masterportalapi`. +For the full documentation, including all rules that are applied when parsing the configuration, see the above linked documentation. + +Example styling some points of a layer gray that have the value `food` in the property `not bamboo`. +All Other features will use the provided default green styling. +A Layer needs to use the property `styleId` in its `mapConfiguration.layers` entry and set it to `panda` to use this styling. + +```json +[ + { + "styleId": "panda", + "rules": [ + { + "conditions": { + "properties": { + "food": "bamboo" + } + }, + "style": { + "circleStrokeColor": [3, 255, 1, 1], + "circleFillColor": [3, 255, 1, 1] + } + }, + { + "style": { + "circleStrokeColor": [128, 128, 128, 1], + "circleFillColor": [128, 128, 128, 1] + } + } + ] + } +] +``` + +##### mapConfiguration.layerConf + +The layer configuration (or: service register) is read by the `@masterportal/masterportalapi`. + +###### Example services register + +```json +[ + { + "id": "my-wfs-id", + "name": "Service name", + "url": "Service url", + "typ": "WFS", + "outputFormat": "XML", + "version": "1.1.0", + "featureType": "ns:featureType" + }, + { + "id": "my-wms-id", + "name": "Service name", + "url": "Service url", + "typ": "WMS", + "format": "image/png", + "version": "1.3.0", + "transparent": true, + "layers": ["A", "B"] + }, + { + "id": "my-self-defined-wmts", + "urls": [ + "url1/{TileMatrix}/{TileCol}/{TileRow}.png", + "url2/{TileMatrix}/{TileCol}/{TileRow}.png", + "url3/{TileMatrix}/{TileCol}/{TileRow}.png" + ], + "typ": "WMTS", + "format": "image/png", + "coordinateSystem": "EPSG:3857", + "origin": [-20037508.3428, 20037508.3428], + "transparent": false, + "tileSize": "256", + "minScale": "1", + "maxScale": "2500000", + "tileMatrixSet": "google3857", + "requestEncoding": "REST", + "resLength": "20" + }, + { + "id": "my-capabilities-wmts", + "capabilitiesUrl": "WMTS capabilities url", + "urls": "WMTS url", + "optionsFromCapabilities": true, + "tileMatrixSet": "EU_EPSG_25832_TOPPLUS", + "typ": "WMTS", + "layers": "layer-name", + "legendURL": "my-legend-url" + }, + { + "id": "oaf", + "typ": "OAF", + "name": "My OAF", + "url": "https://api.hamburg.de/datasets/v1/stadtgruen", + "collection": "poi", + "crs": "http://www.opengis.net/def/crs/EPSG/0/25832", + "bboxCrs": "http://www.opengis.net/def/crs/EPSG/0/25832", + "gfiTheme": "default", + }, + { + "id": "my-geojson", + "name": "My GeoJSON data", + "url": "Service url", + "typ": "GeoJSON", + "version": "1.0", + "minScale": "0", + "maxScale": "2500000", + "legendURL": "" + } +] +``` + +Since this is the base for many functions, the service id set in this is used to reference map material in many places of the map client. + +##### + +Plugins in POLAR are modular components that extend the functionality of the map client. They can be added using the [addPlugins](#addplugins) method and configured through the `mapConfiguration` object. Each plugin has its own set of fields and options that can be customized. + +On how to configure a plugin, see the respective plugin. The configuration is given in the `mapConfiguration` object by the plugin's name as specified in its respective documentation. + +###### Global Plugin Parameters + +Most plugins honor this additional field. + +| fieldName | type | description | +| - | - | - | +| displayComponent | boolean? | Optional field that allows hiding UI elements from the user. The store will still be initialized, allowing you to add your own UI elements and control the plugin's functionality via the Store. This may or may not make sense, depending on the plugin. Defaults to `false` , meaning the default UI is hidden. | + +###### Example Configuration + +For example, a `@polar/plugin-address-search` plugin can be configured like this: + +```js +{ + addressSearch: { + // Plugin-specific configuration + displayComponent: true, // Optional field to control UI elements + // ... + } +} +``` + +### Mutations + +#### setOidcToken + +```js +map.$store.commit('setOidcToken', 'base64encodedOIDCtoken') +``` + +Calling the mutation `setOidcToken` adds the given Base64-encoded OIDC token to the store. +If the configuration parameter `secureServiceUrlRegex` is set, the token will be sent as a Bearer token in the Authorization header of all requests to URLs that match the regular expression. + +### Getters + +You may desire to listen to whether the loader is currently being shown. + +| fieldName | type | description | +| - | - | - | +| map | Map \| null | Returns the openlayers map object. | +| hovered | Feature \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the currently hovered marker. Please mind that it may be a clustered feature. | +| selected | Feature \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the currently selected marker. Please mind that it may be a clustered feature. | +| selectedCoordinates | Array \| null | If `useExtendedMasterportalApiMarkers` is active, this will return the coordinates of the currently selected marker. | + +## Special Flags + +POLAR uses flags on some OL elements to handle overarching issues. Those flags can be retrieved with `olThing.get('_flagName')`. These flags must not be specific to a plugin and must provide documentation in this place. They may yield uses outside of the POLAR application when further building upon the clients or creating new plugins. + +| flagName | type | description | +| - | - | - | +| _isPolarDragLikeInteraction | true | This flag is either `true` or absent. It must be present on drag-like interactions with the map to provide information to the core on when to display the map pan instructions on mobile devices. The instructions will not be shown if a single interaction with this flag is found, assuming that the interaction takes precendence over scrolling the page. | diff --git a/vue2/packages/plugins/Draw/CHANGELOG.md b/vue2/packages/plugins/Draw/CHANGELOG.md new file mode 100644 index 0000000000..b9010594a7 --- /dev/null +++ b/vue2/packages/plugins/Draw/CHANGELOG.md @@ -0,0 +1,50 @@ +# CHANGELOG + +## 3.2.0 + +- Feature: Add new `revision` parameter that, if set, adds another export with modifications and additional information to the drawn geometries. +- Feature: Add new mode `'cut'` to allow users to cut polygons in parts. +- Feature: Add new mode `'merge'` to allow users to merge drawn polygons with a then-to-be-drawn merge polygon. +- Feature: Add new mode `'duplicate'` to allow users to copy drawn features. +- Fix: Measurements were off. The calculation has been fixed. +- Chore: Updated the README.md's state description to be in table format. + +## 3.1.0 + +- Feature: Add a `"translate"` mode that allows moving drawn features as they are. +- Feature: Add a `"snapTo"` key to the configuration that allows defining vector layers to snap to while drawing, editing, and translating. Snapping will only pertain to configured layers while they're visible. +- Feature: Add a lasso mode that allows copying up features from a vector layer that are contained within the user's hand drawn polygon. This also adds the fields `addLoading`, `removeLoading`, and `toastAction` for usage in the `lassos`. +- Feature: Interactions requiring dragging are now marked with the POLAR flag `_isPolarDragLikeInteraction`. +- Feature: Show a "pointer" cursor when the delete interaction would delete a feature on click. +- Feature: An action `setInteractions` has been added to allow clients to bring their own geometry operations. +- Feature: Expose the `getSnaps` function. Intended to be used together with the `setInteractions` action only. +- Feature: Expose `Mode` type of Draw plugin for client-side extensions. +- Fix: Configured font colours were not used on Text draw. This has been resolved. +- Fix: Make `setMode` and `setDrawMode` `async` actions to prevent race conditions if they are called in short succession. + +## 3.0.0 + +- Breaking: Upgrade peerDependency `ol` from `^9.2.4` to `^10.3.1`. +- Feature: Add new configuration parameter `measureOptions` to allow users to select a measurement mode when drawing a feature. This way, a length / area in the selected unit is added to the drawn feature. +- Fix: Update initial value of `drawMode` to a selectable value if the default `Point` is not a drawable option. +- Fix: Adjust type `DrawGetters` regarding its keys `selectableDrawModes` and `selectableModes` to correctly reflect that they represent objects. +- Fix: Stacked geometries can be separated with the "Edit" operation again. [Thanks to mike-000](https://github.com/openlayers/openlayers/issues/16593#issuecomment-2624257614). +- Chore: Add `@polar/core` as a dependency as the component `RadioCard.vue` has been moved from this package to `@polar/core`. + +## 2.0.0 + +- Breaking: Upgrade peerDependency `ol` from `^7.1.0` to `^9.2.4`. +- Fix: Adjust documentation to properly describe optionality of configuration parameters. +- Feature: Make the stroke color for drawn geometry features selectable and editable. + +## 1.1.0 + +- Feature: Improved implementation to make plugin SPA-ready. + +## 1.0.1 + +- Fix: Documentation error regarding plugin state. + +## 1.0.0 + +Initial release. diff --git a/packages/clients/stylePreview/LICENSE b/vue2/packages/plugins/Draw/LICENSE similarity index 100% rename from packages/clients/stylePreview/LICENSE rename to vue2/packages/plugins/Draw/LICENSE diff --git a/vue2/packages/plugins/Draw/README.md b/vue2/packages/plugins/Draw/README.md new file mode 100644 index 0000000000..53c6ed7adc --- /dev/null +++ b/vue2/packages/plugins/Draw/README.md @@ -0,0 +1,323 @@ +# Draw + +## Scope + +The draw plugin allows users to draw features on the map. Drawn features may be edited and deleted. + +Currently supported OpenLayers geometry types: + +- `'Circle''` +- `'LineString'` +- `'Point'` +- `'Polygon'` + +Also, `'Text'` is supported which is modeled as an OpenLayers `'Point'`. This is no default feature, so it must be specified in the configuration to use it. + +## User instructions for Text Mode + +The interaction with text features is not intuitive, which is why the text feature should come with instructions for the users: + +### Edit + +To edit the text or the placement of the text feature, the user must click on the center of the text to select the point geometry below it. After selecting it, the user can move the point by keeping the left mouse button pressed, or edit the text in the input field that opens with selecting the feature. If more than one text size is specified in the configuration, the user can change the text size with a slider. + +### Delete + +To delete the text, the user must either click on the point at the center of the text or use CTRL + left mouse button to open a box over all features that he or she wants to delete. + +## Configuration + +The styling of the drawn features can be configured to overwrite the default ol-style. Configuration is oriented around the [OpenLayers styles](https://openlayers.org/en/latest/apidoc/module-ol_style_Style.html#~StyleLike). + +### draw + +| fieldName | type | description | +| - | - | - | +| addLoading | string? | Expects the path to a mutation within the store. This mutation is committed with a plugin-specific loading key as payload when starting asynchronous procedures that are intended to be communicated to the user. | +| enableOptions | boolean? | If `true`, draw options are displayed, like choosing and changing stroke color. Not available for texts features. Defaults to `false`. | +| lassos | lasso[]? | Allows configuring lasso options. The lasso function allows free-hand drawing a geometry on the map; features completely fitting into that geometry will be copied up to the draw layer from all configured layers. UI-wise, it is not intuitive for users do understand what a "Lasso" does. This feature currently requires further instructions by the outlying UI on what one is supposed to do with it. | +| removeLoading | string? | Expects the path to a mutation within the store. This mutation is committed with a plugin-specific loading key as payload when finishing asynchronous procedures that are intended to be communicated to the user. | +| measureOptions | measureOptions? | If set, an additional radio is being shown to the user to be able to let the (then) drawn features display their length and / or area. See [draw.measureOptions](#drawmeasureoptions) for further information. Not shown by default. | +| revision | revision? | If set, a modified copy of the drawn features is provided as export with configurable properties. | +| selectableDrawModes | string[]? | List 'Point', 'LineString', 'Circle', 'Text' and/or 'Polygon' as desired. All besides 'Text' are selectable by default. | +| snapTo | string[]? | Accepts an array of layer IDs. If these layers are active, they are used as snapping material for geometry manipulation. The Draw layer will also always snap to its own features regardless. Please mind that used layers must provide vector data. The layers referred to must be configured in `mapConfiguration.layers`. | +| style | style? | Please see example below for styling options. Defaults to standard OpenLayers styling. | +| textStyle | textStyle? | Use this object with properties 'font' and 'textColor' to style text feature. | +| toastAction | string? | This string will be used as action to send a toast information to the user to clarify why something happened in edge cases. If this is not defined, the information will only be printed to the console for debugging purposes instead. | + +For details on the `displayComponent` attribute, refer to the [Global Plugin Parameters](../../core/README.md#global-plugin-parameters) section of `@polar/core`. + +
    +Example configuration + +```js +draw: { + selectableDrawModes: ['Circle', 'LineString', 'Point', 'Polygon', 'Text'], + textStyle: { + font: { + size: [10, 20, 30], + family: 'Arial', + }, + }, + style: { + fill: { + color: 'rgba(255, 255, 255, 0.5)' + }, + stroke: { + color: '#e51313', + width: 2, + }, + circle: { + radius: 7, + fillColor: '#e51313', + }, + }, +}, +``` + +
    + +#### draw.lasso + +| fieldName | type | description | +| - | - | - | +| id | string | The layer id of a vector layer to copy up vector features from. | +| minZoom | boolean | Defaults to `true`. If a boolean is given, the `minZoom` (if configured) of the `LayerConfiguration` in `mapConfiguration.layers` will be adhered to when copying up geometries from the source. This is to prevent the client from overly burdening feature-rich vector services on accident. | + +#### draw.measureOptions + +| fieldName | type | description | +| - | - | - | +| metres | boolean? | Whether to show the measure option `'m / m²'` to the user. `false` by default. | +| kilometres | boolean? | Whether to show the measure option `'km / km²'` to the user. `false` by default. | +| hectares | boolean? | Whether to show the measure option `'km / ha‚'` to the user. `false` by default. | +| initialOption | 'none' \| 'meters' \| 'kilometres' \| 'hectares' | The initial measure option to be selected. Defaults to `'none'`. | + +#### draw.textStyle + +| fieldName | type | description | +| - | - | - | +| font | object \| string | Style the font of the text feature with either css font properties or use font as an object with properties 'size' and 'family'. | +| textColor | string? | Define text color in hex or rgb / rgba code. | + +Example configuration: +```js +textStyle: { + font: '16px sans-serif' + textColor: '#e51313' +} +``` + +##### draw.textStyle.font + +| fieldName | type | description | +| - | - | - | +| family | string? | Font family. | +| size | number[]? | Array with numbers that define the available text sizes that a user can choose from | + +Example configuration: +```js +font: { + size: [10.5, 20, 30.5, 35], + family: 'serif' +}, +``` + +#### draw.revision + +| fieldName | type | description | +| - | - | - | +| autofix | boolean? | If `true`, an automatic attempt at repairing the given geometries is executed regarding fulfillment of the OGC Simple Feature Specification (part of [SFA](https://www.ogc.org/de/publications/standard/sfa/)). Defaults to `false`. | +| mergeToMultiGeometries | boolean? | Defaults to `false`. If `true`, the exported FeatureCollection in getter `revisedFeatureCollection` will have merged geometries; that is, instead of Points, Lines, and Polygons, only MultiPoints, MultiLines, and MultiPolygons will be featured, created by merging the features of their respective geometry. All geometry types that are enabled may occur. This step is executed before geometry validation and meta service usage. | +| metaServices | metaService[]? | Specification of meta services that are requested with the spatial position of each geometry. | +| validate | boolean? | If `true`, a `sfaValidity` flag is added to each feature's attributes that indicates whether the geometry is valid respective the OGC Simple Feature Specification (part of [SFA](https://www.ogc.org/de/publications/standard/sfa/)). This will override any other `sfaValidity` property. Defaults to `false`. | + +#### draw.revision.metaService + +| fieldName | type | description | +| - | - | - | +| id | string | Id of the vector layer to make use of in the meta service. | +| aggregationMode | enum['unequal', 'all']? | Defaults to `'unequal'`. In mode `'unequal'`, one of each property set is kept; duplicate property sets are dropped. In mode `'all'`, all property sets are kept without further filtering. | +| propertyNames | string[]? | Names of the properties to build aggregations from. If left undefined, all found properties will be used. | + +From all geometries of the service intersecting our geometries, properties are aggregated. + +Example: Our drawing feature touches these features in the layer with id `"metaSourceExampleId"`: + +```json +{ + "type": "Feature", + "geometry": "...", + "properties": { "a": 0, "b": 0 } +}, +{ + "type": "Feature", + "geometry": "...", + "properties": { "a": 0, "b": 1 } +}, +{ + "type": "Feature", + "geometry": "...", + "properties": { "a": 0, "b": 1 } +}, +{ + "type": "Feature", + "geometry": "...", + "properties": { "a": 1, "b": 1 } +} +``` + +The feature will then have the following properties: + +In mode `'unequal'`: + +```json +{ + "type": "Feature", + "geometry": "...", + "properties": { + "metaProperties": { + "metaSourceExampleId": [ + { "a": 0, "b": 0 }, + { "a": 0, "b": 1 }, + { "a": 1, "b": 1 } + ] + } + } +} +``` + +In mode `'all'`: + +```json +{ + "type": "Feature", + "geometry": "...", + "properties": { + "metaProperties": { + "metaSourceExampleId": [ + { "a": 0, "b": 0 }, + { "a": 0, "b": 1 }, + { "a": 0, "b": 1 }, + { "a": 1, "b": 1 } + ] + } + } +} +``` + +#### draw.style (by example) + +The `@masterportal/masterportalapi` has vectorStyles in development. As soon as that's done, we shall use its styling syntax and methods. + +For the time being, please use this example as a rough reference as to what can currently be done. + +```js +{ + draw: { + enableOptions: true, + style: { + fill: { + color: 'rgba(255, 255, 255, 0.5)' + }, + stroke: { + color: '#e51313', + width: 2 + }, + circle: { + radius: 7, + fillColor: '#e51313' + }, + // Styling for text of measurements; supports everything described at https://openlayers.org/en/v9.2.4/apidoc/module-ol_style_Text-Text.html + measure: { + font: '16px sans-serif', + placement: 'line', + fill: new Fill({ color: 'black' }), + stroke: new Stroke({ color: 'black' }), + offsetY: -5 + } + } + } +} +``` + +## Store + +### State + +| fieldName | type | description | +| - | - | - | +| `'plugin/draw/featureCollection'` | FeatureCollection | A [GeoJSON](https://geojson.org/) FeatureCollection of all drawn features (including possible measurements in meters with two decimals precision). It updates on changes. | +| `'plugin/draw/revisedFeatureCollection'` | FeatureCollection | A [GeoJSON](https://geojson.org/) FeatureCollection after the `draw.revision` configuration has been applied. If it is not set, this FeatureCollection will stay empty. | +| `'plugin/draw/featureCollectionRevisionState'` | enum['inactive', 'inProgress', 'complete', 'error'] | An indicator for asynchronous revisions. If `draw.revision` is not set, this will stay `'inactive'`. While an asynchronous operation is running, it is `'inProgress'`, after that `'finished'`. If the revision failed for any reason, an `'error'` flag is set. | + +```js +map.subscribe('plugin/draw/featureCollection', (featureCollection) => { + /* Your code. */ +}) +``` + +### Actions + +#### addFeatures + +```js +map.$store.dispatch('plugin/draw/addFeatures', { + geoJSON: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [484000, 5885000], + }, + }, + ], + }, + overwrite: true, // defaults to false +}) +``` + +Calling the action `addFeatures` expects an object containing the parameter `geoJSON`, which is a [GeoJSON](https://geojson.org/) FeatureCollection. +It adds the given features from the FeatureCollection to the drawn source. It is also possible to completely overwrite them using the parameter `overwrite`. + +It's important to note that the GeoJSON Standard [RFC7946](https://www.rfc-editor.org/rfc/rfc7946) does not support circles. +To add a circle to the map, it is assumed, that a feature being a circle has a property `radius` together with a point geometry. + +#### setInteractions + +>⚠️ This is a complex action and can not be used without further implementation in a client. + +```js +// `yourInteractions` is of type `ol/interaction[]` +map.$store.dispatch('plugin/draw/setInteractions', yourInteractions) +``` + +Allows interactions of other sources to take precedence without overlapping with the Draw interactions. By using this action, it is ensured both the draw interactions are cleared and the outside interactions are clearable by the draw tool. With this, you may write client-specific geometry operations of arbitrarily specific nature. Please mind that the Draw tool will display that no draw mode is active during this time, requiring you to provide a different UI. + +When setting drag-like interactions, add `_isPolarDragLikeInteraction` to the interaction. Regarding this, refer to the chapter "Special Flags" in the core documentation. + +If you need additional code executed on removal, you may add a method to `yourInteraction._onRemove`. It will be called on clean-up without parameters. + +#### zoomToFeature + +```js +map.$store.dispatch('plugin/draw/zoomToFeature', { + index: 42, // defaults to 0 + margin: 420, // defaults to 20 +}) +``` + +Calling the action `zoomToFeature` zooms to the feature with position `index`, if given, and fits the map view around it with a padding of size `margin` in every direction of the feature. + +#### zoomToAllFeatures + +```js +map.$store.dispatch('plugin/draw/zoomToAllFeatures', { + margin: 420, // defaults to 20 +}) +``` + +Calling the action `zoomToFeature` zooms to all drawn features, fits them in the map view with a padding of size `margin` in every direction of the features. diff --git a/vue2/packages/plugins/Draw/package.json b/vue2/packages/plugins/Draw/package.json new file mode 100644 index 0000000000..056aa2c228 --- /dev/null +++ b/vue2/packages/plugins/Draw/package.json @@ -0,0 +1,53 @@ +{ + "name": "@polar/plugin-draw", + "version": "3.2.0", + "description": "Draw plugin for POLAR that adds draw interactions to the map, allowing users to place various shapes and texts.", + "keywords": [ + "OpenLayers", + "ol", + "POLAR", + "plugin", + "Draw", + "interaction" + ], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "main": "src/index.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Dataport/polar.git", + "directory": "packages/plugins/Draw" + }, + "files": [ + "src/**/*", + "CHANGELOG.md" + ], + "dependencies": { + "@turf/boolean-contains": "^7.2.0", + "@turf/boolean-intersects": "^7.2.0", + "@turf/boolean-point-in-polygon": "^7.2.0", + "@turf/boolean-valid": "^7.2.0", + "@turf/buffer": "^7.2.0", + "@turf/center-of-mass": "^7.2.0", + "@turf/clean-coords": "^7.2.0", + "@turf/difference": "^7.2.0", + "@turf/helpers": "^7.2.0", + "@turf/line-intersect": "^7.2.0", + "@turf/union": "^7.2.0", + "@turf/unkink-polygon": "^7.2.0", + "@polar/lib-get-features": "^3.0.0" + }, + "peerDependencies": { + "@masterportal/masterportalapi": "2.48.0", + "@polar/core": "^3.2.1", + "@repositoryname/vuex-generators": "^1.1.2", + "i18next": "^23.11.5", + "ol": "^10.4.0", + "vue": "^2.6.14", + "vuex": "^3.6.2" + }, + "devDependencies": { + "@polar/lib-custom-types": "^2.2.0" + } +} diff --git a/packages/plugins/Draw/src/components/Draw.vue b/vue2/packages/plugins/Draw/src/components/Draw.vue similarity index 100% rename from packages/plugins/Draw/src/components/Draw.vue rename to vue2/packages/plugins/Draw/src/components/Draw.vue diff --git a/packages/plugins/Draw/src/components/DrawOptions.vue b/vue2/packages/plugins/Draw/src/components/DrawOptions.vue similarity index 100% rename from packages/plugins/Draw/src/components/DrawOptions.vue rename to vue2/packages/plugins/Draw/src/components/DrawOptions.vue diff --git a/packages/plugins/Draw/src/components/index.ts b/vue2/packages/plugins/Draw/src/components/index.ts similarity index 100% rename from packages/plugins/Draw/src/components/index.ts rename to vue2/packages/plugins/Draw/src/components/index.ts diff --git a/packages/plugins/Draw/src/index.ts b/vue2/packages/plugins/Draw/src/index.ts similarity index 100% rename from packages/plugins/Draw/src/index.ts rename to vue2/packages/plugins/Draw/src/index.ts diff --git a/packages/plugins/Draw/src/locales.ts b/vue2/packages/plugins/Draw/src/locales.ts similarity index 100% rename from packages/plugins/Draw/src/locales.ts rename to vue2/packages/plugins/Draw/src/locales.ts diff --git a/packages/plugins/Draw/src/store/actions.ts b/vue2/packages/plugins/Draw/src/store/actions.ts similarity index 100% rename from packages/plugins/Draw/src/store/actions.ts rename to vue2/packages/plugins/Draw/src/store/actions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/cutlery.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/cutlery.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createCutInteractions/cutlery.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/cutlery.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/index.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/index.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createCutInteractions/index.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/index.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/makeDraw.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/makeDraw.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createCutInteractions/makeDraw.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/makeDraw.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/style.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/style.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createCutInteractions/style.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/style.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/types.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/types.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createCutInteractions/types.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createCutInteractions/types.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createDeleteInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createDeleteInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createDeleteInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createDeleteInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createDrawInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createDrawInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createDrawInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createDrawInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createDuplicateInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createDuplicateInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createDuplicateInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createDuplicateInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createLassoInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createLassoInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createLassoInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createLassoInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createMergeInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createMergeInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createMergeInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createMergeInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createModifyInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createModifyInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createModifyInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createModifyInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createTextInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createTextInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createTextInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createTextInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/createTranslateInteractions.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/createTranslateInteractions.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/createTranslateInteractions.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/createTranslateInteractions.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/getSnaps.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/getSnaps.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/getSnaps.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/getSnaps.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/index.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/index.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/index.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/index.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/localSelector.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/localSelector.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/localSelector.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/localSelector.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/modifyDrawStyle.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/modifyDrawStyle.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/modifyDrawStyle.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/modifyDrawStyle.ts diff --git a/packages/plugins/Draw/src/store/createInteractions/modifyTextStyle.ts b/vue2/packages/plugins/Draw/src/store/createInteractions/modifyTextStyle.ts similarity index 100% rename from packages/plugins/Draw/src/store/createInteractions/modifyTextStyle.ts rename to vue2/packages/plugins/Draw/src/store/createInteractions/modifyTextStyle.ts diff --git a/packages/plugins/Draw/src/store/index.ts b/vue2/packages/plugins/Draw/src/store/index.ts similarity index 100% rename from packages/plugins/Draw/src/store/index.ts rename to vue2/packages/plugins/Draw/src/store/index.ts diff --git a/packages/plugins/Draw/src/store/reviseFeatures/autofix.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/autofix.ts similarity index 100% rename from packages/plugins/Draw/src/store/reviseFeatures/autofix.ts rename to vue2/packages/plugins/Draw/src/store/reviseFeatures/autofix.ts diff --git a/packages/plugins/Draw/src/store/reviseFeatures/cloneFeatureCollection.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/cloneFeatureCollection.ts similarity index 100% rename from packages/plugins/Draw/src/store/reviseFeatures/cloneFeatureCollection.ts rename to vue2/packages/plugins/Draw/src/store/reviseFeatures/cloneFeatureCollection.ts diff --git a/packages/plugins/Draw/src/store/reviseFeatures/enrichWithMetaServices.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/enrichWithMetaServices.ts similarity index 100% rename from packages/plugins/Draw/src/store/reviseFeatures/enrichWithMetaServices.ts rename to vue2/packages/plugins/Draw/src/store/reviseFeatures/enrichWithMetaServices.ts diff --git a/vue2/packages/plugins/Draw/src/store/reviseFeatures/index.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/index.ts new file mode 100644 index 0000000000..62c7763752 --- /dev/null +++ b/vue2/packages/plugins/Draw/src/store/reviseFeatures/index.ts @@ -0,0 +1,94 @@ +import type { PolarActionContext } from '@polar/lib-custom-types' +import type { FeatureCollection } from 'geojson' +import type { DrawGetters, DrawState, GeometryType } from '../../types' +import { complete, error, inProgress } from './revisionStates' +import { autofixFeatureCollection } from './autofix' +import { cloneFeatureCollection } from './cloneFeatureCollection' +import { enrichWithMetaServices } from './enrichWithMetaServices' +import { mergeToMultiGeometries } from './mergeToMultiGeometries' +import { validateGeoJson } from './validateGeoJson' + +let abortController: AbortController | null = null + +export const reviseFeatures = async ({ + commit, + dispatch, + rootGetters, + getters, +}: PolarActionContext) => { + const { revision } = getters.configuration + if (!revision) { + return + } + + commit('setFeatureCollectionRevisionState', inProgress) + + if (abortController) { + abortController.abort() + } + const thisController = (abortController = new AbortController()) + + // clone to prevent accidentally messing with the draw tool's data + let revisedFeatureCollection = cloneFeatureCollection( + getters.featureCollection as FeatureCollection + ) + + if (revision.autofix) { + try { + revisedFeatureCollection = autofixFeatureCollection( + revisedFeatureCollection + ) + } catch { + commit('setFeatureCollectionRevisionState', error) + console.warn( + `@polar/plugin-draw: Autofix failed since entered geometries were not valid and fixable. This may e.g. result from pulling points of a polygon in edit mode together until they're point-shaped.` + ) + return + } + } + + // merge first; relevant for both follow-up steps + if (revision.mergeToMultiGeometries) { + // TODO: turf provides "union" and "combine" methods, probably just use them + revisedFeatureCollection = mergeToMultiGeometries(revisedFeatureCollection) + } + + if (revision.validate) { + revisedFeatureCollection = validateGeoJson(revisedFeatureCollection) + } + + if (revision.metaServices?.length) { + try { + revisedFeatureCollection.features = await enrichWithMetaServices( + revisedFeatureCollection, + rootGetters.map, + revision.metaServices, + abortController.signal + ) + } catch (e) { + if (thisController.signal.aborted) { + return + } + console.error( + '@polar/plugin-draw: An error occurred when trying to fetch meta service data for the given feature collection.', + e + ) + if (getters.toastAction) { + dispatch( + getters.toastAction, + { + type: 'warning', + text: 'plugins.draw.metaInformationRetrieval.errorToast', + timeout: 10000, + }, + { root: true } + ) + } + } + } + + if (!thisController.signal.aborted) { + commit('setRevisedFeatureCollection', revisedFeatureCollection) + commit('setFeatureCollectionRevisionState', complete) + } +} diff --git a/vue2/packages/plugins/Draw/src/store/reviseFeatures/mergeToMultiGeometries.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/mergeToMultiGeometries.ts new file mode 100644 index 0000000000..a6f1a8384b --- /dev/null +++ b/vue2/packages/plugins/Draw/src/store/reviseFeatures/mergeToMultiGeometries.ts @@ -0,0 +1,78 @@ +import { + Feature, + FeatureCollection, + GeoJsonTypes, + Geometry, + GeometryCollection, +} from 'geojson' + +type GeometryType = Exclude + +const isMulti = (type: GeometryType['type']) => type.startsWith('Multi') +const multi = (type: GeometryType['type']) => + (isMulti(type) ? type : `Multi${type}`) as + | 'MultiPoint' + | 'MultiLineString' + | 'MultiPolygon' + +const mergeBin = (features: Feature[]): Feature[] => + !features.length + ? [] + : [ + { + ...features[0], + geometry: { + type: multi(features[0].geometry.type), + coordinates: [ + ...features + .map(({ geometry }) => + isMulti(geometry.type) + ? geometry.coordinates + : [geometry.coordinates] + ) + .flat(1), + ], + }, + } as Feature, + ] + +const getGeometryBin = (type: GeoJsonTypes) => + type.endsWith('Point') + ? 'points' + : type.endsWith('LineString') + ? 'lineStrings' + : type.endsWith('Polygon') + ? 'polygons' + : '' + +export const mergeToMultiGeometries = ( + featureCollection: FeatureCollection +): FeatureCollection => { + const bins = featureCollection.features.reduce( + (accumulator, current) => { + const bin = getGeometryBin(current.geometry.type) + if (bin) { + accumulator[bin].push(current) + } else { + console.warn( + `@polar/plugin-draw: Unsupported geometry input "${current.geometry.type}" in multi geometry merge skipped.` + ) + } + return accumulator + }, + { + points: [], + lineStrings: [], + polygons: [], + } as Record<'points' | 'lineStrings' | 'polygons', Feature[]> + ) + + return { + ...featureCollection, + features: [ + ...mergeBin(bins.points), + ...mergeBin(bins.lineStrings), + ...mergeBin(bins.polygons), + ], + } +} diff --git a/packages/plugins/Draw/src/store/reviseFeatures/revisionStates.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/revisionStates.ts similarity index 100% rename from packages/plugins/Draw/src/store/reviseFeatures/revisionStates.ts rename to vue2/packages/plugins/Draw/src/store/reviseFeatures/revisionStates.ts diff --git a/packages/plugins/Draw/src/store/reviseFeatures/validateGeoJson.ts b/vue2/packages/plugins/Draw/src/store/reviseFeatures/validateGeoJson.ts similarity index 100% rename from packages/plugins/Draw/src/store/reviseFeatures/validateGeoJson.ts rename to vue2/packages/plugins/Draw/src/store/reviseFeatures/validateGeoJson.ts diff --git a/packages/plugins/Draw/src/types.ts b/vue2/packages/plugins/Draw/src/types.ts similarity index 100% rename from packages/plugins/Draw/src/types.ts rename to vue2/packages/plugins/Draw/src/types.ts diff --git a/packages/plugins/Draw/src/utils/createDrawLayer.ts b/vue2/packages/plugins/Draw/src/utils/createDrawLayer.ts similarity index 100% rename from packages/plugins/Draw/src/utils/createDrawLayer.ts rename to vue2/packages/plugins/Draw/src/utils/createDrawLayer.ts diff --git a/packages/plugins/Draw/src/utils/createDrawStyle.ts b/vue2/packages/plugins/Draw/src/utils/createDrawStyle.ts similarity index 100% rename from packages/plugins/Draw/src/utils/createDrawStyle.ts rename to vue2/packages/plugins/Draw/src/utils/createDrawStyle.ts diff --git a/packages/plugins/Draw/src/utils/createTextStyle.ts b/vue2/packages/plugins/Draw/src/utils/createTextStyle.ts similarity index 100% rename from packages/plugins/Draw/src/utils/createTextStyle.ts rename to vue2/packages/plugins/Draw/src/utils/createTextStyle.ts diff --git a/packages/lib/getCluster/vite.config.js b/vue2/packages/plugins/Draw/vite.config.js similarity index 100% rename from packages/lib/getCluster/vite.config.js rename to vue2/packages/plugins/Draw/vite.config.js diff --git a/vue2/packages/plugins/Gfi/CHANGELOG.md b/vue2/packages/plugins/Gfi/CHANGELOG.md new file mode 100644 index 0000000000..98b4c31c9f --- /dev/null +++ b/vue2/packages/plugins/Gfi/CHANGELOG.md @@ -0,0 +1,69 @@ +# CHANGELOG + +## 3.1.0 + +- Feature: Extend detection if a `Draw`-interaction is currently active to also check for `@polar/plugin-routing`. +- Fix: When using the gfi with `renderType` set to `'independent'` the window was not added to the MoveHandle to be displayed on mobile devices. Also, the closeIcon was incorrectly set if `ƒeatureList` was configured. This has been fixed by watching for changes to `windowFeatures`. + +## 3.0.2 + +- Fix: Allow layers that have `singleTile` set to `true` and thus being an `ImageLayer` instead a `TileLayer` to be used for GFI-requests as well. + +## 3.0.1 + +- Fix: Clean-up internal flag used for `multiSelect` if a drawing is aborted. This is always the case if a user simply clicks into the map holding CTRL / Command. + +## 3.0.0 + +- Breaking: Upgrade `@masterportal/masterportalapi` from `2.40.0` to `2.45.0` and subsequently `ol` from `^9.2.4` to `^10.3.1`. +- Feature: Add new configuration parameter `multiSelect` to enable the possibility to choose between the selecting multiple features through a box or through a circle. The addition of this parameter deprecates the previously used parameter `boxSelect`. +- Fix: Correctly disable `directSelect` if the user is currently using functionality of `@polar/plugin-draw`. + +## 2.1.0 + +- Feature: Add new action `setFeatureInformation` to be able to set feature information in the store and trigger all relevant processes so that the information displayed to the user is as if he has selected the features himself. + +## 2.0.0 + +- Breaking: Upgrade `@masterportal/masterportalapi` from `2.8.0` to `2.40.0` and subsequently `ol` from `^7.1.0` to `^9.2.4`. +- Feature: Add new configuration parameter `isSelectable` that can be used to filter features to be unselectable. +- Feature: Add new configuration parameters `directSelect` and `boxSelect` to be able to select multiple features at once. +- Fix: Adjust documentation to properly describe optionality of configuration parameters. +- Fix: Add missing configuration parameters `featureList` and `maxFeatures` to the general documentation and `filterBy` and `format` to `gfi.gfiLayerConfiguration` +- Fix: Add missing entry of `gfiContentComponent` to `GfiGetters`. +- Fix: Fix issue rendering properties of a feature if a value is not a string. +- Refactor: Replace redundant prop-forwarding with `getters`. +- Refactor: Use core getter `clientWidth` instead of local computed value. +- Chore: expand on the description to `gfiContentComponent` in the Readme.md. + +## 1.2.2 + +- Fix: The `close` method previously always removed the pin when not in `extendedMasterportalapiMarkers` mode. This issue has been resolved by distinguishing whether a close operation happened in effect to a direct closing user interaction or was technically motivated. + +## 1.2.1 + +- Fix: Add missing deregistration of event listeners on destruction. + +## 1.2.0 + +- Feature: Improved implementation to make plugin SPA-ready. +- Feature: Improve WFS list highlighting with focus/hover styles that are easier to decipher for end users. +- Feature: Add the possibility to update the close-button to e.g. indicate movement to the vector layer feature list. +- Feature: Prevent tooltip windows on touch and pen events; now only mouse hover events produce such tooltips now. +- Feature: If a feature with related features (cluster) is selected in the feature list, users can now toggle between the features with forward/backward buttons, just like when selecting clustered features in the map. +- Feature: If a feature becomes clustered / is no longer clustered when zooming out / in, the selected features are updated properly now based on the selected cluster in the map. + +## 1.1.0 + +- Feature: Add cluster-ready vector layer feature list with pagination, see configuration parameter `gfiLayerConfiguration.featureList`. +- Feature: Can now be rendered as child of icon menu, see configuration parameter `renderType`. +- Feature: Add optional configuration parameter `activeLayerPath` to allow checking for whether any fitting layer is active. +- Feature: Add support for type `GeoJSON` layers. +- Feature: Render mobile content in `MoveHandle` of `@polar/core`. +- Fix: Add space to dev GFI window to fully contain close button effects. +- Fix: Documentation error regarding plugin state. +- Fix: ``s constantly firing `onload`-event thus constantly firing `resize`. + +## 1.0.0 + +Initial release. diff --git a/packages/clients/textLocator/LICENSE b/vue2/packages/plugins/Gfi/LICENSE similarity index 100% rename from packages/clients/textLocator/LICENSE rename to vue2/packages/plugins/Gfi/LICENSE diff --git a/packages/plugins/Gfi/README.md b/vue2/packages/plugins/Gfi/README.md similarity index 100% rename from packages/plugins/Gfi/README.md rename to vue2/packages/plugins/Gfi/README.md diff --git a/vue2/packages/plugins/Gfi/package.json b/vue2/packages/plugins/Gfi/package.json new file mode 100644 index 0000000000..00ec840b2e --- /dev/null +++ b/vue2/packages/plugins/Gfi/package.json @@ -0,0 +1,44 @@ +{ + "name": "@polar/plugin-gfi", + "version": "3.1.0", + "description": "Gfi plugin for POLAR that adds feature information retrieval. UI elements can be provided by configuration, else it works as a store integration.", + "keywords": [ + "OpenLayers", + "ol", + "POLAR", + "plugin", + "gfi", + "feature information" + ], + "license": "EUPL-1.2", + "type": "module", + "author": "Dataport AöR ", + "main": "src/index.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/Dataport/polar.git", + "directory": "packages/plugins/Gfi" + }, + "files": [ + "src/**/*", + "CHANGELOG.md" + ], + "peerDependencies": { + "@masterportal/masterportalapi": "2.48.0", + "@repositoryname/vuex-generators": "^1.1.2", + "ol": "^10.4.0", + "vue": "^2.6.14", + "vuex": "^3.6.2" + }, + "dependencies": { + "just-compare": "^2.3.0", + "lodash.debounce": "^4.0.8" + }, + "devDependencies": { + "@polar/lib-custom-types": "^2.2.0", + "@polar/lib-get-cluster": "^3.0.0", + "@polar/lib-invisible-style": "^3.0.0", + "@polar/lib-test-mount-parameters": "^1.4.0", + "@polar/lib-tooltip": "^1.0.0" + } +} diff --git a/packages/plugins/Gfi/src/components/Feature.vue b/vue2/packages/plugins/Gfi/src/components/Feature.vue similarity index 100% rename from packages/plugins/Gfi/src/components/Feature.vue rename to vue2/packages/plugins/Gfi/src/components/Feature.vue diff --git a/packages/plugins/Gfi/src/components/FeatureButtonGroup.vue b/vue2/packages/plugins/Gfi/src/components/FeatureButtonGroup.vue similarity index 100% rename from packages/plugins/Gfi/src/components/FeatureButtonGroup.vue rename to vue2/packages/plugins/Gfi/src/components/FeatureButtonGroup.vue diff --git a/packages/plugins/Gfi/src/components/FeatureSwitchButtons.vue b/vue2/packages/plugins/Gfi/src/components/FeatureSwitchButtons.vue similarity index 100% rename from packages/plugins/Gfi/src/components/FeatureSwitchButtons.vue rename to vue2/packages/plugins/Gfi/src/components/FeatureSwitchButtons.vue diff --git a/packages/plugins/Gfi/src/components/FeatureTableBody.vue b/vue2/packages/plugins/Gfi/src/components/FeatureTableBody.vue similarity index 100% rename from packages/plugins/Gfi/src/components/FeatureTableBody.vue rename to vue2/packages/plugins/Gfi/src/components/FeatureTableBody.vue diff --git a/packages/plugins/Gfi/src/components/FeatureTableHead.vue b/vue2/packages/plugins/Gfi/src/components/FeatureTableHead.vue similarity index 100% rename from packages/plugins/Gfi/src/components/FeatureTableHead.vue rename to vue2/packages/plugins/Gfi/src/components/FeatureTableHead.vue diff --git a/packages/plugins/Gfi/src/components/Gfi.vue b/vue2/packages/plugins/Gfi/src/components/Gfi.vue similarity index 100% rename from packages/plugins/Gfi/src/components/Gfi.vue rename to vue2/packages/plugins/Gfi/src/components/Gfi.vue diff --git a/packages/plugins/Gfi/src/components/List.vue b/vue2/packages/plugins/Gfi/src/components/List.vue similarity index 100% rename from packages/plugins/Gfi/src/components/List.vue rename to vue2/packages/plugins/Gfi/src/components/List.vue diff --git a/packages/plugins/Gfi/src/components/index.ts b/vue2/packages/plugins/Gfi/src/components/index.ts similarity index 100% rename from packages/plugins/Gfi/src/components/index.ts rename to vue2/packages/plugins/Gfi/src/components/index.ts diff --git a/packages/plugins/Gfi/src/index.ts b/vue2/packages/plugins/Gfi/src/index.ts similarity index 100% rename from packages/plugins/Gfi/src/index.ts rename to vue2/packages/plugins/Gfi/src/index.ts diff --git a/packages/plugins/Gfi/src/locales.ts b/vue2/packages/plugins/Gfi/src/locales.ts similarity index 100% rename from packages/plugins/Gfi/src/locales.ts rename to vue2/packages/plugins/Gfi/src/locales.ts diff --git a/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts b/vue2/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts similarity index 100% rename from packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts rename to vue2/packages/plugins/Gfi/src/store/actions/debouncedGfiRequest.ts diff --git a/packages/plugins/Gfi/src/store/actions/index.ts b/vue2/packages/plugins/Gfi/src/store/actions/index.ts similarity index 100% rename from packages/plugins/Gfi/src/store/actions/index.ts rename to vue2/packages/plugins/Gfi/src/store/actions/index.ts diff --git a/packages/plugins/Gfi/src/store/actions/setup.ts b/vue2/packages/plugins/Gfi/src/store/actions/setup.ts similarity index 100% rename from packages/plugins/Gfi/src/store/actions/setup.ts rename to vue2/packages/plugins/Gfi/src/store/actions/setup.ts diff --git a/packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts b/vue2/packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts similarity index 90% rename from packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts rename to vue2/packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts index 72e5e0467e..a84d632519 100644 --- a/packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts +++ b/vue2/packages/plugins/Gfi/src/store/actions/setupMultiSelection.ts @@ -16,8 +16,12 @@ const isDrawing = (map: Map) => .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)) || + // @ts-expect-error | internal hack to detect it from @polar/plugin-gfi + (interaction._isMultiSelect || + // @ts-expect-error | internal hack to detect it from @polar/plugin-routing + interaction._isRoutingDraw || + // @ts-expect-error | internal hack to detect it from @polar/plugin-draw + interaction._isDrawPlugin)) || interaction instanceof Modify || // @ts-expect-error | internal hack to detect it from @polar/plugin-draw interaction._isDeleteSelect || diff --git a/packages/plugins/Gfi/src/store/getInitialState.ts b/vue2/packages/plugins/Gfi/src/store/getInitialState.ts similarity index 100% rename from packages/plugins/Gfi/src/store/getInitialState.ts rename to vue2/packages/plugins/Gfi/src/store/getInitialState.ts diff --git a/packages/plugins/Gfi/src/store/getters.ts b/vue2/packages/plugins/Gfi/src/store/getters.ts similarity index 100% rename from packages/plugins/Gfi/src/store/getters.ts rename to vue2/packages/plugins/Gfi/src/store/getters.ts diff --git a/packages/plugins/Gfi/src/store/index.ts b/vue2/packages/plugins/Gfi/src/store/index.ts similarity index 100% rename from packages/plugins/Gfi/src/store/index.ts rename to vue2/packages/plugins/Gfi/src/store/index.ts diff --git a/packages/plugins/Gfi/src/store/mutations.ts b/vue2/packages/plugins/Gfi/src/store/mutations.ts similarity index 100% rename from packages/plugins/Gfi/src/store/mutations.ts rename to vue2/packages/plugins/Gfi/src/store/mutations.ts diff --git a/packages/plugins/Gfi/src/types.ts b/vue2/packages/plugins/Gfi/src/types.ts similarity index 100% rename from packages/plugins/Gfi/src/types.ts rename to vue2/packages/plugins/Gfi/src/types.ts diff --git a/packages/plugins/Gfi/src/utils/displayFeatureLayer.ts b/vue2/packages/plugins/Gfi/src/utils/displayFeatureLayer.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/displayFeatureLayer.ts rename to vue2/packages/plugins/Gfi/src/utils/displayFeatureLayer.ts diff --git a/packages/plugins/Gfi/src/utils/filterFeatures.ts b/vue2/packages/plugins/Gfi/src/utils/filterFeatures.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/filterFeatures.ts rename to vue2/packages/plugins/Gfi/src/utils/filterFeatures.ts diff --git a/packages/plugins/Gfi/src/utils/getOriginalFeature.ts b/vue2/packages/plugins/Gfi/src/utils/getOriginalFeature.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/getOriginalFeature.ts rename to vue2/packages/plugins/Gfi/src/utils/getOriginalFeature.ts diff --git a/packages/plugins/Gfi/src/utils/isValidHttpUrl.js b/vue2/packages/plugins/Gfi/src/utils/isValidHttpUrl.js similarity index 100% rename from packages/plugins/Gfi/src/utils/isValidHttpUrl.js rename to vue2/packages/plugins/Gfi/src/utils/isValidHttpUrl.js diff --git a/packages/plugins/Gfi/src/utils/listableLayersFilter.ts b/vue2/packages/plugins/Gfi/src/utils/listableLayersFilter.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/listableLayersFilter.ts rename to vue2/packages/plugins/Gfi/src/utils/listableLayersFilter.ts diff --git a/packages/plugins/Gfi/src/utils/renderFeatures.ts b/vue2/packages/plugins/Gfi/src/utils/renderFeatures.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/renderFeatures.ts rename to vue2/packages/plugins/Gfi/src/utils/renderFeatures.ts diff --git a/packages/plugins/Gfi/src/utils/requestGfi.ts b/vue2/packages/plugins/Gfi/src/utils/requestGfi.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/requestGfi.ts rename to vue2/packages/plugins/Gfi/src/utils/requestGfi.ts diff --git a/packages/plugins/Gfi/src/utils/requestGfiGeoJson.ts b/vue2/packages/plugins/Gfi/src/utils/requestGfiGeoJson.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/requestGfiGeoJson.ts rename to vue2/packages/plugins/Gfi/src/utils/requestGfiGeoJson.ts diff --git a/packages/plugins/Gfi/src/utils/requestGfiWfs.ts b/vue2/packages/plugins/Gfi/src/utils/requestGfiWfs.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/requestGfiWfs.ts rename to vue2/packages/plugins/Gfi/src/utils/requestGfiWfs.ts diff --git a/packages/plugins/Gfi/src/utils/requestGfiWms.ts b/vue2/packages/plugins/Gfi/src/utils/requestGfiWms.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/requestGfiWms.ts rename to vue2/packages/plugins/Gfi/src/utils/requestGfiWms.ts diff --git a/packages/plugins/Gfi/src/utils/sortFeatures.ts b/vue2/packages/plugins/Gfi/src/utils/sortFeatures.ts similarity index 100% rename from packages/plugins/Gfi/src/utils/sortFeatures.ts rename to vue2/packages/plugins/Gfi/src/utils/sortFeatures.ts diff --git a/packages/plugins/Gfi/tests/actions.spec.ts b/vue2/packages/plugins/Gfi/tests/actions.spec.ts similarity index 100% rename from packages/plugins/Gfi/tests/actions.spec.ts rename to vue2/packages/plugins/Gfi/tests/actions.spec.ts diff --git a/packages/plugins/Gfi/tests/filterFeatures.spec.ts b/vue2/packages/plugins/Gfi/tests/filterFeatures.spec.ts similarity index 100% rename from packages/plugins/Gfi/tests/filterFeatures.spec.ts rename to vue2/packages/plugins/Gfi/tests/filterFeatures.spec.ts diff --git a/packages/plugins/Gfi/tests/sortFeatures.spec.ts b/vue2/packages/plugins/Gfi/tests/sortFeatures.spec.ts similarity index 100% rename from packages/plugins/Gfi/tests/sortFeatures.spec.ts rename to vue2/packages/plugins/Gfi/tests/sortFeatures.spec.ts diff --git a/packages/lib/getFeatures/vite.config.js b/vue2/packages/plugins/Gfi/vite.config.js similarity index 100% rename from packages/lib/getFeatures/vite.config.js rename to vue2/packages/plugins/Gfi/vite.config.js diff --git a/packages/plugins/IconMenu/tests/store.spec.ts b/vue2/packages/plugins/IconMenu/tests/store.spec.ts similarity index 100% rename from packages/plugins/IconMenu/tests/store.spec.ts rename to vue2/packages/plugins/IconMenu/tests/store.spec.ts diff --git a/packages/plugins/LayerChooser/tests/findInCapabilities.spec.ts b/vue2/packages/plugins/LayerChooser/tests/findInCapabilities.spec.ts similarity index 100% rename from packages/plugins/LayerChooser/tests/findInCapabilities.spec.ts rename to vue2/packages/plugins/LayerChooser/tests/findInCapabilities.spec.ts diff --git a/packages/plugins/LayerChooser/tests/layerChooser.spec.ts b/vue2/packages/plugins/LayerChooser/tests/layerChooser.spec.ts similarity index 100% rename from packages/plugins/LayerChooser/tests/layerChooser.spec.ts rename to vue2/packages/plugins/LayerChooser/tests/layerChooser.spec.ts diff --git a/packages/plugins/LayerChooser/tests/store.spec.ts b/vue2/packages/plugins/LayerChooser/tests/store.spec.ts similarity index 100% rename from packages/plugins/LayerChooser/tests/store.spec.ts rename to vue2/packages/plugins/LayerChooser/tests/store.spec.ts diff --git a/vue2/packages/plugins/Routing/src/store/actions.ts b/vue2/packages/plugins/Routing/src/store/actions.ts new file mode 100644 index 0000000000..54b2f49dbe --- /dev/null +++ b/vue2/packages/plugins/Routing/src/store/actions.ts @@ -0,0 +1,26 @@ +import { type PolarActionTree } from '@polar/lib-custom-types' +import { RoutingState, RoutingGetters } from '../types' + +const actions: PolarActionTree = { + // TODO: Add implementation for the search functionality + /* async search({ commit, dispatch, getters, rootGetters }, input: string) { + if (getters.searchConfiguration) { + searchConfiguration: { + availability: 'plugin/addressSearch/featuresAvailable', + method: 'plugin/addressSearch/search', + results: 'plugin/addressSearch/searchResults', + }, + const { availability, method, results } = getters.searchConfiguration + // TODO: Show some form of loader + // TODO: Results are currently shown in @polar/plugin-address-search and not in the related input in this plugin + await dispatch(method, { input }, { root: true }) + if (availability) { + commit('setSearchResults', rootGetters[results]) + } else { + // TODO: Show some info that the search failed? set searchResults to null or sth? + } + } + }, */ +} + +export default actions diff --git a/vue2/packages/plugins/Routing/tests/store.spec.ts b/vue2/packages/plugins/Routing/tests/store.spec.ts new file mode 100644 index 0000000000..2bf63478db --- /dev/null +++ b/vue2/packages/plugins/Routing/tests/store.spec.ts @@ -0,0 +1,254 @@ +// NOTE: action tests currently not type-supported, but working +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { PolarActionHandler } from '@polar/lib-custom-types' +import { makeStoreModule } from '../src/store' +import { getInitialState } from '../src/store/state' +import { RoutingState, RoutingGetters } from '../src/types' + +describe('plugin-routing', () => { + jest.mock('ol/source/Vector', () => { + return jest.fn().mockImplementation(() => ({ + addFeature: jest.fn(), + clear: jest.fn(), + })) + }) + describe('store', () => { + describe('actions', () => { + describe('setupModule', () => { + const RoutingStore = makeStoreModule() + const setupModule = RoutingStore.actions + ?.setupModule as PolarActionHandler + + if (typeof setupModule === 'undefined') { + throw new Error( + 'Actions missing in RoutingStore. Tests could not be run.' + ) + } + + let actionContext + let addLayer + let dispatch + + beforeEach(() => { + addLayer = jest.fn() + dispatch = jest.fn() + actionContext = { + dispatch, + rootGetters: { + map: { + addLayer, + }, + }, + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should initialize the draw layer and the draw interaction', () => { + // @ts-ignore + setupModule(actionContext) + + expect(dispatch).toHaveBeenCalledTimes(1) + expect(dispatch).toHaveBeenCalledWith('initializeDraw') + expect(addLayer).toHaveBeenCalledTimes(1) + }) + }) + describe('reset', () => { + const routingStore = makeStoreModule() + const reset = routingStore.actions?.reset as PolarActionHandler< + RoutingState, + RoutingGetters + > + + if (typeof reset === 'undefined') { + throw new Error( + 'Action reset is missing in RoutingStore. Tests could not be run.' + ) + } + + let actionContext + let commit + let dispatch + + beforeEach(() => { + commit = jest.fn() + dispatch = jest.fn() + actionContext = { + state: { + start: [1, 2], + end: [3, 4], + startAddress: 'Start Adresse', + endAddress: 'End Adresse', + selectedTravelMode: 'driving-car', + selectedPreference: 'fastest', + selectedRouteTypesToAvoid: ['toll'], + routingResponseData: { data: 'some data' }, + }, + commit, + dispatch, + } + }) + + it('should reset all coordinates and related state properties', () => { + // @ts-ignore + reset(actionContext) + expect(commit).toHaveBeenCalledTimes(6) + expect(commit).toHaveBeenCalledWith('resetRoute') + expect(commit).toHaveBeenCalledWith('setCurrentlyFocusedInput', -1) + expect(commit).toHaveBeenCalledWith( + 'setSelectedTravelMode', + 'driving-car' + ) + expect(commit).toHaveBeenCalledWith( + 'setSelectedPreference', + 'recommended' + ) + expect(commit).toHaveBeenCalledWith( + 'setSelectedRouteTypesToAvoid', + [] + ) + expect(commit).toHaveBeenCalledWith('setRoutingResponseData', {}) + expect(dispatch).toHaveBeenCalledWith('clearRoute') + }) + }) + describe('getRoute', () => { + const RoutingStore = makeStoreModule() + const getRoute = RoutingStore.actions?.getRoute as PolarActionHandler< + RoutingState, + RoutingGetters + > + + if (typeof getRoute === 'undefined') { + throw new Error( + 'Actions missing in RoutingStore. Tests could not be run.' + ) + } + + let actionContext + let commit + let dispatch + + beforeEach(() => { + commit = jest.fn() + dispatch = jest.fn() + actionContext = { + state: { + ...getInitialState(), + selectedRouteTypesToAvoid: ['tollways'], + selectedPreference: 'recommended', + }, + commit, + dispatch, + getters: { + configuration: { + apiKey: 'my-secure-key', + }, + url: 'http://example.com/driving-car/json', + routeAsWGS84: [ + [19.6, 48.1], + [19.5, 47.1], + ], + }, + } + + // @ts-ignore + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + features: [ + { + geometry: { + coordinates: [ + [19.6, 48.1], + [19.5, 47.1], + ], + }, + }, + ], + }), + }) + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('should fetch routing data and commit the response', async () => { + // @ts-ignore + await getRoute(actionContext) + + expect(fetch).toHaveBeenCalledWith( + 'http://example.com/driving-car/json', + { + method: 'POST', + headers: { + /* eslint-disable @typescript-eslint/naming-convention */ + 'Content-Type': 'application/json', + Authorization: 'my-secure-key', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + body: JSON.stringify({ + coordinates: [ + [19.6, 48.1], + [19.5, 47.1], + ], + geometry: true, + instructions: true, + options: { + avoid_features: ['tollways'], + }, + preference: 'recommended', + units: 'm', + }), + } + ) + + expect(commit).toHaveBeenCalledTimes(1) + expect(commit).toHaveBeenCalledWith('setRoutingResponseData', { + features: [ + { + geometry: { + coordinates: [ + [19.6, 48.1], + [19.5, 47.1], + ], + }, + }, + ], + }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledWith('clearRoute') + expect(dispatch).toHaveBeenCalledWith('drawRoute') + }) + + it('should handle fetch errors correctly', async () => { + // @ts-ignore + global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 500, + }) + ) + + // @ts-ignore + await getRoute(actionContext) + + expect(commit).toHaveBeenCalledTimes(0) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledWith('clearRoute') + expect(dispatch).toHaveBeenCalledWith( + 'handleErrors', + new Error( + 'Route could not be determined. Try different coordinates.' + ) + ) + }) + }) + }) + }) +}) diff --git a/vue2/packages/types/custom/core.ts b/vue2/packages/types/custom/core.ts new file mode 100644 index 0000000000..36e4e3071d --- /dev/null +++ b/vue2/packages/types/custom/core.ts @@ -0,0 +1,189 @@ +import { Feature, Map } from 'ol' +import { Options as Fill } from 'ol/style/Fill' +import { Options as Stroke } from 'ol/style/Stroke' +import { type Options as TextOptions } from 'ol/style/Text' +import { Size } from 'ol/size' +import { Color } from 'ol/color' +import { ColorLike } from 'ol/colorlike' +import { Feature as GeoJsonFeature } from 'geojson' +import { VueConstructor } from 'vue' + +/** + * + * Plugin-Container + * + */ + +export interface PolarCircleStyle { + fillColor?: Color | ColorLike + radius: number + strokeColor?: Color | ColorLike + displacement?: number[] + scale?: number | Size + rotation?: number +} + +export interface DrawStyle { + fill: Fill + stroke: Stroke + circle: PolarCircleStyle + measure?: TextOptions +} + +export interface TextStyle { + font: string | FontStyle + textColor?: Color | ColorLike +} + +export interface FontStyle { + size: number[] + family: string +} + +export type DrawMode = 'Circle' | 'LineString' | 'Point' | 'Polygon' | 'Text' + +export type MeasureMode = 'none' | 'metres' | 'kilometres' | 'hectares' + +export interface MeasureOptions { + metres?: boolean + kilometres?: boolean + hectares?: boolean + initialOption?: MeasureMode +} + +export interface Lasso { + id: string + minZoom: boolean +} + +export interface DrawConfiguration extends Partial { + addLoading?: string + enableOptions?: boolean + lassos?: Lasso[] + measureOptions?: MeasureOptions + removeLoading?: string + revision?: DrawRevision + selectableDrawModes?: DrawMode[] + snapTo?: string[] + style?: DrawStyle + textStyle?: TextStyle + toastAction?: string +} + +export interface DrawRevision { + autofix?: boolean + mergeToMultiGeometries?: boolean + metaServices?: DrawMetaService[] + validate?: boolean +} + +export interface DrawMetaService { + id: string + aggregationMode?: 'unequal' | 'all' + propertyNames?: string[] +} + +/** Configuration of GFI feature regarding a specific layer */ +export interface GfiLayerConfiguration { + /** + * Property of the features of a service having an url usable to trigger a + * download of features as a document. + */ + exportProperty?: string + // filter method to apply on response features, only relevant for WMS services + filterBy?: 'clickPosition' + // format the response is known to come in (e.g. "GML"); only relevant for WMS services + format?: 'GML' | 'GML2' | 'GML3' | 'GML32' | 'text' + /** + * Whether the found features' geometry, if available, is to be shown on the + * map. It is simply printed to a helper layer. + */ + geometry?: boolean + // name of field to use for geometry, if not default field + geometryName?: string + isSelectable?: GfiIsSelectableFunction + maxFeatures?: number + /** + * If window is true, the properties are either + * 1. filtered by whether their key is in a string[] + * 2. filtered by whether their key is in the given object's keys, and then + * translated to the object's value for that key + * I.e., a feature \{ a: 0, b: 0, c: 0 \} with ['a', 'b'] will show key-value + * pairs 'a':0 and 'b':0, and the same feature with object \{a: 'A'\} will + * show key-value pair 'A':0, mind the uppercase A, which is the mapped key. + * + * This does not influence what information is available in the store, + * only the UI is affected by these filters/mappings. + */ + properties?: string[] | Record + showTooltip?: (feature: Feature, map: Map) => [string, string][] + /** + * Whether the found features' properties are to be shown in the client's UI. + * They are displayed as a table, one feature at a time, and if multiple + * features are found, the user may step through all where the layer's window + * value is true. + */ + window?: boolean +} + +/** Object containing information for highlighting a gfi result */ +export interface HighlightStyle { + fill: Fill + stroke: Stroke +} + +export type GfiIsSelectableFunction = (feature: GeoJsonFeature) => boolean + +/** configurable function to gather additional info */ +export type GfiAfterLoadFunction = ( + featureInformation: Record, + srsName: string // TODO: Might be interesting to overlap this with mapConfig.namedProjections for type safety in using only allowed epsg codes +) => + | Record + | Promise> + +/** GFI Module Configuration */ +export interface FeatureList { + mode: 'visible' | 'loaded' + bindWithCoreHoverSelect?: boolean + pageLength?: number + text?: (string | ((f: Feature) => string))[] +} + +export interface GfiConfiguration extends PluginOptions { + /** + * Source paths through store to listen to for changes; it is assumed values + * listened to are coordinates that can be used to request information from + * the specified layers. + */ + coordinateSources: string[] + /** + * The layers to request feature information from. Both WMS and WFS layers are + * supported. Keys are layer IDs as specified in the services.json registry. + */ + layers: Record + activeLayerPath?: string + afterLoadFunction?: GfiAfterLoadFunction + boxSelect?: boolean + /** + * If required the stroke and fill of the highlighted feature can be configured. + * Otherwise, a default style is applied. + */ + customHighlightStyle?: HighlightStyle + directSelect?: boolean + featureList?: FeatureList + /** + * Optionally replace GfiContent component. + * Usable to completely redesign content of GFI window. + */ + gfiContentComponent?: VueConstructor + /** + * Limits the viewable GFIs per layer by this number. The first n elements + * are chosen arbitrarily. Useful if you e.g. just want one result, or to + * limit an endless stream of returns to maybe 10 or so. Infinite by default. + */ + maxFeatures?: number + mode?: 'bboxDot' | 'intersects' + multiSelect?: 'box' | 'circle' + renderType?: 'iconMenu' | 'independent' +} diff --git a/pages/card.css b/vue2/pages/card.css similarity index 100% rename from pages/card.css rename to vue2/pages/card.css diff --git a/pages/documentation.html b/vue2/pages/documentation.html similarity index 97% rename from pages/documentation.html rename to vue2/pages/documentation.html index 004510530f..3c458c2433 100644 --- a/pages/documentation.html +++ b/vue2/pages/documentation.html @@ -2,7 +2,7 @@ - + @@ -23,7 +23,7 @@