diff --git a/.gitignore b/.gitignore
index a2f4307351..0ccc220b48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -45,3 +45,6 @@ package-lock.json
**/scripts/check-layer-providers-results/*invalid_providers_wmts.yaml
**/scripts/check-layer-providers-results/*invalid_providers_content.yaml
**/scripts/check-layer-providers-results/*valid_providers.json
+
+# Dev
+.specstory
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000..421b2f7a31
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,11 @@
+pnpm-lock.yaml
+.github/
+patches/
+
+node_modules/
+**/node_modules
+packages/**/dist/
+
+packages/viewer/index.html
+packages/viewer/public/icon.svg
+packages/viewer/src/assets/svg/swiss-flag.svg
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bf628614ac..85f0234ee5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -109,7 +109,7 @@ The store is divided into modules that mostly correspond to the application part
The goal is to have a centralized way of dealing with changes, and not delegate that to each component.
Store plugins can be used to react to store changes.
-See the [store read-me](packages/viewer/src/store/README.md) for more information.
+See the [store read-me](packages/mapviewer/src/store/README.md) for more information.
### Best practices
@@ -117,7 +117,7 @@ See the [store read-me](packages/viewer/src/store/README.md) for more informatio
- Don't use a complex object as reactive data
- Avoid using JavaScript getter and setter in class that are used in reactive data
-See also [Store Best Practices](packages/viewer/src/store/README.md#best-practices)
+See also [Store Best Practices](packages/mapviewer/src/store/README.md#best-practices)
### Vue Composition API
diff --git a/PACKAGES.md b/PACKAGES.md
new file mode 100644
index 0000000000..2820d9945b
--- /dev/null
+++ b/PACKAGES.md
@@ -0,0 +1,152 @@
+# @swissgeo Packages Dependency Map
+
+State made on the 2026-01-21 (subject to change)
+
+This document maps the dependencies between the packages exported to NPMJS.com under the `@swissgeo` organization.
+
+## Dependency Graph
+
+```mermaid
+graph TD
+ subgraph Core Libraries
+ api["@swissgeo/api"]
+ coordinates["@swissgeo/coordinates"]
+ elevation["@swissgeo/elevation-profile"]
+ layers["@swissgeo/layers"]
+ log["@swissgeo/log"]
+ numbers["@swissgeo/numbers"]
+ theme["@swissgeo/theme"]
+ tooltip["@swissgeo/tooltip"]
+ end
+
+ subgraph Configuration Packages
+ eslint["@swissgeo/config-eslint"]
+ prettier["@swissgeo/config-prettier"]
+ stylelint["@swissgeo/config-stylelint"]
+ typescript["@swissgeo/config-typescript"]
+ staging["@swissgeo/staging-config"]
+ end
+
+ %% Dependencies
+ api --> coordinates
+ api --> layers
+ api --> log
+ api --> numbers
+ api --> staging
+ api --> theme
+
+ coordinates --> log
+ coordinates --> numbers
+
+ elevation --> api
+ elevation --> coordinates
+ elevation --> log
+ elevation --> numbers
+ elevation --> staging
+ elevation --> tooltip
+
+ layers --> coordinates
+ layers --> log
+ layers --> numbers
+ layers --> staging
+
+ numbers --> log
+
+ theme --> staging
+
+ %% DevDependencies to Config (optional to show, but mapping them helps)
+ api -.-> eslint
+ api -.-> prettier
+ api -.-> typescript
+
+ coordinates -.-> eslint
+ coordinates -.-> typescript
+
+ drawing -.-> eslint
+ drawing -.-> prettier
+ drawing -.-> stylelint
+ drawing -.-> typescript
+
+ elevation -.-> eslint
+ elevation -.-> stylelint
+ elevation -.-> typescript
+
+ layers -.-> eslint
+ layers -.-> typescript
+
+ log -.-> eslint
+ log -.-> typescript
+
+ numbers -.-> eslint
+ numbers -.-> typescript
+
+ theme -.-> eslint
+ theme -.-> stylelint
+ theme -.-> typescript
+
+ tooltip -.-> eslint
+ tooltip -.-> stylelint
+ tooltip -.-> typescript
+
+ eslint -.-> typescript
+ prettier -.-> typescript
+ staging -.-> eslint
+ staging -.-> typescript
+ stylelint -.-> typescript
+
+```
+
+## Dependency Table
+
+| Package | Dependencies (@swissgeo) | DevDependencies (@swissgeo) |
+| ------- | ------------------------ | --------------------------- |
+| `@swissgeo/api` | `coordinates`, `layers`, `log`, `numbers`, `staging-config`, `theme` | `config-eslint`, `config-prettier`, `config-typescript` |
+| `@swissgeo/coordinates` | `log`, `numbers` | `config-eslint`, `config-typescript` |
+| `@swissgeo/elevation-profile` | `api`, `coordinates`, `log`, `numbers`, `staging-config`, `tooltip` | `config-eslint`, `config-stylelint`, `config-typescript` |
+| `@swissgeo/layers` | `coordinates`, `log`, `numbers`, `staging-config` | `config-eslint`, `config-typescript` |
+| `@swissgeo/log` | - | `config-eslint`, `config-typescript` |
+| `@swissgeo/numbers` | `log` | `config-eslint`, `config-typescript` |
+| `@swissgeo/theme` | `staging-config` | `config-eslint`, `config-stylelint`, `config-typescript` |
+| `@swissgeo/tooltip` | - | `config-eslint`, `config-stylelint`, `config-typescript` |
+| `@swissgeo/config-eslint` | - | `config-typescript` |
+| `@swissgeo/config-prettier` | - | `config-typescript` |
+| `@swissgeo/config-stylelint` | - | `config-typescript` |
+| `@swissgeo/config-typescript` | - | - |
+| `@swissgeo/staging-config` | - | `config-eslint`, `config-typescript` |
+
+## Dependency Matrix
+
+This table shows which package imports which dependency.
+- **X**: Production dependency
+- **d**: Development dependency
+
+| Importing Package | api | coord | draw | elev | layr | log | numb | thme | tool | esl | pret | styl | ts | stag |
+| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
+| `@swissgeo/api` | - | X | | | X | X | X | X | | d | | | d | X |
+| `@swissgeo/coordinates` | | - | | | | X | X | | | d | | | d | |
+| `@swissgeo/elevation-profile` | X | X | | - | | X | X | | X | d | | d | d | X |
+| `@swissgeo/layers` | | X | | | - | X | X | | | d | | | d | X |
+| `@swissgeo/log` | | | | | | - | | | | d | | | d | |
+| `@swissgeo/numbers` | | | | | | X | - | | | d | | | d | |
+| `@swissgeo/theme` | | | | | | | | - | | d | | d | d | X |
+| `@swissgeo/tooltip` | | | | | | | | | - | d | | d | d | |
+| `@swissgeo/config-eslint` | | | | | | | | | | - | | | d | |
+| `@swissgeo/config-prettier` | | | | | | | | | | | - | | d | |
+| `@swissgeo/config-stylelint` | | | | | | | | | | | | - | d | |
+| `@swissgeo/config-typescript` | | | | | | | | | | | | | - | |
+| `@swissgeo/staging-config` | | | | | | | | | | d | | | d | - |
+
+**Legend:**
+- **api**: `@swissgeo/api`
+- **coord**: `@swissgeo/coordinates`
+- **elev**: `@swissgeo/elevation-profile`
+- **layr**: `@swissgeo/layers`
+- **log**: `@swissgeo/log`
+- **numb**: `@swissgeo/numbers`
+- **thme**: `@swissgeo/theme`
+- **tool**: `@swissgeo/tooltip`
+- **esl**: `@swissgeo/config-eslint`
+- **pret**: `@swissgeo/config-prettier`
+- **styl**: `@swissgeo/config-stylelint`
+- **ts**: `@swissgeo/config-typescript`
+- **stag**: `@swissgeo/staging-config`
diff --git a/README.md b/README.md
index 39cb837d16..6c0a4edb42 100644
--- a/README.md
+++ b/README.md
@@ -37,15 +37,44 @@ pnpm run dev
See [CONTRIBUTING.md](CONTRIBUTING.md)
+## Exported Packages
+
+This monorepo contains several packages that are published to [NPMJS.com](https://www.npmjs.com/org/swissgeo) under the `@swissgeo` organization.
+Detailed dependency mapping can be found in [PACKAGES.md](PACKAGES.md).
+
+### Core Libraries
+
+| Package | Version | Description |
+| ------- | ------- | ----------- |
+| [`@swissgeo/api`](https://www.npmjs.com/package/@swissgeo/api) |  | API utilities to interact with SWISSGEO's backend services |
+| [`@swissgeo/coordinates`](https://www.npmjs.com/package/@swissgeo/coordinates) |  | Projection definition and coordinates utils for SWISSGEO projects |
+| [`@swissgeo/drawing`](https://www.npmjs.com/package/@swissgeo/drawing) |  | Components to draw on a map and export the result as KML or GeoJSON |
+| [`@swissgeo/elevation-profile`](https://www.npmjs.com/package/@swissgeo/elevation-profile) |  | Components to request and display an elevation profile over Switzerland |
+| [`@swissgeo/layers`](https://www.npmjs.com/package/@swissgeo/layers) |  | Layers definition for SwissGeo |
+| [`@swissgeo/log`](https://www.npmjs.com/package/@swissgeo/log) |  | Logging utils for SWISSGEO projects |
+| [`@swissgeo/numbers`](https://www.npmjs.com/package/@swissgeo/numbers) |  | Numbers utils for SWISSGEO projects |
+| [`@swissgeo/theme`](https://www.npmjs.com/package/@swissgeo/theme) |  | Shared SCSS variable and theme utilities |
+| [`@swissgeo/tooltip`](https://www.npmjs.com/package/@swissgeo/tooltip) |  | Tooltip for geoadmin |
+
+### Configuration Packages
+
+| Package | Version | Description |
+| ------- | ------- | ----------- |
+| [`@swissgeo/config-eslint`](https://www.npmjs.com/package/@swissgeo/config-eslint) |  | Shared ESLint config for SWISSGEO projects |
+| [`@swissgeo/config-prettier`](https://www.npmjs.com/package/@swissgeo/config-prettier) |  | Shared Prettier config for SWISSGEO projects |
+| [`@swissgeo/config-stylelint`](https://www.npmjs.com/package/@swissgeo/config-stylelint) |  | Shared Stylelint config for SWISSGEO projects |
+| [`@swissgeo/config-typescript`](https://www.npmjs.com/package/@swissgeo/config-typescript) |  | TypeScript configuration for SWISSGEO projects |
+| [`@swissgeo/staging-config`](https://www.npmjs.com/package/@swissgeo/staging-config) |  | Staging config utils for swissgeo |
+
## Check External Layer Provider list
-In the `Import` tool we provide a hardcoded list of provider via the [external-providers.json](packages/viewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json) file. Because we have quite a lot of provider, we have a CLI tool in order to
+In the `Import` tool we provide a hardcoded list of provider via the [external-providers.json](packages/mapviewer/src/modules/menu/components/advancedTools/ImportCatalogue/external-providers.json) file. Because we have quite a lot of provider, we have a CLI tool in order to
check their validity. The tool can also be used with a single url as input parameter to see the URL would be valid
for our application.
```bash
pnpm install
-./packages/viewer/scripts/check-external-layers-providers.js
+./packages/mapviewer/scripts/check-external-layers-providers.js
```
You can use `-h` option to get more detail on the script.
diff --git a/env.d.ts b/env.d.ts
index 7d0ff9efa9..11f02fe2a0 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/package.json b/package.json
index 2d168224f9..17a96660d7 100644
--- a/package.json
+++ b/package.json
@@ -10,16 +10,19 @@
"build:int": "pnpm --recursive --if-present run build:int",
"build:prod": "pnpm --recursive --if-present run build:prod",
"dev": "pnpm --filter=web-mapviewer run dev ",
+ "dev:https": "pnpm --filter=web-mapviewer run dev:https ",
"force-reinstall": "node ./scripts/force-pnpm-reinstall.js",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "pnpm run --recursive --parallel --no-bail --if-present lint",
"lint:no-fix": "pnpm run --recursive --parallel --no-bail --if-present lint:no-fix ",
- "preview": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview",
- "preview:dev": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview:dev ",
- "preview:int": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview:int",
- "preview:prod": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview:prod",
- "preview:test": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview:test",
+ "preview": "pnpm --filter=web-mapviewer run preview",
+ "preview:dev": "pnpm --filter=web-mapviewer run preview:dev ",
+ "preview:int": "pnpm --filter=web-mapviewer run preview:int",
+ "preview:prod": "pnpm --filter=web-mapviewer run preview:prod",
+ "preview:test": "pnpm --filter=web-mapviewer run preview:test",
+ "publish": "pnpm --filter=!web-mapviewer --filter=!@swissgeo/layers --recursive publish --access public --tag latest",
+ "publish:prepare": "pnpm --filter=!web-mapviewer --filter=!@swissgeo/layers --recursive --parallel build",
"test:component": "pnpm --filter=web-mapviewer run test:component",
"test:component:ci": "pnpm --filter=web-mapviewer run test:component:ci",
"test:e2e": "pnpm --filter=web-mapviewer run test:e2e",
@@ -27,13 +30,22 @@
"test:e2e:headless": "pnpm --filter=web-mapviewer run test:e2e:headless",
"test:unit": "pnpm run --recursive --parallel --no-bail --if-present test:unit",
"test:unit:watch": "pnpm run --recursive --if-present test:unit:watch",
- "update:workspace": "node ./scripts/update-pnpm-workspace.js"
+ "update:workspace": "tsx ./scripts/update-pnpm-workspace.ts"
},
"engines": {
"node": ">=22.18",
"pnpm": ">=10.15"
},
"devDependencies": {
+ "@prettier/plugin-xml": "catalog:",
+ "@swissgeo/config-prettier": "workspace:*",
+ "@types/node": "catalog:",
+ "prettier": "catalog:",
+ "prettier-plugin-jsdoc": "catalog:",
+ "prettier-plugin-packagejson": "catalog:",
+ "prettier-plugin-tailwindcss": "catalog:",
+ "tsx": "catalog:",
+ "vitest": "catalog:",
"yaml": "catalog:"
},
"pnpm": {
@@ -44,6 +56,10 @@
"sharp",
"vue-demi"
],
+ "patchedDependencies": {
+ "@geoblocks/mapfishprint@0.2.22": "patches/@geoblocks__mapfishprint@0.2.22.patch",
+ "ol@10.8.0": "patches/ol@10.8.0.patch"
+ },
"ignoredBuiltDependencies": [
"core-js",
"cypress",
@@ -51,9 +67,6 @@
"esbuild",
"protobufjs",
"sharp"
- ],
- "patchedDependencies": {
- "vite": "patches/vite.patch"
- }
+ ]
}
}
diff --git a/packages/api/README.md b/packages/api/README.md
new file mode 100644
index 0000000000..31fbf17f55
--- /dev/null
+++ b/packages/api/README.md
@@ -0,0 +1,72 @@
+# @swissgeo/api
+
+API utilities to interact with SWISSGEO's backend services.
+
+This package provides a collection of functions and types to interact with various SWISSGEO services, including search, elevation (height), profile, print, and more.
+
+## Installation
+
+```bash
+pnpm add @swissgeo/api
+```
+
+### Peer dependencies
+
+Most functions that deal with coordinates require the [@swissgeo/coordinates](https://www.npmjs.com/package/@swissgeo/coordinates) package to access the coordinate transformation utilities.
+
+```bash
+pnpm add @swissgeo/coordinates
+```
+
+## Features
+
+This package provides utilities for:
+
+- **Features**: Interaction with geographic features.
+- **Feedback**: Sending feedback to the platform.
+- **Files**: KML file management. Aimed at the drawing tools on [map.geo.admin.ch](https://map.geo.admin.ch).
+- **Height**: Requesting elevation at specific coordinates.
+- **Icons**: Accessing platform icons.
+- **LV03 Reframe**: Coordinate transformation (LV03 <-> LV95).
+- **Print**: Interacting with MapFish Print services.
+- **Profile**: Requesting elevation profiles.
+- **QR Code**: Generating QR codes.
+- **Search**: Searching for locations, layers, and features (in layers).
+- **Shortlink**: Generating and resolving shorten URL.
+- **Topics**: Accessing map topics and layers metadata.
+- **What3Words**: Integration with What3Words services.
+
+## Usage (some samples)
+
+### Search API
+
+```typescript
+import { LV95 } from '@swissgeo/coordinates'
+import { searchAPI } from '@swissgeo/api'
+
+const results = await searchAPI.search({
+ queryString: "Bern",
+ outputProjection: LV95,
+ lang: 'en',
+ // in m/px, you can get this from your OpenLayers instance by calling map.getView().getResolution()
+ resolution: 1000
+})
+```
+
+### Height API
+
+```typescript
+import { LV95, WGS84 } from '@swissgeo/coordinates'
+import { heightAPI } from '@swissgeo/api'
+
+const elevation = await getHeightForPosition([2600000, 1200000], LV95)
+const elevationWGS84 = await getHeightForPosition([7.530, 46.627], WGS84)
+```
+
+## License
+
+BSD-3-Clause
+
+## Repository
+
+[https://github.com/geoadmin/web-mapviewer](https://github.com/geoadmin/web-mapviewer)
diff --git a/packages/api/eslint.config.mts b/packages/api/eslint.config.mts
new file mode 100644
index 0000000000..3d347ed5e6
--- /dev/null
+++ b/packages/api/eslint.config.mts
@@ -0,0 +1,12 @@
+import defaultConfig from '@swissgeo/config-eslint'
+
+export default [
+ ...defaultConfig,
+ {
+ languageOptions: {
+ parserOptions: {
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+]
diff --git a/packages/api/package.json b/packages/api/package.json
new file mode 100644
index 0000000000..979e30606f
--- /dev/null
+++ b/packages/api/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@swissgeo/api",
+ "version": "1.0.4",
+ "description": "API utilities to interact with SWISSGEO's backend services",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.umd.cjs"
+ },
+ "./utils": {
+ "types": "./dist/utils.d.ts",
+ "import": "./dist/utils.js",
+ "require": "./dist/utils.umd.cjs"
+ }
+ },
+ "scripts": {
+ "build": "pnpm run type-check && pnpm run generate-types && vite build",
+ "generate-types": "vue-tsc --declaration",
+ "lint": "eslint --fix",
+ "lint:no-fix": "eslint",
+ "prepare": "pnpm run build",
+ "test:unit": "vitest run",
+ "test:unit:watch": "vitest watch",
+ "type-check": "vue-tsc --build"
+ },
+ "dependencies": {
+ "@geoblocks/mapfishprint": "catalog:",
+ "@swissgeo/coordinates": "workspace:*",
+ "@swissgeo/layers": "workspace:*",
+ "@swissgeo/log": "workspace:*",
+ "@swissgeo/numbers": "workspace:*",
+ "@swissgeo/staging-config": "workspace:*",
+ "@swissgeo/theme": "workspace:*",
+ "@tmcw/togeojson": "catalog:",
+ "@turf/turf": "catalog:",
+ "form-data": "catalog:",
+ "jszip": "catalog:",
+ "lodash": "catalog:",
+ "luxon": "catalog:"
+ },
+ "devDependencies": {
+ "@intlify/core-base": "catalog:",
+ "@microsoft/api-extractor": "catalog:",
+ "@swissgeo/config-eslint": "workspace:*",
+ "@swissgeo/config-typescript": "workspace:*",
+ "@tsconfig/node22": "catalog:",
+ "@types/chai": "catalog:",
+ "@types/geojson": "catalog:",
+ "@types/jsdom": "catalog:",
+ "@types/lodash": "catalog:",
+ "@types/pako": "catalog:",
+ "chai": "catalog:",
+ "eslint": "catalog:",
+ "typescript": "catalog:",
+ "unplugin-dts": "catalog:",
+ "vite": "catalog:",
+ "vite-plugin-vue-devtools": "catalog:",
+ "vite-tsconfig-paths": "catalog:",
+ "vitest": "catalog:",
+ "vue-tsc": "catalog:"
+ },
+ "peerDependencies": {
+ "@swissgeo/layers": "workspace:*",
+ "@swissgeo/log": "workspace:*",
+ "@swissgeo/staging-config": "workspace:*",
+ "axios": "catalog:",
+ "ol": "catalog:",
+ "pako": "catalog:",
+ "proj4": "catalog:"
+ }
+}
diff --git a/packages/api/src/__tests__/fileProxy.spec.ts b/packages/api/src/__tests__/fileProxy.spec.ts
new file mode 100644
index 0000000000..407ade0b26
--- /dev/null
+++ b/packages/api/src/__tests__/fileProxy.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+
+import fileProxyAPI from '@/fileProxy'
+
+describe('Serice-proxy tests', () => {
+ describe('transformFileUrl', () => {
+ it('returns undefined when the input is invalid', () => {
+ expect(fileProxyAPI.transformFileUrl('some non URL string')).to.be.undefined
+ })
+ it('returns the URL transformed', () => {
+ expect(fileProxyAPI.transformFileUrl('http://some-file.kml?one=1&foo=bar')).to.eq(
+ `http/some-file.kml${encodeURIComponent('?one=1&foo=bar')}`
+ )
+ })
+ })
+})
diff --git a/packages/api/src/__tests__/search.spec.ts b/packages/api/src/__tests__/search.spec.ts
new file mode 100644
index 0000000000..355c4a0833
--- /dev/null
+++ b/packages/api/src/__tests__/search.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+
+import searchAPI from '@/search'
+
+describe('Builds object by extracting all relevant attributes from the backend', () => {
+ describe('FeatureSearchResult.getSimpleTitle', () => {
+ it('Returns title removing HTML', () => {
+ const expectedResult = 'Some irrelevant stuff 123 Test'
+ const expectedResultWrappedInHtml = 'Some irrelevant stuff 123 Test'
+ expect(searchAPI.sanitizeTitle(expectedResultWrappedInHtml)).to.eq(expectedResult)
+ })
+
+ it('Returns title as is if no HTML is present', () => {
+ const expectedResult = 'Test 123 Test'
+ expect(searchAPI.sanitizeTitle(expectedResult)).to.eq(expectedResult)
+ })
+ })
+})
diff --git a/packages/api/src/config/log.ts b/packages/api/src/config/log.ts
new file mode 100644
index 0000000000..d47ccb42c8
--- /dev/null
+++ b/packages/api/src/config/log.ts
@@ -0,0 +1,13 @@
+import { LogPreDefinedColor } from '@swissgeo/log'
+
+const LogColorPerService = {
+ feedback: LogPreDefinedColor.Amber,
+ fileProxy: LogPreDefinedColor.Green,
+ files: LogPreDefinedColor.Indigo,
+ height: LogPreDefinedColor.Orange,
+ icons: LogPreDefinedColor.Lime,
+ lv03Reframe: LogPreDefinedColor.Zinc,
+ profile: LogPreDefinedColor.Teal,
+ qrCode: LogPreDefinedColor.Purple,
+}
+export default LogColorPerService
diff --git a/packages/api/src/features.ts b/packages/api/src/features.ts
new file mode 100644
index 0000000000..085623c000
--- /dev/null
+++ b/packages/api/src/features.ts
@@ -0,0 +1,800 @@
+import type { CoordinateSystem, FlatExtent, SingleCoordinate } from '@swissgeo/coordinates'
+import type { ExternalLayer, ExternalWMSLayer, GeoAdminLayer } from '@swissgeo/layers'
+import type { AxiosResponse } from 'axios'
+import type { Feature as GeoJsonFeature, FeatureCollection, Geometry } from 'geojson'
+import type Feature from 'ol/Feature'
+import type { LineString, MultiLineString, MultiPolygon, Point, Polygon } from 'ol/geom'
+
+import { allCoordinateSystems, extentUtils, LV95 } from '@swissgeo/coordinates'
+import { ALL_YEARS_TIMESTAMP, CURRENT_YEAR_TIMESTAMP, LayerType } from '@swissgeo/layers'
+import { geoJsonUtils, layerUtils } from '@swissgeo/layers/utils'
+import log from '@swissgeo/log'
+import { getApi3BaseUrl } from '@swissgeo/staging-config'
+import {
+ DEFAULT_FEATURE_COUNT_SINGLE_POINT,
+ DEFAULT_FEATURE_IDENTIFICATION_TOLERANCE,
+} from '@swissgeo/staging-config/constants'
+import axios from 'axios'
+import { WMSGetFeatureInfo } from 'ol/format'
+import GeoJSON from 'ol/format/GeoJSON'
+import proj4 from 'proj4'
+
+import type {
+ EditableFeature,
+ GetFeatureOptions,
+ IdentifyConfig,
+ IdentifyResponse,
+ IdentifyResult,
+ LayerFeature,
+} from '@/types/features'
+
+const GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE = 100
+
+const APPLICATION_JSON_TYPE = 'application/json'
+const APPLICATION_GML_3_TYPE = 'application/vnd.ogc.gml'
+const APPLICATION_OGC_WMS_XML_TYPE = 'application/vnd.ogc.wms_xml'
+const PLAIN_TEXT_TYPE = 'text/plain'
+
+/**
+ * The api3 identify endpoint timeInstant parameter doesn't support the "all" and "current"
+ * timestamps, we need to set it to null in this case.
+ *
+ * @param layer
+ */
+function getApi3TimeInstantParam(layer: GeoAdminLayer): string | undefined {
+ if (
+ layer.timeConfig?.currentTimeEntry &&
+ layer.timeConfig.currentTimeEntry.timestamp !== ALL_YEARS_TIMESTAMP &&
+ layer.timeConfig.currentTimeEntry.timestamp !== CURRENT_YEAR_TIMESTAMP
+ ) {
+ return layer.timeConfig.currentTimeEntry.timestamp
+ }
+ return
+}
+
+/**
+ * Extract OL feature coordinates in a format that we support in our application
+ *
+ * @param feature OpenLayers KML feature
+ * @returns Coordinates of the feature, in WGS84
+ */
+function extractOlFeatureCoordinates(feature?: Feature): SingleCoordinate[] {
+ if (!feature) {
+ return []
+ }
+ const geometry = feature.getGeometry()
+ if (!geometry) {
+ return []
+ }
+ let coordinates: number[][] = []
+ if (geometry.getType() === 'Point') {
+ coordinates = [(geometry as Point).getCoordinates()]
+ } else if (geometry.getType() === 'LineString') {
+ coordinates = (geometry as LineString).getCoordinates()
+ } else if (geometry.getType() === 'MultiLineString') {
+ coordinates = (geometry as MultiLineString).getCoordinates().flat(1)
+ } else if (geometry.getType() === 'Polygon') {
+ coordinates = (geometry as Polygon).getCoordinates().flat(1)
+ } else if (geometry.getType() === 'MultiPolygon') {
+ coordinates = (geometry as MultiPolygon).getCoordinates().flat(2)
+ }
+ return coordinates as SingleCoordinate[]
+}
+
+async function identifyOnGeomAdminLayer(identifyConfig: IdentifyConfig): Promise {
+ const {
+ layer,
+ projection,
+ coordinate,
+ screenWidth,
+ screenHeight,
+ mapExtent,
+ lang,
+ featureCount = DEFAULT_FEATURE_COUNT_SINGLE_POINT,
+ offset,
+ } = identifyConfig
+ if (!layer) {
+ throw new GetFeatureInfoError('Missing layer')
+ }
+ if (layer.isExternal) {
+ throw new GetFeatureInfoError('Wrong type of layer')
+ }
+ const geoadminLayer = layer as GeoAdminLayer
+ if (!Array.isArray(coordinate)) {
+ throw new GetFeatureInfoError('Coordinate are required to perform a getFeatureInfo request')
+ }
+ if (!featureCount) {
+ throw new GetFeatureInfoError(
+ 'A feature count is required to perform a getFeatureInfo request'
+ )
+ }
+ if (!lang) {
+ throw new GetFeatureInfoError('A lang is required to build a getFeatureInfo request')
+ }
+ const imageDisplay = `${screenWidth},${screenHeight},96`
+ const identifyResponse = await axios.get(
+ `${getApi3BaseUrl()}rest/services/${layerUtils.getTopicForIdentifyAndTooltipRequests(geoadminLayer)}/MapServer/identify`,
+ {
+ // params described as https://api3.geo.admin.ch/services/sdiservices.html#identify-features
+ params: {
+ layers: `all:${geoadminLayer.id}`,
+ sr: projection.epsgNumber,
+ geometry: coordinate.join(','),
+ mapExtent: mapExtent.join(','),
+ imageDisplay,
+ geometryFormat: 'geojson',
+ geometryType: `esriGeometry${coordinate.length === 2 ? 'Point' : 'Envelope'}`,
+ limit: featureCount,
+ tolerance: DEFAULT_FEATURE_IDENTIFICATION_TOLERANCE,
+ returnGeometry: geoadminLayer.isHighlightable,
+ timeInstant: getApi3TimeInstantParam(geoadminLayer),
+ lang: lang,
+ offset,
+ },
+ }
+ )
+ // firing a getHtmlPopup (async/parallel) on each identified feature
+ const features: LayerFeature[] = []
+ if (identifyResponse.data?.results?.length > 0) {
+ for (const feature of identifyResponse.data.results) {
+ let featureCoordinate: SingleCoordinate | undefined
+ if (coordinate.length === 2) {
+ featureCoordinate = coordinate
+ } else if (coordinate.length === 4) {
+ // taking the center of the extent as coordinate
+ featureCoordinate = [
+ (coordinate[0] + coordinate[2]) / 2,
+ (coordinate[1] + coordinate[3]) / 2,
+ ]
+ }
+ if (!featureCoordinate) {
+ throw new GetFeatureInfoError('Unable to build required feature coordinate')
+ }
+ const featureData = await getFeatureHtmlPopup(geoadminLayer, feature.id, {
+ lang,
+ screenWidth,
+ screenHeight,
+ mapExtent,
+ coordinate: featureCoordinate,
+ })
+ const parsedFeature = parseGeomAdminFeature(
+ geoadminLayer,
+ feature,
+ featureData,
+ projection,
+ {
+ lang,
+ coordinate: featureCoordinate,
+ }
+ )
+ if (parsedFeature) {
+ features.push(parsedFeature)
+ }
+ }
+ }
+ return features
+}
+
+/** @throws GetFeatureInfoError If any part of the input is not valid */
+async function identifyOnExternalLayer(config: IdentifyConfig): Promise {
+ const { layer, coordinate, resolution, tolerance, projection, lang, featureCount } = config
+ if (!layer) {
+ throw new GetFeatureInfoError('Missing layer')
+ }
+ if (
+ !layer.hasTooltip ||
+ !layer.isExternal ||
+ !(layer as ExternalLayer).getFeatureInfoCapability
+ ) {
+ throw new GetFeatureInfoError(`Layer ${layer.id} can't be getFeatureInfo requested`)
+ }
+ if (coordinate === null) {
+ throw new GetFeatureInfoError('Coordinate are required to perform a getFeatureInfo request')
+ }
+ if (resolution === null) {
+ throw new GetFeatureInfoError(
+ 'Map resolution is required to perform a getFeatureInfo request'
+ )
+ }
+ if (featureCount === null) {
+ throw new GetFeatureInfoError(
+ 'A feature count is required to perform a getFeatureInfo request'
+ )
+ }
+ if (tolerance === null) {
+ throw new GetFeatureInfoError('A tolerance is required to perform a getFeatureInfo request')
+ }
+ if (lang === null) {
+ throw new GetFeatureInfoError('A lang is required to build a getFeatureInfo request')
+ }
+ const externalLayer = layer as ExternalLayer
+ // deciding on which projection we should land to ask the WMS server (the current map projection might not be supported)
+ let requestProjection: CoordinateSystem | undefined = projection
+ let requestedCoordinate = coordinate
+ if (!requestProjection) {
+ throw new GetFeatureInfoError('Missing projection to build a getFeatureInfo request')
+ }
+ if (
+ externalLayer.availableProjections &&
+ !externalLayer.availableProjections.some(
+ (availableProjection) => availableProjection.epsg === requestProjection?.epsg
+ )
+ ) {
+ // trying to find a candidate among the app supported projections
+ requestProjection = allCoordinateSystems.find((candidate) =>
+ externalLayer.availableProjections.some(
+ (availableProjection) => availableProjection.epsg === candidate.epsg
+ )
+ )
+ }
+ if (!requestProjection) {
+ throw new GetFeatureInfoError(
+ `No common projection found with external WMS provider, possible projection were ${externalLayer.availableProjections?.map((proj) => proj.epsg).join(', ')}`
+ )
+ }
+ if (requestProjection.epsg !== projection.epsg) {
+ // If we use different projection, we also need to project out initial coordinate
+ requestedCoordinate = proj4(projection.epsg, requestProjection.epsg, coordinate)
+ }
+ if (layer.type === LayerType.WMS) {
+ return await identifyOnExternalWmsLayer({
+ ...config,
+ coordinate: requestedCoordinate,
+ projection: requestProjection,
+ resolution,
+ layer: externalLayer,
+ featureCount,
+ lang,
+ tolerance,
+ outputProjection: projection,
+ })
+ } else {
+ throw new GetFeatureInfoError(
+ `Unsupported external layer type to build getFeatureInfo request: ${layer.type}`
+ )
+ }
+}
+
+// Parse OGC WMS XML response to GeoJSON
+function parseOGCWMSFeatureInfoResponse(data: string): FeatureCollection | undefined {
+ const parser = new DOMParser()
+ const xmlDoc = parser.parseFromString(data, 'text/xml')
+
+ // Check for parsing errors
+ const parserError = xmlDoc.getElementsByTagName('parsererror')
+ if (parserError.length > 0) {
+ log.error('Error parsing OGC WMS XML response')
+ return
+ }
+
+ const features = []
+ const fieldElements = xmlDoc.getElementsByTagName('FIELDS')
+
+ for (let i = 0; i < fieldElements.length; i++) {
+ const fieldElement = fieldElements[i]
+ const properties: { [key: string]: string } = {}
+
+ if (!fieldElement) {
+ continue
+ }
+
+ // Extract attributes from the FIELDS element
+ for (let j = 0; j < fieldElement.attributes.length; j++) {
+ const attribute = fieldElement.attributes[j]
+ if (!attribute) {
+ continue
+ }
+ properties[attribute.name] = attribute.value
+ }
+
+ const feature: GeoJsonFeature = {
+ type: 'Feature',
+ // Assuming geometry is not provided in the response (stubbing it)
+ geometry: {
+ type: 'Point',
+ coordinates: [0, 0],
+ },
+ properties: properties,
+ }
+
+ features.push(feature)
+ }
+
+ return {
+ type: 'FeatureCollection',
+ features: features,
+ }
+}
+
+/**
+ * Runs a getFeatureInfo request on the backend of an external WMS layer.
+ *
+ * To do so, it will create a "fake" 100x100 pixel extent around the given coordinate to build the
+ * request (with the request asking for the pixel 50:50).
+ *
+ * This is done because, after many attempts to give the current map viewport and click pixel
+ * position without any positive results, I looked at what OpenLayers does under the hood, and
+ * that's exactly their approach (and the approach mf-geoadmin3 had, because it relied on the Ol
+ * class to do exactly that).
+ *
+ * And as we wanted to be as framework-agnostic as possible, I couldn't get myself to pass the OL
+ * instance of the WMS source to this function to use the utils directly, and preferred to implement
+ * it on my own.
+ */
+async function identifyOnExternalWmsLayer(config: IdentifyConfig): Promise {
+ const {
+ coordinate,
+ projection,
+ resolution,
+ layer,
+ featureCount,
+ lang,
+ tolerance,
+ outputProjection,
+ } = config
+
+ let requestExtent: FlatExtent | undefined
+
+ if (!coordinate) {
+ throw new GetFeatureInfoError('Missing coordinate to build a getFeatureInfo request')
+ }
+
+ if (coordinate.length === 2) {
+ requestExtent = extentUtils.createPixelExtentAround({
+ size: GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE,
+ coordinate,
+ projection,
+ resolution,
+ })
+ } else if (coordinate.length === 4) {
+ requestExtent = coordinate
+ }
+ if (!requestExtent) {
+ throw new GetFeatureInfoError('Unable to build required request extent')
+ }
+ if (!layer.isExternal) {
+ throw new GetFeatureInfoError('Layer is not external')
+ }
+ const externalLayer = layer as ExternalWMSLayer
+ // selecting output format depending on external WMS capabilities
+ // preferring JSON output
+ let outputFormat = APPLICATION_JSON_TYPE
+ if (!externalLayer.getFeatureInfoCapability?.formats?.includes(outputFormat)) {
+ // if JSON isn't supported, we check if GML3 is supported
+ if (externalLayer.getFeatureInfoCapability?.formats?.includes(APPLICATION_GML_3_TYPE)) {
+ outputFormat = APPLICATION_GML_3_TYPE
+ } else if (
+ externalLayer.getFeatureInfoCapability?.formats?.includes(APPLICATION_OGC_WMS_XML_TYPE)
+ ) {
+ outputFormat = APPLICATION_OGC_WMS_XML_TYPE
+ } else {
+ // if neither JSON nor GML3 are supported, we will ask for plain text
+ outputFormat = PLAIN_TEXT_TYPE
+ }
+ }
+ // params described as https://docs.geoserver.org/2.22.x/en/user/services/wms/reference.html#getfeatureinfo
+ const params: { [key: string]: string | number | string[] | undefined } = {
+ SERVICE: 'WMS',
+ VERSION: externalLayer.wmsVersion ?? '1.3.0',
+ REQUEST: 'GetFeatureInfo',
+ LAYERS: layer.id,
+ CRS: projection.epsg,
+ BBOX: requestExtent.join(','),
+ WIDTH: GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE,
+ HEIGHT: GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE,
+ QUERY_LAYERS: layer.id,
+ INFO_FORMAT: outputFormat,
+ FEATURE_COUNT: featureCount,
+ LANG: lang,
+ // Trying to activate a tolerance feature, guessing which are the main WMS server running environment
+ // and looking at their documentation to see if it is supported.
+ // ***************
+ // It wasn't clear if MapServer used TOLERANCE directly, but that's what shows up in mapfiles in their doc
+ // see: https://mapserver.org/mapfile/cluster.html#handling-getfeatureinfo
+ TOLERANCE: tolerance,
+ // Tolerance param for GeoServer
+ // see: https://docs.geoserver.org/main/en/user/services/wms/reference.html#getfeatureinfo
+ BUFFER: tolerance,
+ // Tolerance param for QGIS server
+ // see: https://docs.qgis.org/3.34/en/docs/server_manual/services/wms.html#wms-getfeatureinfo
+ FI_POINT_TOLERANCE: tolerance,
+ FI_LINE_TOLERANCE: tolerance,
+ FI_POLYGON_TOLERANCE: tolerance,
+ // tried to no avail finding this in degree doc (https://download.deegree.org/documentation/3.5.5/html)
+ // there might exist more implementation of WMS, but I stopped there looking for more
+ // (please add more if you think one of our customer/external layer providers uses another flavor of WMS)
+ }
+ // In WMS "all" years mean no TIME parameter
+ if (
+ externalLayer.timeConfig?.currentTimeEntry &&
+ externalLayer.timeConfig.currentTimeEntry?.timestamp !== ALL_YEARS_TIMESTAMP
+ ) {
+ params.TIME = externalLayer.timeConfig.currentTimeEntry.timestamp
+ }
+ // WMS 1.3.0 uses i,j to describe pixel coordinate where we want feature info
+ if (params.VERSION === '1.3.0') {
+ params.I = GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE / 2
+ params.J = GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE / 2
+ } else {
+ // older WMS versions use x,y instead
+ params.X = GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE / 2
+ params.Y = GET_FEATURE_INFO_FAKE_VIEWPORT_SIZE / 2
+ }
+ const getFeatureInfoResponse = await axios({
+ method: externalLayer.getFeatureInfoCapability?.method,
+ url: externalLayer.getFeatureInfoCapability?.baseUrl,
+ params,
+ })
+ if (getFeatureInfoResponse.data) {
+ let features: GeoJsonFeature[] = []
+ if (outputFormat === APPLICATION_GML_3_TYPE) {
+ // transforming GML3 features into OL features, and then back to GeoJSON features
+ features = new GeoJSON().writeFeaturesObject(
+ new WMSGetFeatureInfo().readFeatures(getFeatureInfoResponse.data, {
+ dataProjection: projection.epsg,
+ })
+ )?.features
+ } else if (outputFormat === APPLICATION_JSON_TYPE) {
+ // nothing to do other than extracting the data
+ features = getFeatureInfoResponse.data.features
+ } else if (outputFormat === APPLICATION_OGC_WMS_XML_TYPE) {
+ const getFeatureInfoFeatures = parseOGCWMSFeatureInfoResponse(
+ getFeatureInfoResponse.data
+ )
+ if (getFeatureInfoFeatures) {
+ features = getFeatureInfoFeatures.features
+ }
+ } else if (outputFormat === PLAIN_TEXT_TYPE) {
+ // TODO : implement plain text parsing
+ log.error('Plain text parsing not yet implemented')
+ }
+ return features?.map((feature) => {
+ let geometry: Geometry = feature.geometry
+ // if no geometry is defined (because we came from a GML or plain text parsing),
+ // we use the click coordinate as a point
+ if (!geometry) {
+ geometry = {
+ type: 'Point',
+ coordinates: [...coordinate],
+ }
+ }
+ const {
+ id = null,
+ identifier = null,
+ title = null,
+ name = null,
+ label = null,
+ } = feature.properties ?? {}
+ const featureId = feature.id ?? id ?? identifier ?? title ?? name ?? label
+ const featureName = label ?? name ?? title ?? identifier ?? id ?? featureId
+ const layerFeature: LayerFeature = {
+ isEditable: false,
+ layer,
+ id: featureId,
+ title: featureName,
+ data: feature.properties ?? undefined,
+ coordinates: geoJsonUtils.getGeoJsonFeatureCenter(
+ geometry,
+ projection,
+ outputProjection ?? projection
+ ),
+ geometry: geoJsonUtils.reprojectGeoJsonGeometry(
+ geometry,
+ outputProjection ?? projection,
+ projection
+ ),
+ popupDataCanBeTrusted: !layer.isExternal && layer.type !== LayerType.KML,
+ }
+ return layerFeature
+ })
+ }
+ return []
+}
+
+/**
+ * Asks the backend for identification of features at the coordinates for the given layer using
+ * http://api3.geo.admin.ch/services/sdiservices.html#identify-features or the
+ * {@link getFeatureInfoCapability} of an external layer
+ */
+function identify(config: IdentifyConfig): Promise {
+ const {
+ layer,
+ coordinate,
+ mapExtent,
+ screenWidth,
+ screenHeight,
+ featureCount = DEFAULT_FEATURE_COUNT_SINGLE_POINT,
+ tolerance = DEFAULT_FEATURE_IDENTIFICATION_TOLERANCE,
+ } = config
+ return new Promise((resolve, reject) => {
+ if (!layer?.id) {
+ log.error('Invalid layer', layer)
+ reject(new Error('Needs a valid layer with an ID'))
+ }
+ if (!layer.hasTooltip) {
+ log.error('Non queriable layer/no tooltip on this layer', layer)
+ reject(new Error('Non queriable layer/no tooltip on this layer'))
+ }
+ if (!Array.isArray(coordinate) || (coordinate.length !== 2 && coordinate.length !== 4)) {
+ log.error('Invalid coordinate', coordinate)
+ reject(new Error('Needs a valid coordinate to run identification'))
+ }
+ if (!Array.isArray(mapExtent) || mapExtent.length !== 4) {
+ log.error('Invalid extent', mapExtent)
+ reject(new Error('Needs a valid map extent to run identification'))
+ }
+ if (screenWidth <= 0 || screenHeight <= 0) {
+ log.error('Invalid screen size', screenWidth, screenHeight)
+ reject(new Error('Needs valid screen width and height to run identification'))
+ }
+ if (!layer.isExternal) {
+ identifyOnGeomAdminLayer({ ...config, tolerance, featureCount })
+ .then(resolve)
+ .catch((error) => {
+ log.error("Wasn't able to get feature from GeoAdmin layer", layer, error)
+ reject(new Error(error))
+ })
+ } else if (layer.isExternal) {
+ identifyOnExternalLayer({
+ ...config,
+ tolerance,
+ featureCount,
+ })
+ .then(resolve)
+ .catch((error) => {
+ log.error("Wasn't able to get feature from external layer", layer, error)
+ reject(new Error(error))
+ })
+ } else {
+ reject(new Error('Unknown layer type, cannot perform identify'))
+ }
+ })
+}
+
+/**
+ * @param layer The layer from which the feature is part of
+ * @param featureId The feature ID in the BGDI
+ */
+function generateFeatureUrl(layer: GeoAdminLayer, featureId: string | number): string {
+ return `${getApi3BaseUrl()}rest/services/${layerUtils.getTopicForIdentifyAndTooltipRequests(layer)}/MapServer/${layer.id}/${featureId}`
+}
+
+/**
+ * Generates parameters used to request endpoint to get a single feature's data and endpoint to get
+ * a single feature's HTML popup. As some layers have a resolution-dependent answer, we have to give
+ * the map extent and the current screen size with each request.
+ */
+function generateFeatureParams(options: GetFeatureOptions = {}): {
+ [key: string]: number | string | undefined
+} {
+ const { lang = 'en', screenWidth, screenHeight, mapExtent, coordinate } = options
+ let imageDisplay: string | undefined
+ if (screenWidth && screenHeight) {
+ imageDisplay = `${screenWidth},${screenHeight},96`
+ }
+ return {
+ sr: LV95.epsgNumber,
+ lang,
+ imageDisplay,
+ mapExtent: mapExtent?.join(','),
+ coord: coordinate?.join(','),
+ }
+}
+
+/**
+ * @param layer The layer to which this feature belongs to
+ * @param featureMetadata The backend response (either identify, or feature-resource) for this
+ * feature
+ * @param featureHtmlPopup The backend response for the getHtmlPopup endpoint for this feature
+ * @param outputProjection In which projection the feature should be in.
+ * @param options
+ */
+function parseGeomAdminFeature(
+ layer: GeoAdminLayer,
+ featureMetadata: IdentifyResult,
+ featureHtmlPopup: string,
+ outputProjection: CoordinateSystem,
+ options: GetFeatureOptions = {}
+) {
+ const { lang = 'en', coordinate } = options
+ let featureExtent: FlatExtent | undefined
+ if (featureMetadata.bbox) {
+ featureExtent = extentUtils.flattenExtent(featureMetadata.bbox)
+ }
+ let featureName: string | undefined = featureMetadata.id
+ if (featureMetadata.properties) {
+ const { name, title, label } = featureMetadata.properties
+ const titleInCurrentLang = featureMetadata.properties[`title_${lang}`]
+ if (label) {
+ featureName = label
+ } else if (name) {
+ featureName = name as string
+ } else if (title) {
+ featureName = title as string
+ } else if (titleInCurrentLang) {
+ featureName = titleInCurrentLang as string
+ }
+ }
+
+ if (outputProjection.epsg !== LV95.epsg && featureExtent?.length === 4) {
+ featureExtent = extentUtils.projExtent(LV95, outputProjection, featureExtent)
+ }
+
+ let featureGeoJSONGeometry: Geometry | undefined
+ if (layer.isHighlightable) {
+ featureGeoJSONGeometry = featureMetadata.geometry
+ } else if (coordinate) {
+ featureGeoJSONGeometry = {
+ type: 'MultiPoint',
+ coordinates: [coordinate],
+ }
+ }
+ let center: SingleCoordinate | undefined
+ if (featureGeoJSONGeometry) {
+ center = geoJsonUtils.getGeoJsonFeatureCenter(
+ featureGeoJSONGeometry,
+ LV95,
+ outputProjection
+ )
+ } else if (coordinate) {
+ if (coordinate.length === 4) {
+ center = extentUtils.getExtentCenter(coordinate)
+ } else {
+ center = coordinate
+ }
+ }
+ if (!center) {
+ log.error('Unable to get center for feature', featureMetadata, options)
+ return
+ }
+
+ const layerFeature: LayerFeature = {
+ isEditable: false,
+ layer,
+ id: featureMetadata.id,
+ title: featureName,
+ popupData: featureHtmlPopup,
+ coordinates: center,
+ extent: featureExtent,
+ geometry: featureGeoJSONGeometry,
+ popupDataCanBeTrusted: !layer.isExternal && layer.type !== LayerType.KML,
+ }
+ return layerFeature
+}
+/**
+ * Loads a feature metadata and tooltip content from this two endpoint of the backend
+ *
+ * - https://api3.geo.admin.ch/services/sdiservices.html#feature-resource
+ * - http://api3.geo.admin.ch/services/sdiservices.html#htmlpopup-resource
+ *
+ * @param layer The layer from which the feature is part of
+ * @param featureId The feature ID in the BGDI
+ * @param outputProjection Projection in which the coordinates (and possible extent) of the features
+ * should be expressed
+ * @param options
+ */
+function getFeature(
+ layer: GeoAdminLayer,
+ featureId: string | number,
+ outputProjection: CoordinateSystem,
+ options: GetFeatureOptions = {}
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (!layer?.id) {
+ reject(new Error('Needs a valid layer with an ID'))
+ }
+ if (!featureId) {
+ reject(new Error(`Needs a valid feature ID, got ${featureId} instead`))
+ }
+ if (!outputProjection) {
+ reject(new Error('An output projection is required'))
+ }
+ const allRequests: Promise[] = []
+ allRequests.push(
+ axios.get(generateFeatureUrl(layer, featureId), {
+ params: {
+ geometryFormat: 'geojson',
+ ...generateFeatureParams(options),
+ },
+ }),
+ getFeatureHtmlPopup(layer, featureId, options)
+ )
+ Promise.all(allRequests)
+ .then((responses: unknown[]) => {
+ const getFeatureResponse = responses[0] as AxiosResponse
+ const featureHtmlPopup = responses[1] as string
+ const featureMetadata = getFeatureResponse.data.feature ?? getFeatureResponse.data
+ const parsedFeature = parseGeomAdminFeature(
+ layer,
+ featureMetadata,
+ featureHtmlPopup,
+ outputProjection,
+ options
+ )
+ if (parsedFeature) {
+ resolve(parsedFeature)
+ } else {
+ reject(new Error('Unable to parse feature'))
+ }
+ })
+ .catch((error) => {
+ log.error(
+ 'Error while requesting a feature to the backend',
+ layer,
+ featureId,
+ error
+ )
+ reject(new Error(error))
+ })
+ })
+}
+
+/**
+ * Retrieves the HTML popup of a feature (the backend builds it for us).
+ *
+ * As the request's outcome is dependent on the resolution, we have to give the screen size and map
+ * extent with the request.
+ */
+function getFeatureHtmlPopup(
+ layer: GeoAdminLayer,
+ featureId: string | number,
+ options: GetFeatureOptions
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (!layer?.id) {
+ reject(new Error('Needs a valid layer with an ID'))
+ }
+ if (!featureId) {
+ reject(new Error('Needs a valid feature ID'))
+ }
+ axios
+ .get(`${generateFeatureUrl(layer, featureId)}/htmlPopup`, {
+ params: generateFeatureParams(options),
+ })
+ .then((response) => {
+ resolve(response.data)
+ })
+ .catch((error) => {
+ log.error(
+ 'Error while requesting a the HTML popup of a feature to the backend',
+ layer,
+ featureId,
+ error
+ )
+ reject(new Error(error))
+ })
+ })
+}
+
+function isLineOrMeasure(feature: EditableFeature) {
+ return feature.featureType === 'MEASURE' || feature.featureType === 'LINEPOLYGON'
+}
+
+/**
+ * Error when building or requesting an external layer's getFeatureInfo endpoint
+ *
+ * This class also contains an i18n translation key in plus of a technical English message. The
+ * translation key can be used to display a translated user message.
+ *
+ * @property {String} message Technical english message
+ * @property {String} key I18n translation key for a user message
+ */
+export class GetFeatureInfoError extends Error {
+ readonly key: string | undefined
+
+ constructor(message?: string, key?: string) {
+ super(message)
+ this.key = key
+ this.name = 'GetFeatureInfoError'
+ }
+}
+
+export const featuresAPI = {
+ extractOlFeatureCoordinates,
+ identifyOnGeomAdminLayer,
+ identify,
+ getFeature,
+ getFeatureHtmlPopup,
+ isLineOrMeasure,
+}
+export default featuresAPI
diff --git a/packages/api/src/feedback.ts b/packages/api/src/feedback.ts
new file mode 100644
index 0000000000..0b7868ac1e
--- /dev/null
+++ b/packages/api/src/feedback.ts
@@ -0,0 +1,91 @@
+import log from '@swissgeo/log'
+import { getViewerDedicatedServicesBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+
+import type { FeedbackOptions } from '@/types'
+
+import LogColorPerService from '@/config/log'
+import filesAPI from '@/files'
+
+/** The maximum size (in bytes) allowed by the backend service. Can be used to do validation up front */
+export const ATTACHMENT_MAX_SIZE: number = 10 * 1024 * 1024
+export const KML_MAX_SIZE: number = 2 * 1024 * 1024
+
+const logTitle = { title: 'Feedback API', titleColor: LogColorPerService.feedback }
+
+/** @returns True if successful, false otherwise */
+export async function sendFeedback(
+ subject: string,
+ text: string,
+ options: FeedbackOptions
+): Promise {
+ const { appVersion, category, kmlFileUrl, kml, email, attachment } = options
+
+ try {
+ let kmlData: string | undefined
+ if (kmlFileUrl) {
+ try {
+ kmlData = await filesAPI.getKmlFromUrl(kmlFileUrl)
+ } catch (err) {
+ log.error({
+ ...logTitle,
+ messages: [
+ 'could not load KML from URL',
+ kmlFileUrl,
+ 'will not send KML with feedback',
+ err,
+ ],
+ })
+ }
+ } else {
+ kmlData = kml
+ }
+
+ const data = {
+ subject,
+ feedback: text,
+ category,
+ version: appVersion ?? 'unknown version',
+ ua: navigator.userAgent,
+ permalink: window.location.href,
+ kml: kmlData,
+ email,
+ attachment,
+ }
+ log.debug({
+ ...logTitle,
+ messages: ['sending feedback with', data],
+ })
+ const response = await axios.post(`${getViewerDedicatedServicesBaseUrl()}feedback`, data, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+ const success = response?.data?.success
+ if (success) {
+ log.info({
+ ...logTitle,
+ messages: ['Feedback sent successfully'],
+ })
+ } else {
+ log.error({
+ ...logTitle,
+ messages: ['Something went wrong while processing this feedback', response],
+ })
+ }
+ return success
+ } catch (err) {
+ log.error({
+ ...logTitle,
+ messages: ['Error while sending feedback', err],
+ })
+ return false
+ }
+}
+
+export const feedbackAPI = {
+ ATTACHMENT_MAX_SIZE,
+ KML_MAX_SIZE,
+ sendFeedback,
+}
+export default feedbackAPI
diff --git a/packages/api/src/fileProxy.ts b/packages/api/src/fileProxy.ts
new file mode 100644
index 0000000000..72ad79ee26
--- /dev/null
+++ b/packages/api/src/fileProxy.ts
@@ -0,0 +1,90 @@
+import log from '@swissgeo/log'
+import { getServiceProxyBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+import { isString } from 'lodash'
+
+import LogColorPerService from '@/config/log'
+
+const dropboxPattern = /^(https?:\/\/(www\.)?dropbox\.com\/.+)/
+
+const logConfig = {
+ title: 'File proxy API',
+ titleColor: LogColorPerService.fileProxy,
+}
+
+/**
+ * Transform a Dropbox URL to a direct download link, replacing dl=0 by dl=1
+ *
+ * @see https://www.dropbox.com/help/desktop-web/force-download
+ */
+function transformDropboxUrl(fileUrl: string): string {
+ if (dropboxPattern.test(fileUrl)) {
+ try {
+ const url = new URL(fileUrl)
+ const params = new URLSearchParams(url.search)
+ if (params.get('dl') === '0') {
+ params.set('dl', '1')
+ url.search = params.toString()
+ return url.toString()
+ }
+ } catch (e) {
+ log.debug({
+ ...logConfig,
+ messages: ['failed to transformDropboxUrl', e],
+ })
+ return fileUrl
+ }
+ }
+ return fileUrl
+}
+
+/**
+ * Transform our file URL into a path, compatible with a call to service-proxy
+ *
+ * @see https://github.com/geoadmin/service-proxy?tab=readme-ov-file#using-the-proxy
+ */
+function transformFileUrl(fileUrl: string): string | undefined {
+ if (!isString(fileUrl)) {
+ return
+ }
+ // copy from https://github.com/geoadmin/mf-geoadmin3/blob/master/src/components/UrlUtilsService.js#L59-L69
+ const parts = /^(http|https)(:\/\/)(.+)/.exec(transformDropboxUrl(fileUrl))
+ if (!parts || !parts.length || parts.length < 4) {
+ return
+ }
+ const protocol = parts[1]
+ const resource = parts[3]
+ return `${protocol}/${encodeURIComponent(resource)}`
+}
+
+function proxifyUrl(url: string): string {
+ const fileAsPath = transformFileUrl(url)
+ if (!fileAsPath) {
+ throw new Error(`Malformed URL: ${url}, can't proxify`)
+ }
+ return `${getServiceProxyBaseUrl()}${fileAsPath}`
+}
+
+function unProxifyUrl(proxifiedUrl: string): string {
+ if (proxifiedUrl.startsWith(getServiceProxyBaseUrl())) {
+ const url = proxifiedUrl.replace(getServiceProxyBaseUrl(), '')
+ return `${url.split('/')[0]}://${decodeURIComponent(url.split('/')[1])}`
+ }
+
+ return proxifiedUrl
+}
+
+async function getFileContentThroughServiceProxy(fileUrl: string): Promise {
+ const proxifyGetResponse = await axios.get(proxifyUrl(fileUrl), {
+ responseType: 'arraybuffer',
+ })
+ return proxifyGetResponse.data
+}
+
+export const fileProxyAPI = {
+ transformFileUrl,
+ proxifyUrl,
+ unProxifyUrl,
+ getFileContentThroughServiceProxy,
+}
+export default fileProxyAPI
diff --git a/packages/api/src/files.ts b/packages/api/src/files.ts
new file mode 100644
index 0000000000..69b996f266
--- /dev/null
+++ b/packages/api/src/files.ts
@@ -0,0 +1,426 @@
+import type { KMLLayer, KMLMetadata } from '@swissgeo/layers'
+import type { Staging } from '@swissgeo/staging-config'
+
+import log from '@swissgeo/log'
+import { getServiceKmlBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+import FormData from 'form-data'
+import { gzip } from 'pako'
+
+import type { FileAPIMetadataResponse, OnlineFileCompliance } from '@/types/files'
+
+import LogColorPerService from '@/config/log'
+import fileProxyAPI from '@/fileProxy'
+
+const logConfig = (functionName: string) => ({
+ title: `KML File API / ${functionName}`,
+ titleColor: LogColorPerService.files,
+})
+
+function generateKMLMetadataFromAPIData(
+ data: FileAPIMetadataResponse,
+ preExistingAdminId?: string
+): KMLMetadata {
+ let id: string | undefined
+ let adminId: string | undefined = preExistingAdminId
+ let created: Date | undefined
+ let updated: Date | undefined
+ let author: string | undefined
+ let authorVersion: string | undefined
+
+ if ('id' in data) {
+ id = data.id
+ }
+ if (!adminId && 'admin_id' in data) {
+ adminId = data.admin_id
+ }
+ if ('created' in data && typeof data.created === 'string') {
+ created = new Date(data.created)
+ }
+ if ('updated' in data && typeof data.updated === 'string') {
+ updated = new Date(data.updated)
+ }
+ if ('author' in data && typeof data.author === 'string') {
+ author = data.author
+ }
+ if ('author_version' in data && typeof data.author_version === 'string') {
+ authorVersion = data.author_version
+ }
+ if (!id || !created || !updated || !author || !authorVersion) {
+ throw new Error(`Missing required fields in KML metadata response: ${JSON.stringify(data)}`)
+ }
+
+ let metadataLink: string | undefined
+ let kmlLink: string | undefined
+
+ if ('links' in data) {
+ const links = data.links
+ if ('self' in links && typeof links.self === 'string') {
+ metadataLink = links.self
+ }
+ if ('kml' in links && typeof links.kml === 'string') {
+ kmlLink = links.kml
+ }
+ }
+
+ if (!metadataLink) {
+ throw new Error(`Missing metadata link in KML metadata response: ${JSON.stringify(data)}`)
+ }
+ if (!kmlLink) {
+ throw new Error(`Missing KML link in KML metadata response: ${JSON.stringify(data)}`)
+ }
+
+ return {
+ id,
+ adminId,
+ created,
+ updated,
+ author,
+ authorVersion,
+ links: {
+ metadata: metadataLink,
+ kml: kmlLink,
+ },
+ }
+}
+
+// using a function so that any URL override made while using the app will be taken into account
+function getKMLBaseUrl(staging: Staging = 'production'): string {
+ return `${getServiceKmlBaseUrl(staging)}api/kml/`
+}
+
+type PromiseReject = (reason: Error) => void
+
+function validateId(id: string | undefined, reject: PromiseReject) {
+ if (!id) {
+ const errorMessage = `Needs a valid kml ID`
+ log.error({
+ ...logConfig('validateId'),
+ messages: [errorMessage, id],
+ })
+ reject(new Error(errorMessage))
+ }
+}
+
+function validateAdminId(adminId: string | undefined, reject: PromiseReject) {
+ if (!adminId) {
+ const errorMessage = `Needs a valid kml adminId`
+ log.error({
+ ...logConfig('validateAdminId'),
+ messages: [errorMessage, adminId],
+ })
+ reject(new Error(errorMessage))
+ }
+}
+
+function validateKmlContent(kmlContent: string | undefined, reject: PromiseReject) {
+ if (!kmlContent || !kmlContent.length) {
+ const errorMessage = `Needs a valid KML`
+ log.error({
+ ...logConfig('validateKmlContent'),
+ messages: [errorMessage, kmlContent],
+ })
+ reject(new Error(errorMessage))
+ }
+}
+
+function generateFormDataForKML(kmlContent: string, reject: PromiseReject): FormData {
+ validateKmlContent(kmlContent, reject)
+ const form = new FormData()
+ const kmz = gzip(kmlContent)
+ const blob = new Blob([kmz], { type: 'application/vnd.google-earth.kml+xml' })
+ form.append('kml', blob)
+ form.append('author', 'web-mapviewer')
+ form.append('author_version', '1.0.0')
+ return form
+}
+
+/**
+ * Get KML file URL on service-kml backend/S3 bucket
+ *
+ * @param id KML ID
+ * @returns URL to the KML file on our service-kml backend
+ */
+function getKmlUrl(id: string, staging: Staging = 'production'): string {
+ return `${getKMLBaseUrl(staging)}files/${id}`
+}
+
+/** Publish a new KML on the backend and receives back the metadata of the new file */
+function createKml(kmlContent: string, staging: Staging = 'production'): Promise {
+ return new Promise((resolve, reject) => {
+ const form = generateFormDataForKML(kmlContent, reject)
+ axios
+ .post(`${getKMLBaseUrl(staging)}admin`, form)
+ .then((response) => {
+ if (
+ response.status === 201 &&
+ response.data &&
+ response.data.id &&
+ response.data.admin_id
+ ) {
+ resolve(generateKMLMetadataFromAPIData(response.data))
+ } else {
+ const msg = 'Incorrect response while creating a file'
+ log.error({
+ ...logConfig('createKml'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('createKml'),
+ messages: ['Error while creating a file', kmlContent, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/** Update a KML on the backend */
+function updateKml(id: string, adminId: string, kmlContent: string, staging: Staging = 'production'): Promise {
+ return new Promise((resolve, reject) => {
+ validateId(id, reject)
+ validateAdminId(adminId, reject)
+ const form = generateFormDataForKML(kmlContent, reject)
+ form.append('admin_id', adminId)
+ axios
+ .put(`${getKMLBaseUrl(staging)}admin/${id}`, form)
+ .then((response) => {
+ if (
+ response.status === 200 &&
+ response.data &&
+ response.data.id &&
+ response.data.admin_id
+ ) {
+ resolve(generateKMLMetadataFromAPIData(response.data))
+ } else {
+ const msg = `Incorrect response while updating file with id=${id}`
+ log.error({
+ ...logConfig('updateKml'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('updateKml'),
+ messages: ['Error while updating file with id=', id, kmlContent, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/** Delete a KML on the backend */
+function deleteKml(id: string, adminId: string, staging: Staging = 'production'): Promise {
+ return new Promise((resolve, reject) => {
+ validateId(id, reject)
+ validateAdminId(adminId, reject)
+ const form = new FormData()
+ form.append('admin_id', adminId)
+ axios
+ .request({
+ method: 'DELETE',
+ url: `${getKMLBaseUrl(staging)}admin/${id}`,
+ data: form,
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ })
+ .then((response) => {
+ if (response.status === 200 && response.data.id) {
+ resolve()
+ } else {
+ const msg = `Incorrect response while deleting file with id=${id}`
+ log.error({
+ ...logConfig('deleteKml'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('deleteKml'),
+ messages: ['Error while deleting file with id=', id, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/** Get the KML file's content from the given URL */
+function getKmlFromUrl(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ axios
+ .get(url)
+ .then((response) => {
+ if (response.status === 200 && response.data) {
+ resolve(response.data)
+ } else {
+ const msg = `Incorrect response while getting file with url=${url}`
+ log.error({
+ ...logConfig('getKmlFromUrl'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('getKmlFromUrl'),
+ messages: ['Error while getting file with url=', url, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/** Get the KML's metadata by its adminId */
+function getKmlMetadataByAdminId(adminId: string, staging: Staging = 'production'): Promise {
+ return new Promise((resolve, reject) => {
+ validateAdminId(adminId, reject)
+ axios
+ .get(`${getKMLBaseUrl(staging)}admin`, {
+ params: {
+ admin_id: adminId,
+ },
+ })
+ .then((response) => {
+ if (response.status === 200 && response.data) {
+ resolve(generateKMLMetadataFromAPIData(response.data))
+ } else {
+ const msg = `Incorrect response while getting metadata for kml admin_id=${adminId}`
+ log.error({
+ ...logConfig('getKmlMetadataByAdminId'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('getKmlMetadataByAdminId'),
+ messages: ['Error while getting metadata for kml admin_id=', adminId, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/**
+ * Get KML metadata for a KML layer (using its fileId to request the backend)
+ *
+ * If this KML file is not managed by our infrastructure (e.g., external KML), this will reject the
+ * request (the promise will be rejected)
+ */
+function loadKmlMetadata(kmlLayer: KMLLayer, staging: Staging = 'production'): Promise {
+ return new Promise((resolve, reject) => {
+ if (!kmlLayer) {
+ reject(new Error('Missing KML layer, cannot load metadata'))
+ }
+ if (!kmlLayer.fileId || kmlLayer.isExternal) {
+ reject(
+ new Error(
+ `This KML is not one managed by our infrastructure, metadata loading is not possible ${kmlLayer.id}`
+ )
+ )
+ }
+ axios
+ .get(`${getKMLBaseUrl(staging)}admin/${kmlLayer.fileId}`)
+ .then((response) => {
+ if (response.status === 200 && response.data) {
+ const metadata = generateKMLMetadataFromAPIData(response.data, kmlLayer.adminId)
+ resolve(metadata)
+ } else {
+ const msg = `Incorrect response while getting metadata for KML layer ${kmlLayer.id}`
+ log.error({
+ ...logConfig('loadKmlMetadata'),
+ messages: [msg, response],
+ })
+ reject(new Error(msg))
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig('loadKmlMetadata'),
+ messages: ['Error while getting metadata for KML layer', kmlLayer.id, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+/** Load the content of a file from a given URL as ArrayBuffer. */
+async function getFileContentFromUrl(url: string): Promise {
+ const response = await axios.get(url, { responseType: 'arraybuffer' })
+ return response.data
+}
+
+/**
+ * Get a file MIME type through a HEAD request, and reading the Content-Type header returned by this
+ * request. Returns `null` if the HEAD request failed, or if no Content-Type header is set.
+ *
+ * Will attempt to get the HEAD request through service-proxy if the first HEAD request failed.
+ *
+ * Will return if the first HEAD request was successful through a boolean called `supportCORS`, if
+ * this is `true` it means that the first HEAD request could go through CORS checks.
+ *
+ * Also returns a flag telling if the file supports HTTPS or not.
+ */
+async function checkOnlineFileCompliance(url: string): Promise {
+ const supportsHTTPS = url.startsWith('https://')
+ if (supportsHTTPS) {
+ try {
+ const headResponse = await axios.head(url)
+ let mimeType: string | undefined
+ if (headResponse?.headers) {
+ mimeType = headResponse.headers['content-type']
+ }
+ return {
+ mimeType,
+ supportsCORS: true,
+ supportsHTTPS,
+ }
+ } catch (error) {
+ log.error({
+ ...logConfig('checkOnlineFileCompliance'),
+ messages: ['HEAD request on URL', url, 'failed with', error],
+ })
+ }
+ }
+ try {
+ const proxyHeadResponse = await axios.head(fileProxyAPI.proxifyUrl(url))
+ let mimeType: string | undefined
+ if (proxyHeadResponse?.headers) {
+ mimeType = proxyHeadResponse.headers['content-type']
+ }
+ return {
+ mimeType,
+ supportsCORS: false,
+ supportsHTTPS,
+ }
+ } catch (errorWithProxy) {
+ log.error({
+ ...logConfig('checkOnlineFileCompliance'),
+ messages: ['HEAD request through proxy on URL', url, 'failed with', errorWithProxy],
+ })
+ return { mimeType: undefined, supportsCORS: false, supportsHTTPS }
+ }
+}
+
+export const filesAPI = {
+ getKmlUrl,
+ createKml,
+ updateKml,
+ deleteKml,
+ getKmlFromUrl,
+ getKmlMetadataByAdminId,
+ loadKmlMetadata,
+ getFileContentFromUrl,
+ checkOnlineFileCompliance,
+}
+export default filesAPI
diff --git a/packages/api/src/height.ts b/packages/api/src/height.ts
new file mode 100644
index 0000000000..92b78f1584
--- /dev/null
+++ b/packages/api/src/height.ts
@@ -0,0 +1,72 @@
+import type { CoordinateSystem, SingleCoordinate } from '@swissgeo/coordinates'
+
+import { LV95 } from '@swissgeo/coordinates'
+import log from '@swissgeo/log'
+import { round } from '@swissgeo/numbers'
+import { getApi3BaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+import proj4 from 'proj4'
+
+import type { HeightForPosition } from '@/types/height'
+
+import LogColorPerService from '@/config/log'
+
+const meterToFeetFactor: number = 3.28084
+
+const logConfig = {
+ title: 'Height API',
+ titleColor: LogColorPerService.height,
+}
+
+/**
+ * Get the height of the given coordinate from the backend
+ *
+ * @param coordinates Coordinates of the point we want to know the height of
+ * @param projection The projection in which this point is expressed
+ * @returns The height for the given coordinate
+ */
+function getHeightForPosition(
+ coordinates: SingleCoordinate,
+ projection: CoordinateSystem
+): Promise {
+ return new Promise((resolve, reject) => {
+ if (coordinates && Array.isArray(coordinates) && coordinates.length === 2) {
+ // this service only functions with LV95 coordinate, so we have to re-project the input to be sure
+ // we are giving it LV95 coordinates
+ const lv95coords = proj4(projection.epsg, LV95.epsg, coordinates)
+ axios
+ .get(`${getApi3BaseUrl()}rest/services/height`, {
+ params: {
+ easting: lv95coords[0],
+ northing: lv95coords[1],
+ },
+ })
+ .then((heightResponse) => {
+ resolve({
+ coordinates,
+ heightInMeter: heightResponse.data.height,
+ heightInFeet: round(heightResponse.data.height * meterToFeetFactor, 1),
+ })
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig,
+ messages: ['Error while retrieving height for', coordinates, error],
+ })
+ reject(new Error(error))
+ })
+ } else {
+ const errorMessage = 'Invalid coordinates, no height requested'
+ log.error({
+ ...logConfig,
+ messages: ['Invalid coordinates, no height requested', coordinates],
+ })
+ reject(new Error(errorMessage))
+ }
+ })
+}
+
+export const heightAPI = {
+ getHeightForPosition,
+}
+export default heightAPI
diff --git a/packages/api/src/icons.ts b/packages/api/src/icons.ts
new file mode 100644
index 0000000000..170f6c75e1
--- /dev/null
+++ b/packages/api/src/icons.ts
@@ -0,0 +1,136 @@
+import log from '@swissgeo/log'
+import { getViewerDedicatedServicesBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+
+import type {
+ DrawingIcon,
+ DrawingIconSet,
+ IconAPIIconSetsResponse,
+ IconAPIIconsResponse,
+} from '@/types/icons'
+
+import LogColorPerService from '@/config/log'
+
+const logConfig = {
+ title: 'Icons API',
+ titleBackgroundColor: LogColorPerService.icons,
+}
+
+const REGEX_COLOR_HEX = /^#[0-9A-F]{6}$/i
+
+function isValidHexColor(color: string): boolean {
+ return REGEX_COLOR_HEX.test(color)
+}
+
+function convertToRgb(hexColor: string): { r: number; g: number; b: number } {
+ if (isValidHexColor(hexColor)) {
+ const hexWithoutHash = hexColor.substring(1)
+ return {
+ r: parseInt(hexWithoutHash.substring(0, 2), 16),
+ g: parseInt(hexWithoutHash.substring(2, 4), 16),
+ b: parseInt(hexWithoutHash.substring(4), 16),
+ }
+ }
+ return {
+ r: 256,
+ g: 0,
+ b: 0,
+ }
+}
+
+/**
+ * Generate an icon URL from its template. If no iconScale is given, the default scale 1 will be
+ * applied. If no iconColor is given, red will be applied (if applicable, as non-colorable icons
+ * will not have {r}, {g}, {b} part of their template URL)
+ *
+ * @returns A full URL to this icon on the service-icons backend
+ */
+function generateIconURL(icon: DrawingIcon, iconColor: string = '#ff0000') {
+ const checkedColor = isValidHexColor(iconColor) ? iconColor : '#ff0000'
+ const rgb = convertToRgb(checkedColor)
+ return (
+ icon.imageTemplateURL
+ .replace('{icon_set_name}', icon.iconSetName)
+ .replace('{icon_name}', icon.name)
+ // we always use the 1x icon scale and resize the icon with the property in KMLs
+ .replace('{icon_scale}', '1x')
+ .replace('{r}', `${rgb.r}`)
+ .replace('{g}', `${rgb.g}`)
+ .replace('{b}', `${rgb.b}`)
+ )
+}
+
+/**
+ * Retrieve all available icon sets from the backend.
+ *
+ * Also retrieve all available icons for those icon sets (so no need to do any additional request to
+ * the backend after that)
+ */
+async function loadAllIconSetsFromBackend(): Promise {
+ const sets: DrawingIconSet[] = []
+ try {
+ const rawSets = (
+ await axios.get(
+ `${getViewerDedicatedServicesBaseUrl()}icons/sets`
+ )
+ ).data.items
+ for (const rawSet of rawSets) {
+ const iconsURL: string = rawSet.icons_url
+ const iconSetName: string = rawSet.name
+
+ sets.push({
+ name: iconSetName,
+ isColorable: rawSet.colorable,
+ iconsURL,
+ templateURL: rawSet.template_url,
+ hasDescription: rawSet.has_description,
+ language: rawSet.language,
+ // retrieving all icons for this icon set
+ icons: await loadIconsForIconSet(iconsURL, iconSetName),
+ })
+ }
+ } catch (error) {
+ log.error({
+ ...logConfig,
+ messages: ['Failed to retrieve icons sets', error],
+ })
+ }
+ return sets
+}
+
+/** Loads all icons from an icon set and attach them to the icon set */
+async function loadIconsForIconSet(
+ iconSetURL: string,
+ iconSetName: string
+): Promise {
+ const icons = []
+ try {
+ const rawIcons = await axios.get(iconSetURL)
+
+ icons.push(
+ ...rawIcons.data.items.map(
+ (rawIcon): DrawingIcon => ({
+ name: rawIcon.name,
+ imageURL: rawIcon.url,
+ imageTemplateURL: rawIcon.template_url,
+ iconSetName,
+ description: rawIcon.description ?? undefined,
+ anchor: rawIcon.anchor,
+ size: rawIcon.size,
+ })
+ )
+ )
+ } catch (error) {
+ log.error({
+ ...logConfig,
+ messages: ['Error getting icons for icon set', iconSetName, iconSetURL, error],
+ })
+ }
+ return icons
+}
+
+export const iconsAPI = {
+ generateIconURL,
+ loadAllIconSetsFromBackend,
+}
+export default iconsAPI
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
new file mode 100644
index 0000000000..31e1a27530
--- /dev/null
+++ b/packages/api/src/index.ts
@@ -0,0 +1,15 @@
+export type * from '@/types/index'
+
+export * from '@/feedback'
+export * from '@/features'
+export * from '@/fileProxy'
+export * from '@/files'
+export * from '@/height'
+export * from '@/icons'
+export * from '@/lv03Reframe'
+export * from '@/profile'
+export * from '@/qrcode'
+export * from '@/search'
+export * from '@/shortlink'
+export * from '@/topics'
+export * from '@/what3words'
diff --git a/packages/api/src/lv03Reframe.ts b/packages/api/src/lv03Reframe.ts
new file mode 100644
index 0000000000..53f447911d
--- /dev/null
+++ b/packages/api/src/lv03Reframe.ts
@@ -0,0 +1,87 @@
+import type { SingleCoordinate } from '@swissgeo/coordinates'
+
+import { coordinatesUtils, LV03, LV95 } from '@swissgeo/coordinates'
+import log from '@swissgeo/log'
+import axios from 'axios'
+
+import type { ReframeConfig } from '@/types/lv03Reframe'
+
+import LogColorPerService from '@/config/log'
+
+const REFRAME_BASE_URL = 'https://geodesy.geo.admin.ch/reframe/'
+
+const logConfig = {
+ title: 'Reframe API',
+ titleColor: LogColorPerService.lv03Reframe,
+}
+
+/**
+ * Re-frames LV95 coordinate taking all LV03 -> LV95 deformation into account (they are not stable,
+ * so using "simple" proj4 matrices isn't enough to get a very accurate result)
+ *
+ * @returns Input coordinates re-framed by the backend service, and re-projected in the output
+ * projection.
+ * @see https://www.swisstopo.admin.ch/en/rest-api-geoservices-reframe-web
+ * @see https://github.com/geoadmin/mf-geoadmin3/blob/master/src/components/ReframeService.js
+ */
+export function reframe(config: ReframeConfig): Promise {
+ const { inputCoordinates, inputProjection, outputProjection } = config ?? {}
+ return new Promise((resolve, reject) => {
+ if (!Array.isArray(inputCoordinates) || inputCoordinates.length !== 2) {
+ reject(new Error('inputCoordinates must be an array with length of 2'))
+ }
+ if (![LV03.epsg, LV95.epsg].includes(inputProjection?.epsg)) {
+ reject(new Error('inputProjection must be LV03 or LV95'))
+ }
+ const backendResponseProjection = inputProjection.epsg === LV03.epsg ? LV95 : LV03
+ axios({
+ method: 'GET',
+ url: `${REFRAME_BASE_URL}${inputProjection.epsg === LV95.epsg ? 'lv95tolv03' : 'lv03tolv95'}`,
+ params: {
+ easting: inputCoordinates[0],
+ northing: inputCoordinates[1],
+ },
+ })
+ .then((response) => {
+ if (response.data?.coordinates) {
+ let outputCoordinates: SingleCoordinate = response.data.coordinates
+ if (outputProjection && outputProjection !== backendResponseProjection) {
+ outputCoordinates = coordinatesUtils.reprojectAndRound(
+ backendResponseProjection,
+ outputProjection,
+ outputCoordinates
+ )
+ }
+ resolve(outputCoordinates)
+ } else {
+ log.error({
+ ...logConfig,
+ messages: [
+ 'Error while re-framing coordinate',
+ inputCoordinates,
+ 'fallback to proj4',
+ ],
+ })
+ resolve(
+ coordinatesUtils.reprojectAndRound(
+ inputProjection,
+ outputProjection ?? backendResponseProjection,
+ inputCoordinates
+ )
+ )
+ }
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig,
+ messages: ['Error while re-framing coordinate', inputCoordinates, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+export const lv03ReframeAPI = {
+ reframe,
+}
+export default lv03ReframeAPI
diff --git a/packages/api/src/profile.ts b/packages/api/src/profile.ts
new file mode 100644
index 0000000000..01cd3d18c6
--- /dev/null
+++ b/packages/api/src/profile.ts
@@ -0,0 +1,337 @@
+import type { CoordinatesChunk, CoordinateSystem, SingleCoordinate } from '@swissgeo/coordinates'
+import type { Staging } from '@swissgeo/staging-config'
+
+import { coordinatesUtils, LV95 } from '@swissgeo/coordinates'
+import log from '@swissgeo/log'
+import { getApi3BaseUrl } from '@swissgeo/staging-config'
+import axios, { AxiosError } from 'axios'
+import proj4 from 'proj4'
+
+import type {
+ ElevationProfile,
+ ElevationProfileChunk,
+ ElevationProfilePoint,
+ ServiceProfilePoints,
+} from '@/types/profile'
+
+import LogColorPerService from '@/config/log'
+import profileUtils from '@/utils/profileUtils'
+
+const logTitle = { title: 'Profile API', titleColor: LogColorPerService.profile }
+
+export class ElevationProfileError extends Error {
+ public readonly technicalError: Error
+
+ constructor(message: string, technicalError: Error) {
+ super(message)
+ this.technicalError = technicalError
+ }
+}
+
+/**
+ * How many coordinate we will let a chunk have before splitting it into multiple requests/chunks
+ *
+ * Backend has a hard limit at 5k, we take a conservative approach with 3k.
+ */
+const MAX_REQUEST_POINT_LENGTH: number = 3000
+
+function parseProfileChunkFromBackendResponse(
+ backendResponse: Awaited,
+ startingDist: number,
+ outputProjection: CoordinateSystem
+): ElevationProfileChunk {
+ const points: ElevationProfilePoint[] = backendResponse.map((rawPoint) => {
+ let coordinate: SingleCoordinate = [rawPoint.easting, rawPoint.northing]
+ if (outputProjection.epsg !== LV95.epsg) {
+ coordinate = proj4(LV95.epsg, outputProjection.epsg, coordinate)
+ }
+ const point: ElevationProfilePoint = {
+ coordinate,
+ dist: startingDist + (rawPoint.dist ?? 0),
+ elevation: rawPoint.alts?.COMB,
+ hasElevationData: rawPoint.alts?.COMB !== undefined,
+ }
+ return point
+ })
+ return {
+ hasElevationData: points.every((point) => point.hasElevationData),
+ hasDistanceData: points.some((point) => (point.dist ?? 0) > 0),
+ points,
+ }
+}
+
+/** @throws ElevationProfileError */
+async function getProfileDataForChunk(
+ chunk: CoordinatesChunk,
+ outputProjection: CoordinateSystem,
+ staging: Staging = 'production'
+): Promise {
+ if (chunk.isWithinBounds) {
+ try {
+ // our backend has a hard limit of 5k points, we split the coordinates if they are above 3k
+ // (after a couple tests, 3k was a good trade-off for performance, 5k was a bit sluggish)
+ const coordinatesToRequest: CoordinatesChunk[] | undefined =
+ profileUtils.splitIfTooManyPoints(chunk, MAX_REQUEST_POINT_LENGTH)
+ if (!coordinatesToRequest) {
+ return []
+ }
+
+ const allRequests = coordinatesToRequest
+ .filter((coordinateChunk) => coordinateChunk !== null)
+ .map((coordinatesChunk) => {
+ return axios({
+ url: `${getApi3BaseUrl(staging)}rest/services/profile.json`,
+ method: 'POST',
+ params: {
+ offset: 0,
+ sr: LV95.epsgNumber,
+ distinct_points: true,
+ },
+ data: {
+ type: 'LineString',
+ coordinates: coordinatesChunk.coordinates,
+ },
+ })
+ })
+
+ const allResponses = await Promise.all(allRequests)
+ const finalResponse: ServiceProfilePoints[] = []
+ let previousDist = 0
+ // stitching all responses back together, so that the rest of the app is unaware we've cut the coordinates
+ // in 3k points chunks.
+ allResponses.forEach((response) => {
+ // checking the validity of data from the backend, there is a small edge case where we request the backend
+ // because the coordinates are technically in bound of LV95, but our backend doesn't cover the last km
+ // or so of the LV95 bounds, resulting in an empty profile being sent by the backend even though our
+ // coordinates were inbound (hence the dataForChunk.data.length > 2 check)
+ if (response.data?.length > 2) {
+ finalResponse.push(
+ ...response.data.map((point: ServiceProfilePoints) => ({
+ ...point,
+ dist: point.dist + previousDist,
+ }))
+ )
+ previousDist = finalResponse.slice(-1)[0]?.dist ?? 0
+ } else {
+ log.error({
+ ...logTitle,
+ messages: ['Incorrect/empty response while getting profile', response],
+ })
+ throw new ElevationProfileError(
+ 'profile_network_error',
+ new Error('Incorrect/empty response while getting profile')
+ )
+ }
+ })
+ return finalResponse
+ } catch (err: unknown) {
+ if (err) {
+ log.error({
+ ...logTitle,
+ messages: ['Error while trying to fetch profile data', err],
+ })
+ }
+ if (
+ err instanceof AxiosError &&
+ err.response &&
+ err.response.status === 413 &&
+ err.response.data?.error?.message?.includes(
+ 'Request Geometry contains too many points. Maximum number of points allowed'
+ )
+ ) {
+ log.error({
+ ...logTitle,
+ messages: [
+ 'Requesting too many points for a profile, request could not be processed',
+ err,
+ ],
+ })
+ throw new ElevationProfileError(
+ 'profile_too_many_points_error',
+ new Error('Error requesting profile with too many points')
+ )
+ }
+ if (err instanceof ElevationProfileError) {
+ throw err
+ }
+ throw new ElevationProfileError(
+ 'profile_network_error',
+ new Error('Error while trying to fetch profile data')
+ )
+ }
+ }
+ // returning a chunk without data (and also evaluating distance between point as if we were on a flat plane)
+ if (!chunk?.coordinates) {
+ log.error({
+ ...logTitle,
+ messages: ['Malformed chunk', chunk],
+ })
+ throw new ElevationProfileError('profile_network_error', new Error('Malformed chunk'))
+ }
+ let lastCoordinate: SingleCoordinate
+ return [
+ ...chunk.coordinates.map((coordinate) => {
+ let dist = 0
+ if (lastCoordinate) {
+ dist += Math.sqrt(
+ Math.pow(lastCoordinate[0] - coordinate[0], 2) +
+ Math.pow(lastCoordinate[1] - coordinate[1], 2)
+ )
+ }
+ lastCoordinate = coordinate
+ const point: ServiceProfilePoints = {
+ easting: coordinate[0],
+ northing: coordinate[1],
+ dist: outputProjection.roundCoordinateValue(dist),
+ alts: undefined,
+ }
+ return point
+ }),
+ ]
+}
+
+function ensureDoubleNestedArray(
+ arr: SingleCoordinate[] | SingleCoordinate[][]
+): SingleCoordinate[][] {
+ if (Array.isArray(arr) && Array.isArray(arr[0]) && Array.isArray(arr[0][0])) {
+ if (typeof arr[0][0] !== 'number') {
+ throw new ElevationProfileError(
+ 'profile_could_not_generate',
+ new Error(
+ 'Received a multi-feature (MultiLineString or MultiPolygon). This is not supported by this component, you need to split the feature and give each element of the "multi"-feature separately.'
+ )
+ )
+ }
+ return arr as SingleCoordinate[][]
+ }
+ return [arr] as SingleCoordinate[][]
+}
+
+function sanitizeCoordinates(
+ coordinates: SingleCoordinate[] | SingleCoordinate[][],
+ projection: CoordinateSystem
+): SingleCoordinate[][] {
+ return (
+ // If the coordinates are not a double nested array, we make it one.
+ // Segmented files have a double nested array, but not all files or self-made drawings have,
+ // so we have to make sure we have a double nested array and then iterate over it.
+ ensureDoubleNestedArray(coordinates)
+ // removing any 3rd dimension that could come from OL
+ .map((coordinates) => coordinatesUtils.removeZValues(coordinates))
+ .map((coordinates) => {
+ // The service only works with LV95 coordinate,
+ // we have to transform them if they are not in this projection
+ if (projection.epsg !== LV95.epsg) {
+ return coordinates.map((coordinate) => proj4(LV95.epsg, LV95.epsg, coordinate))
+ }
+ return coordinates
+ })
+ )
+}
+
+/**
+ * Gets profile from https://api3.geo.admin.ch/services/sdiservices.html#profile
+ *
+ * @param profileCoordinates Coordinates, expressed in the given projection, from which we want the
+ * profile
+ * @param projection The projection used to describe the coordinates
+ * @param staging On which backend the profile should be requested, default is 'production'
+ * @returns The profile, or null if there was no valid data to produce a profile
+ * @throws ElevationProfileError will reject the promise with a ProfileError if something went
+ * wrong.
+ */
+async function getProfile(
+ profileCoordinates: SingleCoordinate[] | SingleCoordinate[][],
+ projection: CoordinateSystem,
+ staging: Staging = 'production'
+): Promise {
+ if (!profileCoordinates || profileCoordinates.length === 0) {
+ const errorMessage = `Coordinates not provided`
+ log.error({
+ ...logTitle,
+ messages: [errorMessage],
+ })
+ throw new ElevationProfileError(errorMessage, new Error('profile_could_not_generate'))
+ }
+ const chunks: ElevationProfileChunk[] = []
+
+ for (const coordinates of sanitizeCoordinates(profileCoordinates, projection)) {
+ // splitting the profile input into "chunks" if some part are out of LV95 bounds
+ // as there will be no data for those chunks.
+ const coordinateChunks: CoordinatesChunk[] | undefined =
+ LV95.bounds.splitIfOutOfBounds(coordinates)
+
+ if (!coordinateChunks) {
+ log.error({
+ ...logTitle,
+ messages: [
+ 'No data found within LV95 bounds, no profile data could be fetched',
+ coordinates,
+ ],
+ })
+ throw new ElevationProfileError(
+ 'profile_could_not_generate',
+ new Error('No data found within LV95 bounds, no profile data could be fetched')
+ )
+ }
+ if (coordinateChunks.some((chunk) => !chunk.isWithinBounds)) {
+ log.warn({
+ ...logTitle,
+ messages: ['Some parts of the profile are out of LV95 bounds'],
+ })
+ }
+ if (coordinateChunks.every((chunk) => !chunk.isWithinBounds)) {
+ throw new ElevationProfileError(
+ 'profile_out_of_bounds',
+ new Error('All points are out of bounds, no profile data could be fetched')
+ )
+ }
+ const requestsForChunks: Promise[] = coordinateChunks.map((chunk) =>
+ getProfileDataForChunk(chunk, projection, staging)
+ )
+
+ let lastDist: number = 0
+ for (const chunkResponse of await Promise.allSettled(requestsForChunks)) {
+ if (chunkResponse.status === 'fulfilled') {
+ const resultingChunk: ElevationProfileChunk = parseProfileChunkFromBackendResponse(
+ chunkResponse.value,
+ lastDist,
+ projection
+ )
+ if (resultingChunk) {
+ const newChunkLastPoint = resultingChunk.points.slice(-1)[0]
+ lastDist = newChunkLastPoint?.dist ?? 0
+ chunks.push(resultingChunk)
+ }
+ } else {
+ log.error({
+ ...logTitle,
+ messages: [
+ 'Error while getting profile for chunk',
+ chunkResponse.reason?.message,
+ ],
+ })
+ }
+ }
+ }
+ if (chunks.every((chunk) => !chunk.hasElevationData)) {
+ throw new ElevationProfileError(
+ 'profile_could_not_generate',
+ new Error('No elevation data found, feature might be out of bounds')
+ )
+ }
+ return {
+ chunks: chunks,
+ metadata: profileUtils.getProfileMetadata(
+ chunks
+ .flatMap((chunk) => chunk.points)
+ .toSorted((p1, p2) => (p1.dist ?? 0) - (p2.dist ?? 0))
+ ),
+ }
+}
+
+export const profileAPI = {
+ getProfile,
+}
+
+export default profileAPI
diff --git a/packages/api/src/qrcode.ts b/packages/api/src/qrcode.ts
new file mode 100644
index 0000000000..d0b013e1a1
--- /dev/null
+++ b/packages/api/src/qrcode.ts
@@ -0,0 +1,64 @@
+import type { Staging } from '@swissgeo/staging-config'
+
+import log from '@swissgeo/log'
+import { getViewerDedicatedServicesBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+
+import LogColorPerService from '@/config/log'
+
+const logConfig = {
+ title: 'QRCode API',
+ titleColor: LogColorPerService.qrCode,
+}
+
+/** Generates a URL to generate a QR Code for a URL */
+function generateQRCodeUrl(url: string, staging: Staging = 'production'): string {
+ const encodedUrl = encodeURIComponent(url)
+ return `${getViewerDedicatedServicesBaseUrl(staging)}qrcode/generate?url=${encodedUrl}`
+}
+
+/**
+ * Generates a QR Code that, when scanned by mobile devices, open the URL given in parameters
+ *
+ * @param url The URL we want to QR-Codify
+ * @param [staging] On which backend to run the request
+ * @returns A promise that will resolve with the image (QR-Code) in PNG format (byte)
+ */
+const generateQrCodeImage = (url: string, staging: Staging = 'production'): Promise => {
+ return new Promise((resolve, reject) => {
+ try {
+ new URL(url)
+ } catch (error) {
+ const errorMessage = 'Invalid URL, no QR code generated'
+ log.error({
+ ...logConfig,
+ messages: [errorMessage, url, error],
+ })
+ reject(new Error(errorMessage))
+ }
+ axios
+ .get(generateQRCodeUrl(url, staging), {
+ responseType: 'arraybuffer',
+ })
+ .then((image) => {
+ resolve(
+ 'data:image/png;base64,'.concat(
+ btoa(String.fromCharCode(...new Uint8Array(image.data)))
+ )
+ )
+ })
+ .catch((error) => {
+ log.error({
+ ...logConfig,
+ messages: ['Error while retrieving qrCode for', url, error],
+ })
+ reject(new Error(error))
+ })
+ })
+}
+
+export const qrcodeAPI = {
+ getGenerateQRCodeUrl: generateQRCodeUrl,
+ generateQrCode: generateQrCodeImage,
+}
+export default qrcodeAPI
diff --git a/packages/api/src/search.ts b/packages/api/src/search.ts
new file mode 100644
index 0000000000..73c8c80a1f
--- /dev/null
+++ b/packages/api/src/search.ts
@@ -0,0 +1,509 @@
+import type { CoordinateSystem, FlatExtent, SingleCoordinate } from '@swissgeo/coordinates'
+import type { GPXLayer, KMLLayer, Layer } from '@swissgeo/layers'
+import type { AxiosResponse, CancelToken, CancelTokenSource } from 'axios'
+import type Feature from 'ol/Feature'
+
+import { CustomCoordinateSystem, LV95, WGS84 } from '@swissgeo/coordinates'
+import { LayerType } from '@swissgeo/layers'
+import { geoJsonUtils } from '@swissgeo/layers/utils'
+import log, { LogPreDefinedColor } from '@swissgeo/log'
+import { getApi3BaseUrl } from '@swissgeo/staging-config'
+import { bbox, points } from '@turf/turf'
+import axios from 'axios'
+import proj4 from 'proj4'
+
+import type { DrawingIconSet } from '@/types/icons'
+import type {
+ LayerFeatureSearchResult,
+ LayerSearchResult,
+ LocationSearchResult,
+ SearchResponse,
+ SearchResponseResult,
+ SearchResult,
+} from '@/types/search'
+
+import featuresAPI from '@/features'
+import gpxUtils from '@/utils/gpxUtils'
+import kmlUtils from '@/utils/kmlUtils'
+
+const KML_GPX_SEARCH_FIELDS = ['name', 'description', 'id']
+
+// comes from https://stackoverflow.com/questions/5002111/how-to-strip-html-tags-from-string-in-javascript
+const REGEX_DETECT_HTML_TAGS = /<\/?[^>]+(>|$)/g
+
+/**
+ * Exported so that it may be unit tested, it is intended to only care for search results title and
+ * nothing more
+ */
+function sanitizeTitle(title: string = ''): string {
+ return title.replace(REGEX_DETECT_HTML_TAGS, '')
+}
+
+const generateAxiosSearchRequest = (
+ query: string,
+ lang: string,
+ type: string,
+ cancelToken: CancelToken,
+ extraParams: object = {}
+): Promise> => {
+ return axios.get(`${getApi3BaseUrl()}rest/services/ech/SearchServer`, {
+ cancelToken,
+ params: {
+ sr: LV95.epsgNumber,
+ searchText: query.trim(),
+ lang,
+ type,
+ ...extraParams,
+ },
+ })
+}
+
+function parseLayerResult(result: SearchResponseResult): LayerSearchResult {
+ if (!result.attrs) {
+ throw new SearchError('Invalid layer result, cannot be parsed')
+ }
+ const { label: title, detail: description, layer: layerId } = result.attrs
+ return {
+ resultType: 'LAYER',
+ id: layerId ?? title,
+ title,
+ sanitizedTitle: sanitizeTitle(title),
+ description,
+ layerId: layerId ?? title,
+ }
+}
+
+function parseLocationResult(
+ result: SearchResponseResult,
+ outputProjection: CoordinateSystem,
+ translations?: {
+ kantone: string
+ district: string
+ }
+): LocationSearchResult {
+ if (!result.attrs) {
+ throw new SearchError('Invalid location result, cannot be parsed')
+ }
+ // reading the main values from the attrs
+ const { label: title, detail: description, featureId, origin } = result.attrs
+
+ let coordinate: SingleCoordinate | undefined
+ let zoom = result.attrs.zoomlevel
+ if (result.attrs.lon && result.attrs.lat) {
+ coordinate = [result.attrs.lon, result.attrs.lat]
+ if (outputProjection.epsg !== WGS84.epsg) {
+ coordinate = proj4(WGS84.epsg, outputProjection.epsg, coordinate)
+ }
+ }
+ if (outputProjection.epsg !== LV95.epsg) {
+ // re-projecting result coordinate and zoom to wanted projection
+ zoom = LV95.transformCustomZoomLevelToStandard(zoom)
+ if (outputProjection instanceof CustomCoordinateSystem) {
+ zoom = outputProjection.transformStandardZoomLevelToCustom(zoom)
+ }
+ }
+ // reading the extent from the LineString (if defined)
+ let extent: FlatExtent | undefined
+ if (result.attrs.geom_st_box2d) {
+ const extentMatches = Array.from(
+ result.attrs.geom_st_box2d.matchAll(
+ /BOX\(([0-9\\.]+) ([0-9\\.]+),([0-9\\.]+) ([0-9\\.]+)\)/g
+ )
+ )[0]
+ if (Array.isArray(extentMatches) && extentMatches.length >= 5) {
+ let bottomLeft = [Number(extentMatches[1]), Number(extentMatches[2])]
+ let topRight = [Number(extentMatches[3]), Number(extentMatches[4])]
+ if (outputProjection.epsg !== LV95.epsg) {
+ bottomLeft = proj4(LV95.epsg, outputProjection.epsg, bottomLeft)
+ topRight = proj4(LV95.epsg, outputProjection.epsg, topRight)
+ }
+ // checking if both point are the same (can happen if what is shown is a point of interest)
+ if (bottomLeft[0] !== topRight[0] && bottomLeft[1] !== topRight[1]) {
+ extent = [...bottomLeft, ...topRight] as FlatExtent
+ }
+ }
+ }
+ // when no zoom and no extent are given, we go 1:25'000 map by default
+ if (!zoom && !extent) {
+ zoom = outputProjection.get1_25000ZoomLevel()
+ }
+ let newOrigin
+ if (origin === 'district' && translations?.district) {
+ newOrigin = translations.district
+ }
+ if (origin === 'kantone' && translations?.kantone) {
+ newOrigin = translations.kantone
+ }
+ const newTitle = newOrigin ? `${newOrigin} ${title}` : title
+ return {
+ resultType: 'LOCATION',
+ id: featureId,
+ title: newTitle,
+ sanitizedTitle: sanitizeTitle(title),
+ description,
+ featureId: featureId ?? description,
+ coordinate,
+ extent,
+ zoom,
+ }
+}
+
+async function searchLayers(
+ queryString: string,
+ lang: string,
+ cancelToken: CancelToken,
+ limit?: number
+) {
+ try {
+ const layerResponse = await generateAxiosSearchRequest(
+ queryString,
+ lang,
+ 'layers',
+ cancelToken,
+ { limit }
+ )
+ if (!layerResponse?.data || !layerResponse?.data?.results) {
+ return []
+ }
+ // checking that there is something of interest to parse
+ const resultWithAttrs = layerResponse?.data.results?.filter(
+ (result: SearchResponseResult) => !!result.attrs
+ )
+ return resultWithAttrs?.map(parseLayerResult) ?? []
+ } catch (error) {
+ log.error({
+ title: 'Search API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [`Failed to search layer, fallback to empty result`, error],
+ })
+ return []
+ }
+}
+
+/**
+ * Search locations for this query string in our backend, returning results reprojected to the
+ * outputProjection (if it isn't LV95 already)
+ */
+async function searchLocation(
+ outputProjection: CoordinateSystem,
+ queryString: string,
+ lang: string,
+ cancelToken: CancelToken,
+ limit?: number,
+ translations?: {
+ kantone: string
+ district: string
+ }
+): Promise {
+ try {
+ const locationResponse = await generateAxiosSearchRequest(
+ queryString,
+ lang,
+ 'locations',
+ cancelToken,
+ { limit }
+ )
+ if (!locationResponse?.data || !locationResponse?.data?.results) {
+ return []
+ }
+ // checking that there is something of interest to parse
+ const resultWithAttrs = locationResponse?.data.results?.filter(
+ (result: SearchResponseResult) => !!result.attrs
+ )
+ return (
+ resultWithAttrs.map((location) =>
+ parseLocationResult(location, outputProjection, translations)
+ ) ?? []
+ )
+ } catch (error) {
+ log.error({
+ title: 'Search API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [`Failed to search locations, fallback to empty result`, error],
+ })
+ return []
+ }
+}
+
+async function searchLayerFeatures(
+ outputProjection: CoordinateSystem,
+ queryString: string,
+ layer: Layer,
+ lang: string,
+ cancelToken: CancelTokenSource
+): Promise {
+ try {
+ const layerFeatureResponse = await generateAxiosSearchRequest(
+ queryString,
+ lang,
+ 'featuresearch',
+ cancelToken.token,
+ {
+ features: layer.id,
+ timeEnabled: false,
+ }
+ )
+ // checking that there is something of interest to parse
+ const resultWithAttrs = layerFeatureResponse?.data.results?.filter((result) => result.attrs)
+ return (
+ resultWithAttrs.map((layerFeature) => {
+ const layerContent = parseLayerResult(layerFeature)
+ const locationContent = parseLocationResult(layerFeature, outputProjection)
+ const title = `${layer.name}
${layerContent.title}`
+ return {
+ ...layerContent,
+ ...locationContent,
+ resultType: 'FEATURE',
+ title,
+ layer,
+ }
+ }) ?? []
+ )
+ } catch (error) {
+ log.error({
+ title: 'Search API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [
+ `Failed to search layer features for layer ${layer.id}, fallback to empty result`,
+ error,
+ ],
+ })
+ return []
+ }
+}
+
+/** Searches for the query string in the feature inside the provided search fields */
+function isQueryInFeature(feature: Feature, queryString: string, searchFields: string[]): boolean {
+ const queryStringClean = queryString
+ .trim()
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '') // replaces all special characters and accents
+ return searchFields.some((field) => {
+ const value = feature.get(field)?.toString()
+ return !!value && value.trim().toLowerCase().includes(queryStringClean)
+ })
+}
+
+/** Searches for the query string in the vector layer */
+function searchFeatures(
+ features: Feature[],
+ outputProjection: CoordinateSystem,
+ queryString: string,
+ layer: KMLLayer | GPXLayer
+): SearchResult[] {
+ try {
+ if (!features || !features.length) {
+ return []
+ }
+ const includedFeatures = features.filter((feature) =>
+ isQueryInFeature(feature, queryString, KML_GPX_SEARCH_FIELDS)
+ )
+ if (!includedFeatures.length) {
+ return []
+ }
+ return includedFeatures.map((feature) =>
+ createSearchResultFromLayer(layer, feature, outputProjection)
+ )
+ } catch (error) {
+ log.error({
+ title: 'Search API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [
+ `Failed to search layer features for layer ${layer.id}, fallback to empty result`,
+ error,
+ ],
+ })
+ return []
+ }
+}
+
+/** Searches for features in KML and GPX layers based on a query string and output projection */
+function searchLayerFeaturesKMLGPX(
+ layersToSearch: Layer[],
+ queryString: string,
+ outputProjection: CoordinateSystem,
+ resolution: number,
+ iconSets: DrawingIconSet[]
+): SearchResult[] {
+ return layersToSearch.reduce((returnLayers: SearchResult[], currentLayer: Layer) => {
+ if (currentLayer.type === LayerType.KML) {
+ const kmlLayer = currentLayer as KMLLayer
+ return returnLayers.concat(
+ searchFeatures(
+ kmlUtils.parseKml(kmlLayer, outputProjection, iconSets, resolution),
+ outputProjection,
+ queryString,
+ kmlLayer
+ )
+ )
+ }
+ if (currentLayer.type === LayerType.GPX) {
+ const gpxLayer = currentLayer as GPXLayer
+ const gpxData = gpxLayer.gpxData
+ if (!gpxData) {
+ return returnLayers
+ }
+ const gpxFeatures = gpxUtils.parseGpx(gpxData, outputProjection)
+ if (!gpxFeatures) {
+ return returnLayers
+ }
+ return returnLayers.concat(
+ ...searchFeatures(gpxFeatures, outputProjection, queryString, gpxLayer)
+ )
+ }
+ return returnLayers
+ }, [])
+}
+
+/** Creates the SearchResult for a layer */
+function createSearchResultFromLayer(
+ layer: KMLLayer | GPXLayer,
+ feature: Feature,
+ outputProjection: CoordinateSystem
+): LayerFeatureSearchResult {
+ const featureName: string = feature.get('name') || layer.name || '' // this needs || to avoid using empty string when feature.get("name") is an empty string
+ const coordinates: SingleCoordinate[] = featuresAPI.extractOlFeatureCoordinates(feature)
+ const zoom: number = outputProjection.get1_25000ZoomLevel()
+
+ const coordinatePoints = points(coordinates)
+ let extent: number[] = bbox(coordinatePoints)
+ if (extent.length > 4) {
+ extent = extent.slice(0, 4)
+ }
+
+ const featureId = feature.getId() ? `${feature.getId()}` : layer.id
+ return {
+ resultType: 'FEATURE',
+ id: featureId,
+ title: featureName,
+ sanitizedTitle: sanitizeTitle(featureName),
+ description: feature.get('description') ?? '',
+ featureId: featureId,
+ coordinate: geoJsonUtils.getGeoJsonFeatureCenter(
+ coordinatePoints,
+ outputProjection,
+ outputProjection
+ ),
+ extent: extent as FlatExtent,
+ zoom,
+ layer,
+ layerId: layer.id,
+ }
+}
+
+interface SearchConfig {
+ /** The projection in which the search results must be returned */
+ outputProjection: CoordinateSystem
+ /** The query string that describe what is wanted from the search */
+ queryString: string
+ /** The lang ISO code in which the search must be conducted */
+ lang: string
+ /** The resolution of the map in which the search must be conducted, in meters per pixel */
+ resolution: number
+ /** List of searchable layers for which to fire search requests. */
+ layersToSearch?: Layer[]
+ /** The maximum number of results to return */
+ limit?: number
+ iconSets?: DrawingIconSet[]
+ /** Translations for location origin prefixes (e.g., "Ct." for kantone, "District" for district) */
+ translations?: {
+ kantone: string
+ district: string
+ }
+}
+
+let cancelTokenSource: CancelTokenSource | undefined
+async function search(config: SearchConfig): Promise {
+ const {
+ outputProjection,
+ queryString,
+ lang,
+ resolution,
+ layersToSearch = [],
+ limit,
+ iconSets = [],
+ translations,
+ } = config
+
+ if (!outputProjection) {
+ const errorMessage = `A valid output projection is required to start a search request`
+ log.error(errorMessage)
+ throw new SearchError(errorMessage)
+ }
+ if (!lang || lang.length !== 2) {
+ const errorMessage = `A valid lang ISO code is required to start a search request, received: ${lang}`
+ log.error(errorMessage)
+ throw new SearchError(errorMessage)
+ }
+ if (!queryString || queryString.length < 2) {
+ const errorMessage = `At least to character are needed to launch a backend search, received: ${queryString}`
+ log.error(errorMessage)
+ throw new SearchError(errorMessage)
+ }
+ // if a request is currently pending, we cancel it to start the new one
+ if (cancelTokenSource) {
+ cancelTokenSource.cancel('new search query')
+ }
+ // CancelToken is only a type and can't be imported as a "class" directly. So calling `axios.CancelToken` is required.
+ // eslint-disable-next-line import/no-named-as-default-member
+ cancelTokenSource = axios.CancelToken.source()
+ const allResults: SearchResult[] = [
+ ...(await searchLayers(queryString, lang, cancelTokenSource?.token, limit)),
+ ...(await searchLocation(
+ outputProjection,
+ queryString,
+ lang,
+ cancelTokenSource?.token,
+ limit,
+ translations
+ )),
+ ]
+
+ const searchableLayers = layersToSearch.filter(
+ (layer) => 'searchable' in layer && !!layer.searchable
+ )
+ for (const searchableLayer of searchableLayers) {
+ allResults.push(
+ ...(await searchLayerFeatures(
+ outputProjection,
+ queryString,
+ searchableLayer,
+ lang,
+ cancelTokenSource
+ ))
+ )
+ }
+
+ allResults.push(
+ ...searchLayerFeaturesKMLGPX(
+ layersToSearch,
+ queryString,
+ outputProjection,
+ resolution,
+ iconSets
+ )
+ )
+
+ cancelTokenSource = undefined
+
+ return allResults.flat()
+}
+
+/**
+ * Error when building/sending/parsing a search request
+ *
+ * @property message Technical english message
+ */
+export class SearchError extends Error {
+ constructor(message?: string) {
+ super(message)
+ this.name = 'SearchError'
+ }
+}
+
+export const searchAPI = {
+ search,
+ sanitizeTitle,
+}
+export default searchAPI
diff --git a/packages/api/src/shortlink.ts b/packages/api/src/shortlink.ts
new file mode 100644
index 0000000000..569a24f0a9
--- /dev/null
+++ b/packages/api/src/shortlink.ts
@@ -0,0 +1,77 @@
+import type { Staging } from '@swissgeo/staging-config';
+import type { CancelTokenSource } from 'axios'
+
+import log, { LogPreDefinedColor } from '@swissgeo/log'
+import { getServiceShortLinkBaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+
+let cancelToken: CancelTokenSource | undefined
+export interface ShortLinkOptions {
+ withCrosshair?: boolean
+ url: string
+ staging: Staging
+}
+/**
+ * Generates a short link from the given URL
+ *
+ * @param url The URL we want to shorten
+ * @param withCrosshair If a cross-hair should be placed at the center of the map in the shortlink
+ * @returns A promise that will resolve with the short link
+ */
+function createShortLink(options: ShortLinkOptions): Promise {
+ const { url, withCrosshair = false, staging } = options
+ return new Promise((resolve, reject) => {
+ // we do not want the geolocation of the user clicking the link to kick in, so we force the flag out of the URL
+ let sanitizedUrl = url.replace('&geolocation', '')
+ if (withCrosshair) {
+ sanitizedUrl += '&crosshair=marker'
+ }
+ try {
+ new URL(sanitizedUrl)
+ } catch (error) {
+ const errorMessage = 'Invalid URL, no short link generated'
+ log.error({
+ title: 'ShortLink API',
+ titleStyle: {
+ backgroundColor: LogPreDefinedColor.Gray,
+ },
+ messages: [errorMessage, sanitizedUrl, error],
+ })
+ reject(new Error(errorMessage))
+ }
+
+ // if a request is currently pending, we cancel it to start the new one
+ if (cancelToken) {
+ cancelToken.cancel('A new shortLink request arrived')
+ }
+ // CancelToken is only a type and can't be imported as a "class" directly. So calling `axios.CancelToken` is required.
+ // eslint-disable-next-line import/no-named-as-default-member
+ cancelToken = axios.CancelToken.source()
+
+ axios
+ .post(getServiceShortLinkBaseUrl(staging), {
+ url: sanitizedUrl,
+ })
+ .then((response) => {
+ resolve(response.data.shorturl)
+ })
+ .catch((error) => {
+ log.error({
+ title: 'ShortLink API',
+ titleStyle: {
+ backgroundColor: LogPreDefinedColor.Gray,
+ },
+ messages: ['Error while retrieving short link for', sanitizedUrl, error],
+ })
+ reject(new Error(error))
+ })
+ .finally(() => {
+ cancelToken = undefined
+ })
+ })
+}
+
+export const shortLinkAPI = {
+ createShortLink,
+}
+export default shortLinkAPI
diff --git a/packages/api/src/topics.ts b/packages/api/src/topics.ts
new file mode 100644
index 0000000000..41eac0c01f
--- /dev/null
+++ b/packages/api/src/topics.ts
@@ -0,0 +1,265 @@
+import type { GeoAdminGroupOfLayers, GeoAdminLayer, Layer } from '@swissgeo/layers'
+
+import { layerUtils } from '@swissgeo/layers/utils'
+import log, { LogPreDefinedColor } from '@swissgeo/log'
+import { WarningMessage } from '@swissgeo/log/Message'
+import { getApi3BaseUrl } from '@swissgeo/staging-config'
+import axios from 'axios'
+
+import type {
+ CatalogServerResponse,
+ CatalogServerResponseCategory,
+ CatalogServerResponseLayer,
+ CatalogServerResponseRoot,
+ ServicesResponse,
+ Topic,
+ TopicTree,
+} from '@/types/topics'
+
+import legacyLayerParamUtils from '@/utils/legacyLayerParamUtils'
+
+function gatherItemIdThatShouldBeOpened(
+ node: CatalogServerResponseRoot | CatalogServerResponseCategory | CatalogServerResponseLayer
+): string[] {
+ const ids: string[] = []
+ if ('category' in node) {
+ if (node.category === 'layer') {
+ return ids
+ } else {
+ node.children.forEach((child) => {
+ ids.push(...gatherItemIdThatShouldBeOpened(child))
+ })
+ if (node.selectedOpen) {
+ ids.push(`${node.id}`)
+ }
+ }
+ } else {
+ // we are dealing with the root node
+ ids.push(...node.children.flatMap((child) => gatherItemIdThatShouldBeOpened(child)))
+ }
+ return ids
+}
+
+/**
+ * Reads the output of the topic tree endpoint, and creates all themes and layers object accordingly
+ *
+ * @param node The node for whom we are looking into
+ * @param availableLayers All layers available from the layers' config
+ */
+const readTopicTreeRecursive = (
+ node: CatalogServerResponseCategory | CatalogServerResponseLayer,
+ availableLayers: GeoAdminLayer[]
+): { layer: GeoAdminLayer | GeoAdminGroupOfLayers; warnings: WarningMessage[] } => {
+ if (node.category === 'topic') {
+ const children: {
+ layer: GeoAdminLayer | GeoAdminGroupOfLayers
+ warnings: WarningMessage[]
+ }[] = []
+ const warnings: WarningMessage[] = []
+ node.children.forEach((topicChild) => {
+ try {
+ children.push(readTopicTreeRecursive(topicChild, availableLayers))
+ } catch (err) {
+ log.warn({
+ title: 'Topics API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [
+ `Child ${topicChild.id} can't be loaded, probably due to data integration work ongoing`,
+ err,
+ ],
+ })
+ if (err instanceof Error) {
+ warnings.push(
+ new WarningMessage(
+ `Topic element ${topicChild.id} can't be loaded, probably due to data integration work ongoing. cause: ${err.message}`
+ )
+ )
+ }
+ }
+ })
+ return {
+ layer: layerUtils.makeGeoAdminGroupOfLayers({
+ id: `${node.id}`,
+ name: node.label,
+ layers: children.flatMap(({ layer }) => layer),
+ }),
+ warnings,
+ }
+ } else if (node.category === 'layer') {
+ // we have to match IDs first, some layers have the same technicalNames (when 3D counterpart config exist for instance), and
+ // matching both together will result sometimes in the 3D config being displayed in the topic instead of the correct layer
+ let matchingLayer = availableLayers.find((layer) => layer.id === node.layerBodId)
+ if (!matchingLayer) {
+ matchingLayer = availableLayers.find((layer) => layer.technicalName === node.layerBodId)
+ }
+ if (matchingLayer) {
+ return { layer: matchingLayer, warnings: [] }
+ }
+ throw new Error(`Layer with BOD ID ${node.layerBodId} not found in the layers config`)
+ }
+ throw new Error('unknown topic node type')
+}
+
+/**
+ * Loads the topic tree for a topic. This will be used to create the UI of the topic in the menu.
+ *
+ * @param lang The lang in which to load the topic tree
+ * @param topicId The topic we want to load the topic tree
+ * @param layersConfig All available layers for this app (the "layers config")
+ * @returns A list of topic's layers
+ */
+async function loadTopicTreeForTopic(
+ lang: string,
+ topicId: string,
+ layersConfig: GeoAdminLayer[]
+): Promise<{ tree: TopicTree; warnings: WarningMessage[] }> {
+ try {
+ const response = await axios.get(
+ `${getApi3BaseUrl()}rest/services/${topicId}/CatalogServer?lang=${lang}`
+ )
+ const data = response.data as CatalogServerResponse
+ const topicRoot: CatalogServerResponseRoot = data.results.root
+
+ const treeItems: (GeoAdminLayer | GeoAdminGroupOfLayers)[] = []
+ const warnings: WarningMessage[] = []
+ topicRoot.children.forEach((child) => {
+ try {
+ const { layer, warnings: layerWarnings } = readTopicTreeRecursive(
+ child,
+ layersConfig
+ )
+ if (layer.id) {
+ treeItems.push(layer)
+ }
+ warnings.push(...layerWarnings)
+ } catch (err) {
+ log.error({
+ title: 'Topics API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [`Error while loading Layer ${child.id} for Topic ${topicId}`, err],
+ })
+ }
+ })
+ const itemIdToOpen = gatherItemIdThatShouldBeOpened(topicRoot)
+ return { tree: { layers: treeItems, itemIdToOpen }, warnings }
+ } catch (error) {
+ const errorMessage = `Failed to load topic tree for topic ${topicId}`
+ log.error({
+ title: 'Topics API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [errorMessage, error],
+ })
+ throw new Error(errorMessage, { cause: error})
+ }
+}
+
+/**
+ * Loads all topics (without their tree) from the backend.
+ *
+ * @returns Raw topics from backend
+ */
+async function loadTopics(): Promise {
+ try {
+ const response = await axios.get(`${getApi3BaseUrl()}rest/services`)
+ return response.data as ServicesResponse
+ } catch (error) {
+ const errorMessage = `Failed to load topics from backend`
+ log.error({
+ title: 'Topics API',
+ titleColor: LogPreDefinedColor.Amber,
+ messages: [errorMessage, error],
+ })
+ throw new Error(errorMessage, { cause: error})
+ }
+}
+
+/**
+ * Parse topics from backend response.
+ *
+ * The topics will already by filled with the correct layer object, coming from the `layersConfig`
+ * param
+ *
+ * @param layersConfig All available layers for this app (the "layers config")
+ * @param rawTopics
+ * @returns All topics available for this app
+ */
+function parseTopics(layersConfig: GeoAdminLayer[], rawTopics: ServicesResponse): Topic[] {
+ if (!rawTopics.topics) {
+ log.error(`Invalid topics input`, rawTopics)
+ throw new Error('Invalid topics input')
+ }
+ const topics: Topic[] = []
+ rawTopics.topics.forEach((rawTopic) => {
+ const {
+ id: topicId,
+ backgroundLayers: backgroundLayersId,
+ defaultBackground: defaultBackgroundLayerId,
+ plConfig: legacyUrlParams,
+ } = rawTopic
+ const backgroundLayers = layersConfig.filter(
+ (layer) => backgroundLayersId.indexOf(layer.id) !== -1
+ )
+ const backgroundLayerFromUrlParam =
+ legacyLayerParamUtils.getBackgroundLayerFromLegacyUrlParams(
+ layersConfig,
+ legacyUrlParams
+ )
+ // first we get the background from the "plConfig" of the API response
+ let defaultBackgroundLayer: GeoAdminLayer | null | undefined = undefined
+ // checking if there was something in the "plConfig"
+ // null is a valid background as it is the void layer in our app
+ // so we have to exclude only the "undefined" value and fill this variable
+ // with what is in "defaultBackground" in this case
+ if (backgroundLayerFromUrlParam === undefined) {
+ defaultBackgroundLayer = backgroundLayers.find(
+ (layer) => layer.id === defaultBackgroundLayerId
+ )
+ }
+ const params = new URLSearchParams(legacyUrlParams)
+ const layersToActivate: Layer[] = [
+ ...legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ layersConfig,
+ params.get('layers') ?? '',
+ params.get('layers_visibility') ?? '',
+ params.get('layers_opacity') ?? '',
+ params.get('layers_timestamp') ?? ''
+ ),
+ ]
+ const activatedLayers = [
+ ...new Set([...(rawTopic.activatedLayers ?? []), ...(rawTopic.selectedLayers ?? [])]),
+ ]
+ // Filter out layers that have been already added by the infamous
+ // plConfig topic config that has priority, this avoid duplicate
+ // layers
+ .filter((layerId) => !layersToActivate.some((layer) => layer.id === layerId))
+ activatedLayers.forEach((layerId) => {
+ const layer = layersConfig.find((layer) => layer.id === layerId)
+
+ if (layer) {
+ // deep copy so that we can reassign values later on
+ // (layers come from the pinia store so it can't be modified directly)
+ const layerClone: GeoAdminLayer = layerUtils.cloneLayer(layer)
+ // checking if the layer should be also visible
+ layerClone.isVisible = rawTopic.selectedLayers?.indexOf(layerId) !== -1
+ // In the backend the layers are in the wrong order
+ // so we need to reverse the order here by simply adding
+ // the layer at the beginning of the array
+ layersToActivate.unshift(layerClone)
+ }
+ })
+ topics.push({
+ id: topicId,
+ backgroundLayers,
+ defaultBackgroundLayer: defaultBackgroundLayer,
+ layersToActivate,
+ })
+ })
+ return topics
+}
+
+export const topicsAPI = {
+ loadTopicTreeForTopic,
+ loadTopics,
+ parseTopics,
+}
+export default topicsAPI
diff --git a/packages/api/src/types/features.ts b/packages/api/src/types/features.ts
new file mode 100644
index 0000000000..9d1be08b4b
--- /dev/null
+++ b/packages/api/src/types/features.ts
@@ -0,0 +1,178 @@
+import type { CoordinateSystem, FlatExtent, SingleCoordinate } from '@swissgeo/coordinates'
+import type { Layer } from '@swissgeo/layers'
+import type { Geometry } from 'geojson'
+
+import type { DrawingIcon } from '@/types/icons'
+
+/** A color that can be used to style a feature (comprised of a fill and a border color) */
+export interface FeatureStyleColor {
+ /** Name of the color in english lower cased */
+ name: string
+ /** HTML color (with # prefix) describing this color (usable in CSS or other styling context) */
+ fill: string
+ /**
+ * HTML color (with # prefix) describing the border color (usable in CSS or other styling
+ * context)
+ */
+ border: string
+}
+
+/**
+ * Representation of a size for feature style
+ *
+ * Scale values (that are to apply to the KML/GeoJSON) are different for text and icon. For icon the
+ * scale is the one used by open layer and is scaled up by the factor icon_size/32, see
+ * https://github.com/openlayers/openlayers/issues/12670
+ */
+export interface FeatureStyleSize {
+ /**
+ * Translation key for this size (must go through the i18n service to have a human-readable
+ * value)
+ */
+ label: string
+ /** Scale to apply to a text when choosing this size (related to KML/GeoJSON styling) */
+ textScale: number
+ /** Scale to apply to an icon when choosing this size (related to KML/GeoJSON styling) */
+ iconScale: number
+}
+
+export type TextPlacement =
+ | 'top-left'
+ | 'top'
+ | 'top-right'
+ | 'left'
+ | 'center'
+ | 'right'
+ | 'bottom-left'
+ | 'bottom'
+ | 'bottom-right'
+ | 'unknown'
+
+export interface SelectableFeature {
+ /**
+ * Unique identifier for this feature (unique in the context it comes from, not for the whole
+ * app)
+ */
+ readonly id: string | number
+ /** Coordinates describing the center of this feature. Format is [[x,y],[x2,y2],...] */
+ coordinates: SingleCoordinate[] | SingleCoordinate
+ /** Title of this feature */
+ title: string
+ /** A description of this feature. Cannot be HTML content (only text). */
+ description?: string
+ /** The extent of this feature (if any) expressed as [minX, minY, maxX, maxY]. */
+ extent?: FlatExtent
+ /** GeoJSON representation of this feature (if it has a geometry, for points it isn't necessary). */
+ geometry?: Geometry
+ /** Whether this feature is editable when selected (color, size, etc...). */
+ readonly isEditable: IsEditable
+}
+
+export type EditableFeatureTypes = 'MARKER' | 'ANNOTATION' | 'LINEPOLYGON' | 'MEASURE'
+
+export interface EditableFeature extends SelectableFeature {
+ featureType: EditableFeatureTypes
+ textOffset: [number, number]
+ textColor?: FeatureStyleColor
+ textSize?: FeatureStyleSize
+ fillColor?: FeatureStyleColor
+ strokeColor?: FeatureStyleColor
+ icon?: DrawingIcon
+ /** Size of the icon (if defined) that will be covering this feature */
+ iconSize?: FeatureStyleSize
+ /** Anchor of the text around the feature. Only useful for markers */
+ textPlacement: TextPlacement
+ showDescriptionOnMap: boolean
+}
+
+export interface LayerFeature extends SelectableFeature {
+ /** The layer in which this feature belongs */
+ readonly layer: Layer
+ /** Data for this feature's popup (or tooltip). */
+ readonly data?: Record
+ /** HTML representation of this feature */
+ readonly popupData?: string
+ /**
+ * If sanitization should take place before rendering the popup (as HTML) or not (trusted
+ * source)
+ *
+ * We can't trust the content of the popup data for external layers, and for KML layers. For
+ * KML, the issue is that users can create text-rich (HTML) description with links, and such. It
+ * would then be possible to do some XSS through this, so we need to sanitize this before
+ * showing it.
+ */
+ readonly popupDataCanBeTrusted: boolean
+}
+
+export type StoreFeature = EditableFeature | LayerFeature
+
+export interface IdentifyConfig {
+ layer: Layer
+ /** Coordinate where to identify */
+ coordinate: SingleCoordinate | FlatExtent
+ /** Current map resolution, in meters/pixel */
+ resolution: number
+ mapExtent: FlatExtent
+ screenWidth: number
+ screenHeight: number
+ lang: string
+ projection: CoordinateSystem
+ featureCount: number
+ tolerance?: number
+ /**
+ * Offset of how many items the identification should start after. This enables us to do some
+ * "pagination" or "load more" (if you already have 10 features, set an offset of 10 to get the
+ * 10 next, 20 in total). This only works with GeoAdmin backends
+ */
+ offset?: number
+ /**
+ * The wanted output projection. Enable us to request this WMS with a different projection and
+ * then reproject on the fly if this WMS doesn't support the current map projection.
+ */
+ outputProjection?: CoordinateSystem
+}
+
+interface IdentifyResultProperties {
+ x: number
+ y: number
+ lat: number
+ lon: number
+ label: string
+ [key: string]: number | string | null
+}
+
+export interface IdentifyResult {
+ featureId: string
+ bbox: FlatExtent
+ layerBodId: string
+ layerName: string
+ id: string
+ geometry: Geometry
+ properties: IdentifyResultProperties
+}
+
+export interface IdentifyResponse {
+ results: IdentifyResult[]
+}
+
+export interface GetFeatureOptions {
+ /** The language for the HTML popup. Will default to `en` if none given. */
+ lang?: string
+ /** Width of the screen in pixels */
+ screenWidth?: number
+ /** Height of the screen in pixels */
+ screenHeight?: number
+ /** Current extent of the map, described in LV95. */
+ mapExtent?: FlatExtent
+ coordinate?: SingleCoordinate | FlatExtent
+}
+
+export const StyleZIndex = {
+ AzimuthCircle: 0,
+ MainStyle: 10,
+ Line: 20,
+ MeasurePoint: 21,
+ WhiteDot: 30,
+ Tooltip: 40,
+ OnTop: 9999,
+}
diff --git a/packages/api/src/types/feedback.ts b/packages/api/src/types/feedback.ts
new file mode 100644
index 0000000000..e7b7c8ca49
--- /dev/null
+++ b/packages/api/src/types/feedback.ts
@@ -0,0 +1,8 @@
+export interface FeedbackOptions {
+ appVersion?: string
+ category?: string
+ kmlFileUrl?: string
+ kml?: string
+ email?: string
+ attachment?: File
+}
diff --git a/packages/api/src/types/files.ts b/packages/api/src/types/files.ts
new file mode 100644
index 0000000000..0d8f6d3f5b
--- /dev/null
+++ b/packages/api/src/types/files.ts
@@ -0,0 +1,18 @@
+export interface FileAPIMetadataResponse {
+ id: string
+ admin_id: string
+ created: string
+ updated: string
+ author: string
+ author_version: string
+ links: {
+ metadata: string
+ kml: string
+ }
+}
+
+export interface OnlineFileCompliance {
+ mimeType?: string
+ supportsCORS: boolean
+ supportsHTTPS: boolean
+}
diff --git a/packages/api/src/types/height.ts b/packages/api/src/types/height.ts
new file mode 100644
index 0000000000..a907604dc3
--- /dev/null
+++ b/packages/api/src/types/height.ts
@@ -0,0 +1,9 @@
+import type { SingleCoordinate } from '@swissgeo/coordinates'
+
+export interface HeightForPosition {
+ /** Lat/lon, the position for which the height was requested */
+ readonly coordinates: SingleCoordinate
+ /** The height for the position given by our backend */
+ readonly heightInMeter: number
+ readonly heightInFeet: number
+}
diff --git a/packages/api/src/types/icons.ts b/packages/api/src/types/icons.ts
new file mode 100644
index 0000000000..4c69cd6379
--- /dev/null
+++ b/packages/api/src/types/icons.ts
@@ -0,0 +1,85 @@
+/**
+ * Collection of icons belonging to the same "category" (or set).
+ *
+ * Some sets are colorable, where others aren't
+ */
+export interface DrawingIconSet {
+ /** Name of this set in the backend, lower cased */
+ name: string
+ /**
+ * Tells if this set's icons can be colored differently (if the color can be defined in each
+ * icon's URL)
+ */
+ isColorable: boolean
+ /* URL to the backend endpoint that gives all available icons for this set */
+ iconsURL: string
+ /**
+ * A template URL to access this icon set's metadata ({icon_set_name} needs to be replaced with
+ * this icon set's name)
+ */
+ templateURL: string
+ /** Tells if this icon set has icon descriptions */
+ hasDescription: boolean
+ /**
+ * Two letter iso code that corresponds to a specific language, if the icon set does not
+ * correspond to a language it is null
+ */
+ language: string
+ /** List of all icons from this icon set */
+ icons: DrawingIcon[]
+}
+
+/** Offset to apply to an icon when placed on a coordinate ([x,y] format) */
+export type DrawingIconAnchor = [number, number]
+/** Size of the icons in pixel assuming a scaling factor of 1 */
+export type DrawingIconSize = [number, number]
+
+/**
+ * Representation of one icon available in our backend.
+ *
+ * Each icon has a specific anchor (an offset from the coordinate it is linked to)
+ */
+export interface DrawingIcon {
+ /** Name of this icon in the backend (lower cased) */
+ name: string
+ /** URL to the image of this icon itself (with default size and color) */
+ imageURL: string
+ /**
+ * URL template where size and color can be defined (by replacing {icon_scale} and {{r},{g},{b}}
+ * respectively
+ */
+ imageTemplateURL: string
+ /** Name of the icon set in which belongs this icon (an icon can only belong to one icon set) */
+ iconSetName: string
+ /** Description of icon in all available languages */
+ description?: Record
+ /** Offset to apply to this icon when placed on a coordinate ([x,y] format) */
+ anchor: DrawingIconAnchor
+ size: DrawingIconSize
+}
+
+export interface IconAPIIconSet {
+ colorable: boolean
+ has_description: boolean
+ icons_url: string
+ language: string
+ name: string
+ template_url: string
+}
+
+export interface IconAPIIconSetsResponse {
+ items: IconAPIIconSet[]
+}
+
+export interface IconAPIIcon {
+ anchor: DrawingIconAnchor
+ description: Record | null
+ name: string
+ size: DrawingIconSize
+ template_url: string
+ url: string
+}
+
+export interface IconAPIIconsResponse {
+ items: IconAPIIcon[]
+}
diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts
new file mode 100644
index 0000000000..fb3e724f78
--- /dev/null
+++ b/packages/api/src/types/index.ts
@@ -0,0 +1,9 @@
+export type * from '@/types/features'
+export type * from '@/types/feedback'
+export type * from '@/types/files'
+export type * from '@/types/height'
+export type * from '@/types/icons'
+export type * from '@/types/lv03Reframe'
+export type * from '@/types/profile'
+export type * from '@/types/search'
+export type * from '@/types/topics'
diff --git a/packages/api/src/types/lv03Reframe.ts b/packages/api/src/types/lv03Reframe.ts
new file mode 100644
index 0000000000..7134bcb617
--- /dev/null
+++ b/packages/api/src/types/lv03Reframe.ts
@@ -0,0 +1,14 @@
+import type { CoordinateSystem, SingleCoordinate } from '@swissgeo/coordinates'
+
+export interface ReframeConfig {
+ /** LV95 or LV03 coordinate that we want expressed in the other coordinate system */
+ inputCoordinates: SingleCoordinate
+ /** Tells which projection is used to describe the input coordinate. Must be either LV03 or LV95. */
+ inputProjection: CoordinateSystem
+ /**
+ * Tells which projection the output coordinates should be expressed into. If nothing is given,
+ * the "opposite" swiss projection of the input will be chosen (if LV03 coordinates are given,
+ * the output will be LV95 coordinates, and vice versa)
+ */
+ outputProjection?: CoordinateSystem
+}
diff --git a/packages/api/src/types/profile.ts b/packages/api/src/types/profile.ts
new file mode 100644
index 0000000000..c4d0d4fd71
--- /dev/null
+++ b/packages/api/src/types/profile.ts
@@ -0,0 +1,56 @@
+import type { SingleCoordinate } from '@swissgeo/coordinates'
+
+export interface ServiceProfileAltitudes {
+ COMB?: number
+ DTM2?: number
+ DTM25?: number
+}
+
+export interface ServiceProfilePoints {
+ alts?: ServiceProfileAltitudes
+ dist: number
+ easting: number
+ northing: number
+}
+
+export interface ElevationProfilePoint {
+ /** Distance from first to current point (relative to the whole profile, not by chunks) */
+ dist?: number
+ coordinate: SingleCoordinate
+ /** Expressed in the COMB elevation model */
+ elevation?: number
+ hasElevationData: boolean
+}
+
+export interface ElevationProfileChunk {
+ points: ElevationProfilePoint[]
+ hasElevationData: boolean
+ hasDistanceData: boolean
+}
+
+export interface ElevationProfileMetadata {
+ totalLinearDist: number
+ minElevation: number
+ maxElevation: number
+ elevationDifference: number
+ totalAscent: number
+ totalDescent: number
+ /** Sum of slope/surface distances (distance on the ground) */
+ slopeDistance: number
+ /**
+ * Hiking time calculation for the profile in minutes.
+ *
+ * Official formula: http://www.wandern.ch/download.php?id=4574_62003b89 Reference link:
+ * http://www.wandern.ch
+ *
+ * But we use a slightly modified version from Schweizmobil
+ */
+ hikingTime: number
+ hasElevationData: boolean
+ hasDistanceData: boolean
+}
+
+export interface ElevationProfile {
+ chunks: ElevationProfileChunk[]
+ metadata: ElevationProfileMetadata
+}
diff --git a/packages/api/src/types/search.ts b/packages/api/src/types/search.ts
new file mode 100644
index 0000000000..de96f55e04
--- /dev/null
+++ b/packages/api/src/types/search.ts
@@ -0,0 +1,94 @@
+import type { CoordinateSystem, FlatExtent, SingleCoordinate } from '@swissgeo/coordinates'
+import type { Layer } from '@swissgeo/layers'
+
+import type { DrawingIconSet } from '@/types/icons'
+
+// API file that covers the backend endpoint http://api3.geo.admin.ch/services/sdiservices.html#search
+
+export type SearchResultTypes = 'LAYER' | 'LOCATION' | 'FEATURE'
+
+export interface SearchResult {
+ resultType: SearchResultTypes
+ /** ID of this search result */
+ id: string
+ /** Title of this search result (can be HTML as a string) */
+ title: string
+ /** The title without any HTML tags (will keep what's inside or tags if there are) */
+ sanitizedTitle: string
+ /** A description of this search result (plain text only, no HTML) */
+ description: string
+}
+
+export interface LayerSearchResult extends SearchResult {
+ /** ID of the layer in the layers config */
+ layerId: string
+}
+
+export interface LocationSearchResult extends SearchResult {
+ /**
+ * ID of this feature given by the backend (can be then used to access other information about
+ * the feature, such as the HTML popup). If the backend doesn't give a feature ID for this
+ * feature, the description will be used as a fallback ID.
+ */
+ featureId: string
+ /** Coordinate of this feature where to anchor the popup */
+ coordinate?: SingleCoordinate
+ /**
+ * Extent of this feature described as `[ [bottomLeftCoords], [topRightCoords] ]` (if this
+ * feature is a point, there will be two times the same point in the extent)
+ */
+ extent?: FlatExtent
+ /**
+ * The zoom level at which the map should be zoomed when showing the feature (if the extent is
+ * defined, this should be ignored). The zoom level correspond to a zoom level in the projection
+ * system this feature was requested in.
+ */
+ zoom: number
+}
+
+export interface LayerFeatureSearchResult extends LayerSearchResult, LocationSearchResult {
+ /** The layer of this feature. */
+ layer: Layer
+}
+
+export interface SearchResponseResult {
+ id: number
+ weight: number
+ attrs?: {
+ featureId: string
+ detail: string
+ geom_quadindex: string
+ geom_st_box2d: string
+ label: string
+ lat: number
+ lon: number
+ num: number
+ objectclass: string
+ origin: string
+ rank: number
+ x: number
+ y: number
+ zoomlevel: number
+ layer?: string
+ }
+}
+
+export interface SearchResponse {
+ results: SearchResponseResult[]
+}
+
+export interface SearchConfig {
+ /** The projection in which the search results must be returned */
+ outputProjection: CoordinateSystem
+ /** The query string that describe what is wanted from the search */
+ queryString: string
+ /** The lang ISO code in which the search must be conducted */
+ lang: string
+ /** The resolution of the map in which the search must be conducted, in meters per pixel */
+ resolution: number
+ /** List of searchable layers for which to fire search requests. */
+ layersToSearch?: Layer[]
+ /** The maximum number of results to return */
+ limit?: number
+ iconSets?: DrawingIconSet[]
+}
diff --git a/packages/api/src/types/topics.ts b/packages/api/src/types/topics.ts
new file mode 100644
index 0000000000..19d8305120
--- /dev/null
+++ b/packages/api/src/types/topics.ts
@@ -0,0 +1,65 @@
+import type { GeoAdminGroupOfLayers, GeoAdminLayer, Layer } from '@swissgeo/layers'
+
+/** Representation of a topic (a subset of layers to be shown to the user) */
+export interface Topic {
+ readonly id: string
+ /** The list of layers eligible for background when this topic is active */
+ readonly backgroundLayers: GeoAdminLayer[]
+ /**
+ * The layer that should be activated as the background layer by default when this topic is
+ * selected. The value will be set to undefined when the void layer should be selected.
+ */
+ readonly defaultBackgroundLayer: GeoAdminLayer | undefined
+ /**
+ * All layers that should be added to the displayed layer (but not necessarily visible, that
+ * will depend on their state)
+ */
+ readonly layersToActivate: Layer[]
+}
+
+export interface CatalogServerResponseLayer {
+ id: string
+ category: 'layer'
+ staging: 'dev' | 'int' | 'prod'
+ label: string
+ layerBodId: string
+}
+
+export interface CatalogServerResponseCategory {
+ id: string
+ category: 'topic'
+ staging: 'dev' | 'int' | 'prod'
+ label: string
+ selectedOpen: boolean
+ children: (CatalogServerResponseCategory | CatalogServerResponseLayer)[]
+}
+
+export interface CatalogServerResponseRoot {
+ id: string
+ children: CatalogServerResponseCategory[]
+}
+
+export interface CatalogServerResponse {
+ results: {
+ root: CatalogServerResponseRoot
+ }
+}
+
+export interface TopicTree {
+ layers: (GeoAdminLayer | GeoAdminGroupOfLayers)[]
+ itemIdToOpen: string[]
+}
+
+export interface ServicesResponseTopic {
+ id: string
+ defaultBackground: string
+ backgroundLayers: string[]
+ selectedLayers: string[]
+ activatedLayers: string[]
+ plConfig: string
+ groupId: number
+}
+
+export interface ServicesResponse {
+ topics: ServicesResponseTopic[]
+}
diff --git a/packages/api/src/utils/__tests__/gpxUtils.spec.ts b/packages/api/src/utils/__tests__/gpxUtils.spec.ts
new file mode 100644
index 0000000000..d435401ab9
--- /dev/null
+++ b/packages/api/src/utils/__tests__/gpxUtils.spec.ts
@@ -0,0 +1,13 @@
+import { LV95 } from '@swissgeo/coordinates'
+import { describe, expect, it } from 'vitest'
+
+import gpxUtils from '@/utils/gpxUtils'
+
+describe('Test GPX utils', () => {
+ describe('parseGpx', () => {
+ it('handles correctly invalid inputs', () => {
+ expect(gpxUtils.parseGpx('', LV95)).to.eql([])
+ })
+ // further testing isn't really necessary as it's using out-of-the-box OL functions
+ })
+})
diff --git a/packages/api/src/utils/__tests__/kmlUtils.spec.ts b/packages/api/src/utils/__tests__/kmlUtils.spec.ts
new file mode 100644
index 0000000000..216d54b351
--- /dev/null
+++ b/packages/api/src/utils/__tests__/kmlUtils.spec.ts
@@ -0,0 +1,514 @@
+import type { Feature } from 'ol'
+
+import { WEBMERCATOR } from '@swissgeo/coordinates'
+import { layerUtils } from '@swissgeo/layers/utils'
+import { getServiceKmlBaseUrl } from '@swissgeo/staging-config'
+import { readFileSync } from 'fs'
+import IconStyle from 'ol/style/Icon'
+import { resolve } from 'path'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import type { EditableFeature } from '@/types/features'
+import type { DrawingIconSet } from '@/types/icons'
+
+import iconsAPI from '@/icons'
+import { fakeIconSets } from '@/utils/__tests__/legacyKmlUtils.spec'
+import kmlUtils from '@/utils/kmlUtils'
+
+describe('Test KML utils', () => {
+ describe('get KML Extent', () => {
+ it('handles correctly invalid inputs', () => {
+ expect(kmlUtils.getKmlExtent('')).toBeUndefined()
+ })
+ it('returns null if the KML has no feature', () => {
+ const emptyDocument = `
+
+
+
+
+ `
+ expect(kmlUtils.getKmlExtent(emptyDocument)).toBeUndefined()
+ })
+ it('get extent of a single feature', () => {
+ const content = `
+
+
+
+ Sample Placemark
+ This is a sample KML Placemark.
+
+ 8.117189,46.852375
+
+
+
+
+ `
+ expect(kmlUtils.getKmlExtent(content)).to.deep.equal([
+ 8.117189, 46.852375, 8.117189, 46.852375,
+ ])
+ })
+ it('get extent of a single line feature crossing europe', () => {
+ const content = `
+
+
+
+ Line accross europe and switzerland
+ This is a sample KML Placemark.
+
+
+ -0.771255570521181,47.49354012712542,0
+ 2.274382135396515,47.72412908565185,0
+ 4.437570870313552,46.75086073673278,0
+ 6.524331142187565,46.85471653404525,0
+ 7.977505534554298,46.45920177484218,0
+ 8.377161172051387,46.87625449359594,0
+ 10.28654975787117,46.54805193225931,0
+ 13.85851000860247,47.33184853266135,0
+ 15.69760034017345,47.60792210418662,0
+
+
+
+
+
+ `
+ expect(kmlUtils.getKmlExtent(content)).to.deep.equal([
+ -0.771255570521181, 46.45920177484218, 15.69760034017345, 47.72412908565185,
+ ])
+ })
+ it('get extent of multiples features (marker, line, polygone)', () => {
+ const content = `
+
+
+
+ My Marker
+
+ 7.659940678339698,46.95427014117109,843.3989330301123
+
+
+
+ No title
+
+ 7.84495931692009,46.92430731160568,801.1875341365708
+
+
+
+ Polygone sans titre
+
+
+
+
+ 7.736857178846723,46.82670125209653,0 8.06124136917296,46.75405886506746,0 8.092263503513564,46.88254009447432,0 7.674984953448975,46.90741897412888,0 7.736857178846723,46.82670125209653,0
+
+
+
+
+
+
+ This is a line
+
+
+ 7.661326190900879,46.9229765613044,0 7.736317332421581,46.95310606597951,0 7.783350668405761,46.96964910688379,0 7.819080121407933,46.95554156911379,0 7.895270534105141,46.9409704077278,0
+
+
+
+
+
+ `
+ expect(kmlUtils.getKmlExtent(content)).to.deep.equal([
+ 7.659940678339698, 46.75405886506746, 8.092263503513564, 46.96964910688379,
+ ])
+ })
+ })
+ describe('isKmlFeaturesValid', () => {
+ it('returns false if there is an error in the features of the KML', () => {
+ const kml = readFileSync(resolve(__dirname, './samples/kml_feature_error.kml'), 'utf8')
+ expect(kmlUtils.isKmlFeaturesValid(kml)).to.be.false
+ })
+ it('returns true if there is no error in the features of the KML', () => {
+ const kml = readFileSync(
+ resolve(__dirname, './samples/webmapviewerOffsetTestKml.kml'),
+ 'utf8'
+ )
+ expect(kmlUtils.isKmlFeaturesValid(kml)).to.be.true
+ })
+ })
+ describe('get marker title offset', () => {
+ let features: EditableFeature[] = []
+ function findFeatureWithId(featureId: string) {
+ return features.find((feature) => feature.id === featureId)
+ }
+
+ beforeEach(() => {
+ const kml = readFileSync(
+ resolve(__dirname, './samples/webmapviewerOffsetTestKml.kml'),
+ 'utf8'
+ )
+ const kmlLayer = layerUtils.makeKMLLayer({
+ kmlFileUrl: getServiceKmlBaseUrl(), // so that it is not considered external
+ kmlData: kml,
+ })
+ const resolution = 1000
+ const olFeatures: Feature[] = kmlUtils.parseKml(
+ kmlLayer,
+ WEBMERCATOR,
+ fakeIconSets,
+ resolution
+ )
+ features = olFeatures
+ .map((f) => {
+ const ef = f.get('editableFeature')
+ if (ef) {
+ ef.olFeature = f
+ }
+ return ef
+ })
+ .filter((f) => f !== undefined) as EditableFeature[]
+ })
+ it('handles correctly text offset from extended data', () => {
+ const icon = findFeatureWithId('drawing_feature_1714651153088')
+ expect(icon).toBeDefined()
+ expect(icon.textOffset).to.deep.equal([0, -44.75])
+ })
+ it('handles correctly text offset if no offset provided', () => {
+ const icon = findFeatureWithId('drawing_feature_1714651899088')
+ expect(icon).toBeDefined()
+ expect(icon.textOffset).to.deep.equal([0, 0])
+ })
+ })
+ describe('parseIconUrl', () => {
+ it('parse a new icon url of a default set', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://sys-map.dev.bgdi.ch/api/icons/sets/default/icons/001-marker@1.5x-0,128,0.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('default')
+ expect(args.name).to.be.equal('001-marker')
+ expect(args.color).toBeDefined()
+ expect(args.color?.r).to.be.equal(0)
+ expect(args.color?.g).to.be.equal(128)
+ expect(args.color?.b).to.be.equal(0)
+ expect(args.isLegacy).to.be.false
+ })
+ it('parse a new icon url of a default set with invalid color', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://sys-map.dev.bgdi.ch/api/icons/sets/default/icons/001-marker@4x-0,600,0.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('default')
+ expect(args.name).to.be.equal('001-marker')
+ expect(args.color).toBeDefined()
+ expect(args.color?.r).to.be.equal(0)
+ expect(args.color?.g).to.be.equal(255)
+ expect(args.color?.b).to.be.equal(0)
+ expect(args.isLegacy).to.be.false
+ })
+ it('parse a new icon url of a default set with no number color', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://sys-map.dev.bgdi.ch/api/icons/sets/default/icons/001-marker@1.5x-0,600,a.png'
+ )
+ expect(args).toBeUndefined()
+ })
+ it('parse a new icon url of a babs set', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://sys-map.dev.bgdi.ch/api/icons/sets/babs/icons/babs-100@1x-255,128,3.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('babs')
+ expect(args.name).to.be.equal('babs-100')
+ expect(args.color).toBeDefined()
+ expect(args.color?.r).to.be.equal(255)
+ expect(args.color?.g).to.be.equal(128)
+ expect(args.color?.b).to.be.equal(3)
+ expect(args.isLegacy).to.be.false
+ })
+ it('parse a new icon standard url of a default set (no scale no color)', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://map.geo.admin.ch/api/icons/sets/my-set/icons/my-icon.png'
+ )
+ expect(args).toBeUndefined()
+ })
+ it('parse a new icon url of a default set without scale', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://map.geo.admin.ch/api/icons/sets/my-set/icons/my-icon-255,0,0.png'
+ )
+ expect(args).toBeUndefined()
+ })
+ it('parse a new icon url of a default set without color', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://map.geo.admin.ch/api/icons/sets/my-set/icons/my-icon@1.5x.png'
+ )
+ expect(args).toBeUndefined()
+ })
+ it('parse a legacy icon url of a default set', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://api3.geo.admin.ch/color/45,128,23/star-24@2x.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('default')
+ expect(args.name).to.be.equal('star')
+ expect(args.color).toBeDefined()
+ expect(args.color?.r).to.be.equal(45)
+ expect(args.color?.g).to.be.equal(128)
+ expect(args.color?.b).to.be.equal(23)
+ expect(args.isLegacy).to.be.true
+ })
+ it('parse a legacy icon url of a default set with invalid color', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://api3.geo.admin.ch/color/45,600,800/star-24@2x.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('default')
+ expect(args.name).to.be.equal('star')
+ expect(args.color).toBeDefined()
+ expect(args.color?.r).to.be.equal(45)
+ // invalid color fallback to 255
+ expect(args.color?.g).to.be.equal(255)
+ expect(args.color?.b).to.be.equal(255)
+ expect(args.isLegacy).to.be.true
+ })
+ it('parse a legacy icon url of a default set with non number color', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://api3.geo.admin.ch/color/45,a,800/star-24@2x.png'
+ )
+ expect(args).toBeUndefined()
+ })
+ it('parse a legacy icon url of a babs set', () => {
+ const args = kmlUtils.parseIconUrl(
+ 'https://sys-api3.dev.bgdi.ch/images/babs/babs-76.png'
+ )
+ expect(args).toBeDefined()
+ expect(args.set).to.be.equal('babs')
+ expect(args.name).to.be.equal('babs-76')
+ // expect default scale and color
+ expect(args.color?.r).to.be.equal(255)
+ expect(args.color?.g).to.be.equal(0)
+ expect(args.color?.b).to.be.equal(0)
+ expect(args.isLegacy).to.be.true
+ })
+ })
+ describe('getIcon', () => {
+ const fakeDefaultIconSet: DrawingIconSet = {
+ name: 'default',
+ isColorable: true,
+ hasDescription: false,
+ language: 'en',
+ iconsURL: 'https://totally.fake.url',
+ templateURL: 'https://tottally.fake.template',
+ icons: [
+ {
+ name: '001-marker',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL:
+ 'https://fake.image.url/api/icons/sets/{icon_set_name}/icons/{icon_name}@{icon_scale}-{r},{g},{b}.png',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ {
+ name: '002-circle',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL:
+ 'https://fake.image.url/api/icons/sets/{icon_set_name}/icons/{icon_name}@{icon_scale}-{r},{g},{b}.png',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ {
+ name: '0003-square',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL:
+ 'https://fake.image.url/api/icons/sets/{icon_set_name}/icons/{icon_name}@{icon_scale}-{r},{g},{b}.png',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ ],
+ }
+ const fakeBabsIconSet: DrawingIconSet = {
+ name: 'babs',
+ isColorable: false,
+ hasDescription: true,
+ language: 'en',
+ iconsURL: 'https://another.fake.url',
+ templateURL: 'https://another.fake.template',
+ icons: [
+ {
+ name: 'babs-3',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL:
+ 'https://fake.image.url/api/icons/sets/{icon_set_name}/icons/{icon_name}@{icon_scale}-{r},{g},{b}.png',
+ iconSetName: 'babs',
+ anchor: [0, 0],
+ size: [48, 48],
+ description: {
+ en: 'BABS 3 icon',
+ },
+ },
+ ],
+ }
+ const fakeIconSets: DrawingIconSet[] = [fakeDefaultIconSet, fakeBabsIconSet]
+ it('get icon with standard arguments from the set', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'default',
+ name: '001-marker',
+ isLegacy: false,
+ },
+ undefined,
+ fakeIconSets
+ )
+ expect(icon).toBeDefined()
+ expect(icon.name).to.be.equal('001-marker')
+ expect(icon.iconSetName).to.be.equal('default')
+ expect(iconsAPI.generateIconURL(icon)).to.be.equal(
+ 'https://fake.image.url/api/icons/sets/default/icons/001-marker@1x-255,0,0.png'
+ )
+ })
+ it('get icon with standard arguments from the set with color', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'default',
+ name: '001-marker',
+ isLegacy: false,
+ },
+ undefined,
+ fakeIconSets
+ )
+ expect(icon).toBeDefined()
+ expect(icon.name).to.be.equal('001-marker')
+ expect(icon.iconSetName).to.be.equal('default')
+ expect(iconsAPI.generateIconURL(icon, '#123456')).to.be.equal(
+ 'https://fake.image.url/api/icons/sets/default/icons/001-marker@1x-18,52,86.png'
+ )
+ })
+ it('get icon with standard arguments from the babs set', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'babs',
+ name: 'babs-3',
+ isLegacy: false,
+ },
+ undefined,
+ fakeIconSets
+ )
+ expect(icon).toBeDefined()
+ expect(icon.name).to.be.equal('babs-3')
+ expect(icon.iconSetName).to.be.equal('babs')
+ expect(iconsAPI.generateIconURL(icon)).to.be.equal(
+ 'https://fake.image.url/api/icons/sets/babs/icons/babs-3@1x-255,0,0.png'
+ )
+ })
+ it('get icon with standard arguments without sets and style', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'default',
+ name: '001-marker',
+ isLegacy: false,
+ },
+ undefined,
+ undefined
+ )
+ expect(icon).toBeUndefined()
+ })
+ it('get icon with standard arguments witout sets', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'default',
+ name: '001-marker',
+ isLegacy: false,
+ },
+ new IconStyle({
+ src: 'https://fake.image.url/api/icons/sets/default/icons/001-marker@1x-255,0,0.png',
+ })
+ )
+ expect(icon).toBeDefined()
+ expect(icon.name).to.be.equal('001-marker')
+ expect(icon.iconSetName).to.be.equal('default')
+ expect(iconsAPI.generateIconURL(icon)).to.be.equal(
+ 'https://fake.image.url/api/icons/sets/default/icons/001-marker@1x-255,0,0.png'
+ )
+ })
+ it('get legacy icon with standard arguments witout sets', () => {
+ const icon = kmlUtils.getIcon(
+ {
+ set: 'default',
+ name: 'star',
+ isLegacy: true,
+ },
+ new IconStyle({
+ src: 'https://api3.geo.admin.ch/color/45,600,800/star-24@2x.png',
+ })
+ )
+ expect(icon).toBeDefined()
+ expect(icon.name).to.be.equal('star')
+ expect(icon.iconSetName).to.be.equal('default')
+ expect(iconsAPI.generateIconURL(icon)).to.be.equal(
+ 'https://api3.geo.admin.ch/color/45,600,800/star-24@2x.png'
+ )
+ })
+ })
+ describe('isKml', () => {
+ it('detects a simple KML file syntax correctly', () => {
+ expect(kmlUtils.isKml('test')).to.be.true
+ })
+ it('can detect a KML file with a namespace prefix', () => {
+ expect(kmlUtils.isKml('test')).to.be.true
+ })
+ it('can detect a KML file with a namespace prefix and a namespace declaration', () => {
+ expect(kmlUtils.isKml('test'))
+ .to.be.true
+ })
+ it('handles carriage returns correctly', () => {
+ expect(
+ kmlUtils.isKml(
+ `
+
+ test
+`
+ )
+ ).to.be.true
+ })
+ it('can handle a full KML sample correctly', () => {
+ expect(
+ kmlUtils.isKml(`
+
+
+ test
+ KML With Prefixed Namespace
+
+ 7.438632503,46.951082887,598.947
+
+
+`)
+ ).to.be.true
+ })
+ it('rejects a tag with a typo at the end', () => {
+ expect(kmlUtils.isKml('test')).to.be.false
+ })
+ it('rejects a tag with a typo in the middle', () => {
+ expect(kmlUtils.isKml('test')).to.be.false
+ })
+ it('rejects a tag with a typo at the beginning', () => {
+ expect(kmlUtils.isKml('test')).to.be.false
+ })
+ it('rejects a valid XML (but-non KML) input', () => {
+ expect(kmlUtils.isKml('test
')).to.be
+ .false
+ })
+ it('rejects a KML that is wrapped in a CDATA section', () => {
+ expect(
+ kmlUtils.isKml(`
+
+ test
+ KML With Prefixed Namespace
+
+ 7.438632503,46.951082887,598.947
+
+
+
+]]>
`)
+ ).to.be.false
+ })
+ })
+})
diff --git a/packages/api/src/utils/__tests__/legacyKmlUtils.spec.ts b/packages/api/src/utils/__tests__/legacyKmlUtils.spec.ts
new file mode 100644
index 0000000000..eed7a900ea
--- /dev/null
+++ b/packages/api/src/utils/__tests__/legacyKmlUtils.spec.ts
@@ -0,0 +1,211 @@
+import type { default as Feature } from 'ol/Feature'
+
+import { WEBMERCATOR } from '@swissgeo/coordinates'
+import { layerUtils } from '@swissgeo/layers/utils'
+import { getServiceKmlBaseUrl } from '@swissgeo/staging-config'
+import { readFileSync } from 'fs'
+import { resolve } from 'path'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import type { EditableFeature, EditableFeatureTypes } from '@/types/features'
+import type { DrawingIconSet } from '@/types/icons'
+
+import featureStyleUtils from '@/utils/featureStyleUtils'
+import kmlUtils from '@/utils/kmlUtils'
+
+const fakeDefaultIconSet: DrawingIconSet = {
+ name: 'default',
+ isColorable: true,
+ iconsURL: 'https://totally.fake.url',
+ templateURL: 'https://tottally.fake.template',
+ hasDescription: false,
+ language: 'en',
+ // adding the 3 icons from the default set found in mfgeoadmin3TestKml.kml
+ icons: [
+ {
+ name: '001-marker',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL: 'https://fake.template.url',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ {
+ name: '002-circle',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL: 'https://fake.template.url',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ {
+ name: '0003-square',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL: 'https://fake.template.url',
+ iconSetName: 'default',
+ anchor: [0, 0],
+ size: [48, 48],
+ },
+ ],
+}
+const fakeBabsIconSet: DrawingIconSet = {
+ name: 'babs',
+ isColorable: false,
+ iconsURL: 'https://another.fake.url',
+ templateURL: 'https://another.fake.template',
+ hasDescription: true,
+ language: 'en',
+ icons: [
+ {
+ name: 'babs-3',
+ imageURL: 'https://fake.image.url',
+ imageTemplateURL: 'https://fake.template.url',
+ iconSetName: 'babs',
+ anchor: [0, 0],
+ size: [48, 48],
+ description: {
+ en: 'some description',
+ },
+ },
+ ],
+}
+export const fakeIconSets: DrawingIconSet[] = [fakeDefaultIconSet, fakeBabsIconSet]
+
+function performStandardChecks(
+ feature: EditableFeature | undefined,
+ expectedFeatureType: EditableFeatureTypes,
+ expectedTitle: string = '',
+ expectedDescription: string = '',
+ expectedCoordinateCount: number = 3
+): void {
+ expect(feature).toBeDefined()
+ expect(feature.coordinates).toBeDefined()
+ expect(feature.coordinates).to.have.length.greaterThan(0)
+
+ if (feature.coordinates.length === 1) {
+ expect(feature.coordinates[0]).to.have.length(expectedCoordinateCount)
+ } else {
+ expect(feature.coordinates).to.have.length(expectedCoordinateCount)
+ }
+
+ expect(feature.title).to.be.equal(expectedTitle)
+ expect(feature.description).to.equal(expectedDescription)
+ expect(feature.featureType).to.be.equal(expectedFeatureType)
+}
+
+describe('Validate deserialization of the mf-geoadmin3 viewer kml format', () => {
+ let features: EditableFeature[] = []
+
+ function findFeatureWithId(featureId: string) {
+ return features.find((feature) => feature.id === featureId)
+ }
+
+ beforeEach(() => {
+ const kml = readFileSync(resolve(__dirname, './samples/mfgeoadmin3TestKml.kml'), 'utf8')
+ const kmlLayer = layerUtils.makeKMLLayer({
+ kmlFileUrl: getServiceKmlBaseUrl(), // so that it is not considered external
+ kmlData: kml,
+ })
+ const resolution = 12345
+ const olFeatures: Feature[] = kmlUtils.parseKml(
+ kmlLayer,
+ WEBMERCATOR,
+ fakeIconSets,
+ resolution
+ )
+ features = olFeatures
+ .map((f) => f.get('editableFeature'))
+ .filter((f) => f !== undefined) as EditableFeature[]
+ })
+ describe('icon parsing', () => {
+ it('parses a marker with a very small scale and blue fill color correctly', () => {
+ const icon = findFeatureWithId('marker_1668530694970')
+ performStandardChecks(icon, 'MARKER', 'icon 1', 'desc 1')
+ expect(icon.icon).toBeDefined()
+ expect(icon.icon.name).to.be.equal('001-marker')
+ expect(icon.fillColor).to.be.an('Object').that.have.property('fill')
+ expect(icon.fillColor.fill).to.equal('#0000ff')
+ expect(icon.iconSize).to.be.equal(featureStyleUtils.SMALL)
+ })
+ it('parses a marker with a small scale and grey fill color correctly', () => {
+ const icon = findFeatureWithId('marker_1668530774636')
+ performStandardChecks(icon, 'MARKER', 'icon 2', 'desc 2')
+ expect(icon.icon).toBeDefined()
+ expect(icon.icon.name).to.be.equal('002-circle')
+ expect(icon.fillColor).to.be.equal(featureStyleUtils.GRAY)
+ expect(icon.iconSize).to.be.equal(featureStyleUtils.MEDIUM)
+ expect(icon.textColor).to.be.equal(featureStyleUtils.WHITE)
+ })
+ it('parses a marker with a big BABS icon correctly', () => {
+ const icon = findFeatureWithId('marker_1668530823345')
+ performStandardChecks(icon, 'MARKER', 'icon 3', 'desc 3')
+ expect(icon.icon).toBeDefined()
+ expect(icon.icon.name).to.be.equal('babs-3')
+ expect(icon.fillColor).to.be.equal(featureStyleUtils.RED) // default should be red
+ expect(icon.iconSize).to.be.equal(featureStyleUtils.LARGE)
+ expect(icon.textColor).to.be.equal(featureStyleUtils.RED)
+ })
+ })
+ describe('text parsing', () => {
+ it('parses a small black text correctly', () => {
+ const standardText = findFeatureWithId('annotation_1668530699494')
+ performStandardChecks(standardText, 'ANNOTATION', 'text 1', '')
+ expect(standardText.textColor).to.be.equal(featureStyleUtils.BLACK)
+ expect(standardText.textSize).to.be.equal(featureStyleUtils.SMALL)
+ expect(standardText.fillColor).to.be.equal(featureStyleUtils.RED) // default should be RED even if no icon is defined
+ expect(standardText.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(standardText.icon).to.be.undefined
+ })
+ it('parses a medium blue text correctly', () => {
+ const standardText = findFeatureWithId('annotation_1668530932170')
+ performStandardChecks(standardText, 'ANNOTATION', 'text 2', '')
+ expect(standardText.textColor).to.be.equal(featureStyleUtils.BLUE)
+ expect(standardText.textSize).to.be.equal(featureStyleUtils.MEDIUM)
+ expect(standardText.fillColor).to.be.equal(featureStyleUtils.RED) // default should be RED even if no icon is defined
+ expect(standardText.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(standardText.icon).to.be.undefined
+ })
+ it('parses a large gray text correctly', () => {
+ const standardText = findFeatureWithId('annotation_1668530944079')
+ performStandardChecks(standardText, 'ANNOTATION', 'text 3', '')
+ expect(standardText.textColor).to.be.equal(featureStyleUtils.GRAY)
+ expect(standardText.textSize).to.be.equal(featureStyleUtils.LARGE)
+ expect(standardText.fillColor).to.be.equal(featureStyleUtils.RED) // default should be RED even if no icon is defined
+ expect(standardText.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(standardText.icon).to.be.undefined
+ })
+ })
+ describe('line/polygon parsing', () => {
+ it('parses a line/polygon with two points and black fill correctly', () => {
+ const line = findFeatureWithId('linepolygon_1668530962424')
+ performStandardChecks(line, 'LINEPOLYGON', '', 'desc 7', 2)
+ expect(line.fillColor).to.be.equal(featureStyleUtils.BLACK)
+ expect(line.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(line.icon).to.be.undefined
+ })
+ it('parses a line/polygon with two points and blue fill correctly', () => {
+ const line = findFeatureWithId('linepolygon_1668530991477')
+ performStandardChecks(line, 'LINEPOLYGON', '', 'desc 8', 2)
+ expect(line.fillColor).to.be.equal(featureStyleUtils.BLUE)
+ expect(line.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(line.icon).to.be.undefined
+ })
+ it('parses a line/polygon with five points and yellow fill correctly', () => {
+ const line = findFeatureWithId('linepolygon_1668625663095')
+ performStandardChecks(line, 'LINEPOLYGON', '', 'desc 9', 5)
+ expect(line.fillColor).to.be.equal(featureStyleUtils.YELLOW)
+ expect(line.iconSize).to.eq(featureStyleUtils.MEDIUM)
+ expect(line.icon).to.be.undefined
+ })
+ })
+ describe('measure parsing', () => {
+ it('parses a measure with two points correctly', () => {
+ const line = findFeatureWithId('measure_1668531023034')
+ performStandardChecks(line, 'MEASURE', '', '', 2)
+ })
+ it('parses a measure with three points correctly', () => {
+ const line = findFeatureWithId('measure_1668531037052')
+ performStandardChecks(line, 'MEASURE', '', '', 3)
+ })
+ })
+})
diff --git a/packages/api/src/utils/__tests__/legacyLayerParamUtils.spec.ts b/packages/api/src/utils/__tests__/legacyLayerParamUtils.spec.ts
new file mode 100644
index 0000000000..8f88b10ab8
--- /dev/null
+++ b/packages/api/src/utils/__tests__/legacyLayerParamUtils.spec.ts
@@ -0,0 +1,408 @@
+import type { ExternalWMSLayer, GeoAdminLayer, Layer } from '@swissgeo/layers'
+
+import { LayerType } from '@swissgeo/layers'
+import { layerUtils, timeConfigUtils } from '@swissgeo/layers/utils'
+import { describe, expect, it } from 'vitest'
+
+import legacyLayerParamUtils from '@/utils/legacyLayerParamUtils'
+
+describe('Test parsing of legacy URL param into new params', () => {
+ describe('test getLayersFromLegacyUrlParams', () => {
+ const fakeLayerConfig: GeoAdminLayer[] = [
+ layerUtils.makeGeoAdminWMSLayer({
+ name: 'Test layer WMS',
+ id: 'test.wms.layer',
+ technicalName: 'test.wms.layer',
+ opacity: 0.8,
+ attributions: [{ name: 'attribution.test.wms.layer' }],
+ baseUrl: 'https://base-url/',
+ format: 'png',
+ timeConfig: timeConfigUtils.makeTimeConfig(),
+ }),
+ layerUtils.makeGeoAdminWMTSLayer({
+ name: 'Test layer WMTS',
+ id: 'test.wmts.layer',
+ technicalName: 'test.wmts.layer',
+ attributions: [{ name: 'test' }],
+ }),
+ layerUtils.makeGeoAdminWMTSLayer({
+ name: 'Test timed layer WMTS',
+ technicalName: 'test.timed.wmts.layer',
+ id: 'test.timed.wmts.layer',
+ opacity: 0.8,
+ attributions: [{ name: 'attribution.test.timed.wmts.layer' }],
+ timeConfig: timeConfigUtils.makeTimeConfig('123', [
+ timeConfigUtils.makeTimeConfigEntry('123'),
+ timeConfigUtils.makeTimeConfigEntry('456'),
+ timeConfigUtils.makeTimeConfigEntry('789'),
+ ]),
+ }),
+ ]
+ it('Parses layers IDs and pass them along', () => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ 'test.wms.layer',
+ undefined,
+ undefined,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [firstLayer] = result
+ expect(firstLayer?.id).to.eq('test.wms.layer')
+ })
+ it('Parses visibility when specified', () => {
+ const checkOneLayerVisibility = (flagValue: boolean) => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `test.wms.layer`,
+ `${flagValue}`,
+ undefined,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [firstLayer] = result
+ expect(firstLayer).to.haveOwnProperty('isVisible')
+ expect(firstLayer.isVisible).to.eq(
+ flagValue,
+ 'param layer_visibility was not parsed correctly'
+ )
+ }
+ checkOneLayerVisibility(true)
+ checkOneLayerVisibility(false)
+ })
+ it('sets visibility to true when layers_visibility is not present', () => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ 'test.wms.layer',
+ undefined,
+ undefined,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [firstLayer] = result
+ expect(firstLayer.isVisible).to.be.true
+ })
+ it('Parses opacity when specified', () => {
+ const checkOneLayerOpacity = (opacity: number) => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `test.wms.layer`,
+ undefined,
+ `${opacity}`,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [firstLayer] = result
+ expect(firstLayer).to.haveOwnProperty('opacity')
+ expect(firstLayer.opacity).to.eq(opacity)
+ }
+ for (let i = 0; i <= 10; i += 1) {
+ checkOneLayerOpacity(i / 10.0)
+ }
+ })
+ it('Parses timestamps when specified', () => {
+ const checkOneLayerTimestamps = (timestamp: string) => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `test.timed.wmts.layer`,
+ undefined,
+ undefined,
+ `${timestamp}`
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [firstLayer] = result
+ expect(firstLayer).to.haveOwnProperty('timeConfig')
+ expect(firstLayer.timeConfig.currentTimeEntry?.timestamp).to.eq(timestamp)
+ }
+ fakeLayerConfig[2].timeConfig.timeEntries.forEach((entry) =>
+ checkOneLayerTimestamps(entry.timestamp)
+ )
+ })
+ it('Parses multiples layers with all params set', () => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ 'test.wmts.layer,test.wms.layer,test.timed.wmts.layer',
+ 'false,true,false',
+ '0.6,0.5,0.8',
+ ',,456'
+ )
+ expect(result).to.be.an('Array').length(3)
+ const [wmtsLayer, wmsLayer, timedWmtsLayer] = result
+ const checkLayer = (
+ layer: Layer,
+ expectedId: string,
+ expectedOpacity: number,
+ expectedVisibility: boolean,
+ expecedTimestamp: string | undefined = undefined
+ ) => {
+ expect(layer.id).to.eq(expectedId)
+ expect(layer).to.haveOwnProperty('opacity')
+ expect(layer.opacity).to.eq(expectedOpacity)
+ expect(layer).to.haveOwnProperty('isVisible')
+ expect(layer.isVisible).to.eq(expectedVisibility)
+ if (expecedTimestamp) {
+ expect(layer).to.haveOwnProperty('timeConfig')
+ expect(layer.timeConfig.currentTimeEntry?.timestamp).to.eq(expecedTimestamp)
+ }
+ }
+ checkLayer(wmtsLayer, 'test.wmts.layer', 0.6, false)
+ checkLayer(wmsLayer, 'test.wms.layer', 0.5, true)
+ checkLayer(timedWmtsLayer, 'test.timed.wmts.layer', 0.8, false, '456')
+ })
+ describe('support for legacy external layers URL format', () => {
+ it('Parses KML layers IDs correctly', () => {
+ const kmlFileUrl = 'https://public.geo.admin.ch/super-legit-file-id'
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `KML||${kmlFileUrl}`,
+ undefined,
+ undefined,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [kmlLayer] = result
+ expect(kmlLayer.id).to.eq(`KML|${kmlFileUrl}`)
+ expect(kmlLayer.type).to.eq(LayerType.KML)
+ expect(kmlLayer.baseUrl).to.eq(kmlFileUrl)
+ })
+ it('Handles opacity/visibility correctly with external layers', () => {
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ 'KML||https://we-dont-care-about-this-url',
+ 'true',
+ '0.65',
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [kmlLayer] = result
+ expect(kmlLayer.opacity).to.eq(0.65)
+ expect(kmlLayer.isVisible).to.be.true
+ })
+ it('parses a legacy external WMS layer correctly', () => {
+ const wmsLayerName = 'Name of the WMS layer, with a comma'
+ const wmsBaseUrl = 'https://fake.url?SERVICE=GetMap&'
+ const wmsLayerId = 'fake.layer.id'
+ const wmsVersion = '9.9.9'
+ const legacyLayerUrlString = `WMS||${wmsLayerName}||${wmsBaseUrl}||${wmsLayerId}||${wmsVersion}`
+
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `${encodeURIComponent(legacyLayerUrlString)}`,
+ `true`,
+ `0.45`,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [externalWmsLayer] = result
+ expect(externalWmsLayer.type).to.eq(LayerType.WMS)
+ expect(externalWmsLayer.opacity).to.eq(0.45)
+ expect((externalWmsLayer as ExternalWMSLayer).wmsVersion).to.eq(wmsVersion)
+ expect(externalWmsLayer.id).to.eq(wmsLayerId)
+ expect(externalWmsLayer.name).to.eq(wmsLayerName)
+ expect(externalWmsLayer.baseUrl).to.eq(wmsBaseUrl)
+ })
+ it('parses a legacy external WMTS layer correctly', () => {
+ const wmtsLayerId = 'fake.wmts.id'
+ const wmtsGetCapabilitesUrl = 'https://fake.wmts.server/WMTSCapabilities.xml'
+ const legacyLayerUrlString = encodeURIComponent(
+ `WMTS||${wmtsLayerId}||${wmtsGetCapabilitesUrl}`
+ )
+ const result = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `${legacyLayerUrlString}`,
+ `false`,
+ `0.77`,
+ undefined
+ )
+ expect(result).to.be.an('Array').length(1)
+ const [externalWmtsLayer] = result
+ expect(externalWmtsLayer.type).to.eq(LayerType.WMTS)
+ expect(externalWmtsLayer.opacity).to.eq(0.77)
+ expect(externalWmtsLayer.isVisible).to.be.false
+ expect(externalWmtsLayer.id).to.eq(wmtsLayerId)
+ expect(externalWmtsLayer.baseUrl).to.eq(wmtsGetCapabilitesUrl)
+ })
+ it('does not parse an external layer if it is in the current format', () => {
+ const wmtsResult = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ 'WMTS|https://url.to.wmts.server|layer.id',
+ undefined,
+ undefined,
+ undefined
+ )
+ expect(wmtsResult).to.be.an('Array').empty
+ const wmsResult = legacyLayerParamUtils.getLayersFromLegacyUrlParams(
+ fakeLayerConfig,
+ `WMS|${'https://wms.server.url?PARAM1=x&'}|layer.id`,
+ undefined,
+ undefined,
+ undefined
+ )
+ expect(wmsResult).to.be.an('Array').empty
+ })
+ describe('utility functions for legacy Parameter Handling', () => {
+ it('ensure the parseOpacity Function always returns a valid value', () => {
+ const correct_opacity = legacyLayerParamUtils.parseOpacity('0.321')
+ expect(correct_opacity).to.equal(0.321)
+ const opacity_too_low = legacyLayerParamUtils.parseOpacity('-0.2')
+ expect(opacity_too_low).to.equal(0)
+ const opacity_too_high = legacyLayerParamUtils.parseOpacity('1.45')
+ expect(opacity_too_high).to.equal(1)
+ const opacity_NaN = legacyLayerParamUtils.parseOpacity('test')
+ expect(opacity_NaN).to.equal(1)
+ })
+ it('Makes sure the isLegacyParams function recognize a legacy URL', () => {
+ expect(legacyLayerParamUtils.isLegacyParams('?test=true')).to.equal(true)
+ expect(legacyLayerParamUtils.isLegacyParams('/?test=true')).to.equal(true)
+ expect(legacyLayerParamUtils.isLegacyParams('/?test')).to.equal(true)
+ })
+ it("Makes sure the isLegacyParams function don't match new URL", () => {
+ expect(legacyLayerParamUtils.isLegacyParams(undefined)).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('/?')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('?')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('#/map?test')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('/#/map?test')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('#?test=false')).to.equal(false)
+ expect(legacyLayerParamUtils.isLegacyParams('#/?test=false')).to.equal(false)
+ })
+ })
+ })
+ describe('ensure layers parameter handler for feature preselection works as intended', () => {
+ /**
+ * Small precision regarding the createLayersParamForFeaturePreselection, which is what
+ * we are testing here : This function is only called when : there is a layer-id
+ * parameter set and that layer is already within the query 'layers' parameters. This
+ * means we do not need to test if there is no param_key set, or if there is no layers
+ * set
+ */
+
+ // This function deals mostly with the special parameters and features id order,
+ // As they are not important, but could break the tests if we made a simple string comparison
+ // layer.id@time=123@features=1,2 is the same end result as layer.id@features=2,1@time=123
+ function compareLayersStrings(layerString1: string, layerString2: string) {
+ const [layer1AndParams, visibility1, opacity1] = layerString1.split(',')
+ const [layer2AndParams, visibility2, opacity2] = layerString2.split(',')
+ expect(visibility1).to.eq(visibility2)
+ expect(opacity1).to.eq(opacity2)
+ const layer1Split = layer1AndParams.split('@')
+ const layer2Split = layer2AndParams.split('@')
+ layer1Split.sort()
+ layer2Split.sort()
+ expect(layer1Split.length).to.eq(layer2Split.length)
+ for (let i = 0; i < layer1Split.length; i++) {
+ if (layer1Split[i].includes('features')) {
+ expect(layer2Split[i].includes('features')).to.eq(true)
+ const features_1 = layer1Split[i].split('=')[1].split(':')
+ const features_2 = layer2Split[i].split('=')[1].split(':')
+ features_1.sort()
+ features_2.sort()
+ expect(features_1.join(':')).to.eq(features_2.join(':'))
+ } else {
+ expect(layer1Split[i]).to.eq(layer2Split[i])
+ }
+ }
+ }
+ function testLayersStringCreation(params: {
+ layerId: string
+ featuresId: string | null
+ layers: string
+ expectedResult: string
+ }) {
+ const result = legacyLayerParamUtils.createLayersParamForFeaturePreselection(
+ params.layerId,
+ params.featuresId ?? '',
+ params.layers
+ )
+
+ const [layer1, layer2] = result.split(';')
+ const [expectedLayer1, expectedLayer2] = params.expectedResult.split(';')
+ expect(layer1).to.be.a('string')
+ expect(layer2).to.be.a('string')
+ compareLayersStrings(layer1, expectedLayer1)
+ compareLayersStrings(layer2, expectedLayer2)
+ }
+ it('adds a Feature parameter when the layer has no parameter at all', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '1,2,3',
+ layers: 'layer.id;layer.id2',
+ expectedResult: 'layer.id@features=1:2:3;layer.id2',
+ })
+ })
+ it('adds a Feature parameter when the layer only has visibility and opacity params', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '1,2,3',
+ layers: 'layer.id,,0.3;layer.id2',
+ expectedResult: 'layer.id@features=1:2:3,,0.3;layer.id2',
+ })
+ })
+ it('adds a Feature parameter when there is a time parameter given', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '1,2,3',
+ layers: 'layer.id@time=1234;layer.id2',
+ expectedResult: 'layer.id@time=1234@features=1:2:3;layer.id2',
+ })
+ })
+ it("combines existing features between the already given features and the legacy parameter's features", () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '1,2,3',
+ layers: 'layer.id@features=3:4:5;layer.id2',
+ expectedResult: 'layer.id@features=1:2:3:4:5;layer.id2',
+ })
+ })
+ it('combines existing features when all parameters are set', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '1,2,3',
+ layers: 'layer.id@features=3:4:5@time=1234,f,0.2;layer.id2',
+ expectedResult: 'layer.id@time=1234@features=1:2:3:4:5,f,0.2;layer.id2',
+ })
+ })
+ it('does not add a feature parameter when the feature ids are an empty string', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: '',
+ layers: 'layer.id;layer.id2',
+ expectedResult: 'layer.id;layer.id2',
+ })
+ })
+ it('does not add a feature parameter when the feature ids are null', () => {
+ // I am not certain this is possible, but I prefer to test it anyway
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: null,
+ layers: 'layer.id;layer.id2',
+ expectedResult: 'layer.id;layer.id2',
+ })
+ })
+ it('does not add a feature parameter when the feature ids are all empty strings', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,',
+ layers: 'layer.id;layer.id2',
+ expectedResult: 'layer.id;layer.id2',
+ })
+ })
+ it('adds a feature value for each non empty string in the features ids', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: ',,,,,,,,,,,,,,,,,,,,,1,,,,,,,,,,,,,,,,,,,,,,,,34',
+ layers: 'layer.id;layer.id2',
+ expectedResult: 'layer.id@features=1:34;layer.id2',
+ })
+ })
+ it('preserve an existing feature parameter when there are no features Ids to add', () => {
+ testLayersStringCreation({
+ layerId: 'layer.id',
+ featuresId: ',,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,',
+ layers: 'layer.id@features=12:14;layer.id2',
+ expectedResult: 'layer.id@features=12:14;layer.id2',
+ })
+ })
+ })
+ })
+})
diff --git a/packages/api/src/utils/__tests__/profileUtils.spec.ts b/packages/api/src/utils/__tests__/profileUtils.spec.ts
new file mode 100644
index 0000000000..f605ef7cad
--- /dev/null
+++ b/packages/api/src/utils/__tests__/profileUtils.spec.ts
@@ -0,0 +1,125 @@
+import type { CoordinatesChunk, SingleCoordinate } from '@swissgeo/coordinates'
+
+import { describe, expect, it } from 'vitest'
+
+import type { ElevationProfileMetadata, ElevationProfilePoint } from '@/types/profile'
+
+import profileUtils from '@/utils/profileUtils'
+
+const testPoints: ElevationProfilePoint[] = [
+ { coordinate: [0, 0], dist: 0, elevation: 100, hasElevationData: true },
+ { coordinate: [0, 50], dist: 50, elevation: 210, hasElevationData: true },
+ { coordinate: [0, 150], dist: 150, elevation: 90, hasElevationData: true },
+ { coordinate: [50, 150], dist: 200, elevation: 200, hasElevationData: true },
+]
+
+describe('Profile calculation => getProfileMetadata(points)', () => {
+ it('returns 0 for all multi-points calculation if there is less than two points', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata([
+ { coordinate: [1, 1], dist: 1, hasElevationData: false },
+ ])
+ expect(result.hasElevationData).to.be.false
+ expect(result.hasDistanceData).to.be.true
+ expect(result.totalLinearDist).to.eq(1) // dist is a unique point calculation, it should be there
+ expect(result.elevationDifference).to.eq(0)
+ expect(result.totalAscent).to.eq(0)
+ expect(result.totalDescent).to.eq(0)
+ expect(result.hikingTime).to.eq(0)
+ })
+ it('tells it has elevation data if the only parts of the points contains elevation', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata([
+ { coordinate: [1, 1], dist: 0, hasElevationData: false },
+ { coordinate: [2, 1], dist: 1, elevation: 1, hasElevationData: true },
+ ])
+ expect(result.hasElevationData).to.be.true
+ expect(result.hasDistanceData).to.be.true
+ })
+ it('gives the correct max/min distance and elevations', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata(testPoints)
+ expect(result.totalLinearDist).to.eq(200)
+ expect(result.maxElevation).to.eq(210)
+ expect(result.minElevation).to.eq(90)
+ })
+ it('calculates elevation difference correctly', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata(testPoints)
+ // comparing start and finish, so 200 - 100
+ expect(result.elevationDifference).to.eq(100)
+ })
+ it('calculates total ascent correctly', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata(testPoints)
+ // from point 1 to 2 : 110
+ // from point 3 to 4 : 110
+ // total: 220
+ expect(result.totalAscent).to.eq(220)
+ })
+ it('calculates total descent correctly', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata(testPoints)
+ // from 210 to 90, so -120, but it's in absolute form
+ expect(result.totalDescent).to.eq(120)
+ })
+ it('calculates slope distance correctly', () => {
+ const result: ElevationProfileMetadata = profileUtils.getProfileMetadata(testPoints)
+ // here we calculate with Pythagoras between each point
+ // so that we take into account the difference of altitude/elevation
+ // between 1 and 2 : 50m of distance and 110m of elevation, so sqrt(50^2 + 110^2) ~= 120.83m
+ // between 2 and 3 : 100m of distance and -120m of elevation, so sqrt(100^2 + -120^2) ~= 156.20m
+ // between 3 and 4 : 50m of distance and 110m of elevation, so sqrt(50^2 + 110^2) ~= 120.83m
+ // total : 397.86m
+ expect(result.slopeDistance).to.approximately(397.86, 0.01)
+ })
+})
+describe('formatMinutesTime()', () => {
+ it('format time', () => {
+ expect(profileUtils.formatMinutesTime()).to.equal('-')
+ expect(profileUtils.formatMinutesTime(42)).to.equal('42min')
+ expect(profileUtils.formatMinutesTime(1200)).to.equal('20h')
+ expect(profileUtils.formatMinutesTime(1230)).to.equal('20h 30min')
+ expect(profileUtils.formatMinutesTime(1202)).to.equal('20h 2min')
+ })
+})
+
+describe('splitIfTooManyPoints', () => {
+ /**
+ * @param pointsCount Number of points to generate
+ * @returns A CoordinatesChunk with the specified number of points
+ */
+ function generateChunkWith(pointsCount: number): CoordinatesChunk {
+ const coordinates: SingleCoordinate[] = []
+ for (let i = 0; i < pointsCount; i++) {
+ coordinates.push([0, i])
+ }
+ return {
+ coordinates,
+ isWithinBounds: true,
+ }
+ }
+
+ it('does not split a segment that does not contain more point than the limit', () => {
+ const result = profileUtils.splitIfTooManyPoints(generateChunkWith(3000))
+ expect(result).to.be.an('Array').lengthOf(1)
+ expect(result).to.not.be.undefined
+ if (result && result.length > 0) {
+ expect(result[0].coordinates).to.be.an('Array').lengthOf(3000)
+ }
+ })
+ it('splits if one coordinates above the limit', () => {
+ const result = profileUtils.splitIfTooManyPoints(generateChunkWith(3001))
+ expect(result).to.be.an('Array').lengthOf(2)
+ expect(result).to.not.be.undefined
+ if (result && result.length > 1) {
+ expect(result[0].coordinates).to.be.an('Array').lengthOf(3000)
+ expect(result[1].coordinates).to.be.an('Array').lengthOf(1)
+ }
+ })
+ it('creates as many sub-chunks as necessary', () => {
+ const result = profileUtils.splitIfTooManyPoints(generateChunkWith(3000 * 4 + 123))
+ expect(result).to.be.an('Array').lengthOf(5)
+ expect(result).to.not.be.undefined
+ if (result && result.length === 5) {
+ for (let i = 0; i < 4; i++) {
+ expect(result[i].coordinates).to.be.an('Array').lengthOf(3000)
+ }
+ expect(result[4].coordinates).to.be.an('Array').lengthOf(123)
+ }
+ })
+})
diff --git a/packages/viewer/src/utils/__tests__/kml_feature_error.kml b/packages/api/src/utils/__tests__/samples/kml_feature_error.kml
similarity index 100%
rename from packages/viewer/src/utils/__tests__/kml_feature_error.kml
rename to packages/api/src/utils/__tests__/samples/kml_feature_error.kml
diff --git a/packages/viewer/src/utils/__tests__/mfgeoadmin3TestKml.kml b/packages/api/src/utils/__tests__/samples/mfgeoadmin3TestKml.kml
similarity index 100%
rename from packages/viewer/src/utils/__tests__/mfgeoadmin3TestKml.kml
rename to packages/api/src/utils/__tests__/samples/mfgeoadmin3TestKml.kml
diff --git a/packages/viewer/src/utils/__tests__/webmapviewerOffsetTestKml.kml b/packages/api/src/utils/__tests__/samples/webmapviewerOffsetTestKml.kml
similarity index 100%
rename from packages/viewer/src/utils/__tests__/webmapviewerOffsetTestKml.kml
rename to packages/api/src/utils/__tests__/samples/webmapviewerOffsetTestKml.kml
diff --git a/packages/api/src/utils/featureStyleUtils.ts b/packages/api/src/utils/featureStyleUtils.ts
new file mode 100644
index 0000000000..fef6dc1584
--- /dev/null
+++ b/packages/api/src/utils/featureStyleUtils.ts
@@ -0,0 +1,479 @@
+import type { Color } from 'ol/color'
+import type { ColorLike, PatternDescriptor } from 'ol/colorlike'
+import type { default as Feature, FeatureLike } from 'ol/Feature'
+import type { Size } from 'ol/size'
+
+import { DEFAULT_ICON_SIZE, DEFAULT_TITLE_OFFSET } from '@swissgeo/staging-config/constants'
+import { styleUtils } from '@swissgeo/theme'
+import { fromString } from 'ol/color'
+import { Fill, Stroke, Text } from 'ol/style'
+import Icon from 'ol/style/Icon'
+import Style from 'ol/style/Style'
+
+import type {
+ EditableFeature,
+ FeatureStyleColor,
+ FeatureStyleSize,
+ TextPlacement,
+} from '@/types/features'
+
+import iconsAPI from '@/icons'
+
+/**
+ * @returns CSS string describing the text shadow that must be applied when coloring a text with
+ * this color
+ */
+function generateTextShadow(style: FeatureStyleColor): string {
+ return `-1px -1px 0 ${style.border}, 1px -1px 0 ${style.border}, -1px 1px 0 ${style.border},1px 1px 0 ${style.border}`
+}
+
+function generateRGBFillString(style: FeatureStyleColor): string {
+ const rgb = fromString(style.fill)
+ return `${rgb[0]},${rgb[1]},${rgb[2]}`
+}
+
+const BLACK: FeatureStyleColor = { name: 'black', fill: '#000000', border: '#ffffff' }
+const BLUE: FeatureStyleColor = { name: 'blue', fill: '#0000ff', border: '#ffffff' }
+const GRAY: FeatureStyleColor = { name: 'gray', fill: '#808080', border: '#ffffff' }
+const GREEN: FeatureStyleColor = { name: 'green', fill: '#008000', border: '#ffffff' }
+const ORANGE: FeatureStyleColor = { name: 'orange', fill: '#ffa500', border: '#000000' }
+const RED: FeatureStyleColor = { name: 'red', fill: '#ff0000', border: '#ffffff' }
+const WHITE: FeatureStyleColor = { name: 'white', fill: '#ffffff', border: '#000000' }
+const YELLOW: FeatureStyleColor = { name: 'yellow', fill: '#ffff00', border: '#000000' }
+
+const allStylingColors: FeatureStyleColor[] = [BLACK, BLUE, GRAY, GREEN, ORANGE, RED, WHITE, YELLOW]
+const FEATURE_FONT_SIZE = 16
+const FEATURE_FONT_SIZE_SMALL = 14
+const FEATURE_FONT = 'Helvetica'
+
+function generateFontString(size: FeatureStyleSize, font = FEATURE_FONT) {
+ return `normal ${FEATURE_FONT_SIZE * size.textScale}px ${font}`
+}
+/**
+ * NOTE: Here below the icons scale is the one used by openlayer, not the final scale put in the KML
+ * file. In the kml the scale will be set with a factor icon_size/32 => 48/32 => 1.5. The text scale
+ * is unchanged and the scale in openlayer match the KML scale.
+ */
+const SMALL: FeatureStyleSize = { label: 'small_size', textScale: 1, iconScale: 0.5 }
+const MEDIUM: FeatureStyleSize = { label: 'medium_size', textScale: 1.5, iconScale: 0.75 }
+const LARGE: FeatureStyleSize = { label: 'large_size', textScale: 2.0, iconScale: 1 }
+const EXTRA_LARGE: FeatureStyleSize = {
+ label: 'extra_large_size',
+ textScale: 2.5,
+ iconScale: 1.25,
+}
+
+/** List of all available sizes for drawing style */
+const allStylingSizes: FeatureStyleSize[] = [SMALL, MEDIUM, LARGE, EXTRA_LARGE]
+
+/** Get Feature style from feature */
+export function getStyle(olFeature: Feature, resolution: number): Style | undefined {
+ const styleFunction = olFeature.getStyleFunction()
+ if (!styleFunction) {
+ return
+ }
+ const styles = styleFunction(olFeature, resolution)
+ if (Array.isArray(styles)) {
+ return styles[0]
+ } else if (styles instanceof Style) {
+ return styles
+ }
+ return
+}
+
+/**
+ * Return an instance of this class matching the requested fill color
+ *
+ * Default to RED if the color code is not found or invalid !
+ *
+ * @param fillColor Rgb array of the requested fill color
+ * @returns The feature style color
+ */
+function getFeatureStyleColor(
+ fillColor: Color | ColorLike | PatternDescriptor | null
+): FeatureStyleColor {
+ if (!Array.isArray(fillColor)) {
+ return RED
+ }
+ const fill =
+ '#' +
+ fillColor
+ .slice(0, 3)
+ .map((color) => ('0' + color.toString(16)).slice(-2))
+ .reduce((prev, current) => prev + current)
+ return allStylingColors.find((color) => color.fill === fill) ?? RED
+}
+
+/**
+ * Return an instance of FeatureStyleSize matching the requested text scale
+ *
+ * @param textScale The requested text scale
+ * @returns Text size or undefined if not found
+ */
+function getTextSize(textScale?: number): FeatureStyleSize | undefined {
+ if (textScale) {
+ return allStylingSizes.find((size) => size.textScale === textScale) ?? MEDIUM
+ }
+ return
+}
+
+/**
+ * Get KML text color from style
+ *
+ * When a text is present but no color is given, then default to RED.
+ *
+ * @param {Style} style Feature style
+ * @returns {FeatureStyleColor | null} Returns the feature style color object or null if text is not
+ * found
+ */
+function getTextColor(style: Style): FeatureStyleColor | undefined {
+ const styleColor = style?.getText()?.getFill()?.getColor()
+ if (Array.isArray(styleColor)) {
+ return getFeatureStyleColor(styleColor)
+ }
+ return
+}
+
+/**
+ * Calculate text alignment from style parameters *
+ *
+ * @param textScale
+ * @param iconScale
+ * @param anchor Relative position of the anchor
+ * @param iconSize Absolute size of the icon in pixel
+ * @param textPlacement Absolute position of the text in pixel
+ * @param text
+ * @returns The feature label offset
+ */
+function calculateTextOffset(
+ textScale: number,
+ iconScale: number,
+ anchor: [number, number],
+ iconSize: [number, number],
+ textPlacement: TextPlacement,
+ text: string
+): [number, number] {
+ if (!iconScale) {
+ return DEFAULT_TITLE_OFFSET
+ }
+ const [textPlacementX, textPlacementY] = calculateTextXYOffset(
+ textScale,
+ iconScale,
+ anchor,
+ iconSize,
+ text
+ )
+
+ return calculateTextOffsetFromPlacement(textPlacementX, textPlacementY, textPlacement)
+}
+
+/**
+ * Calculate the text X and Y offset that can be applied to the text depending on the text position
+ *
+ * @param textScale
+ * @param iconScale
+ * @param anchor Relative position of the anchor
+ * @param iconSize Absolute size of the icon in pixel
+ * @param text Text to display
+ * @returns The default X and Y label offset in pixel
+ */
+function calculateTextXYOffset(
+ textScale: number,
+ iconScale: number | Size,
+ anchor: [number, number],
+ iconSize: [number, number],
+ text: string
+): [number, number] {
+ const fontSize = 11
+ const anchorScale: number = anchor ? anchor[1] * 2 : 1
+
+ const iconScaleXY: [number, number] = Array.isArray(iconScale)
+ ? [iconScale[0], iconScale[1]]
+ : [iconScale, iconScale]
+
+ const iconOffset: [number, number] = [
+ 0.5 * iconScaleXY[0] * anchorScale * iconSize[0],
+ 0.5 * iconScaleXY[1] * anchorScale * iconSize[1],
+ ]
+
+ const textOffset = 0.5 * fontSize * textScale
+ const textWidth = calculateFeatureTextWidth(text, textScale)
+ const defaultOffset = 5
+
+ return [
+ defaultOffset + iconOffset[0] + textOffset + textWidth / 2, // / 2 because the text is centered so the textWidth has to be halved
+ defaultOffset + iconOffset[1] + textOffset,
+ ]
+}
+
+/**
+ * Calculate the text offset from the text placement and the default offset
+ *
+ * @param defaultXOffset
+ * @param defaultYOffset
+ * @param placement
+ * @returns The default X and Y label offset in pixel
+ */
+function calculateTextOffsetFromPlacement(
+ defaultXOffset: number,
+ defaultYOffset: number,
+ placement: TextPlacement
+): [number, number] {
+ if (placement === 'top-left') {
+ return [-defaultXOffset, -defaultYOffset]
+ } else if (placement === 'top') {
+ return [0, -defaultYOffset]
+ } else if (placement === 'top-right') {
+ return [defaultXOffset, -defaultYOffset]
+ } else if (placement === 'left') {
+ return [-defaultXOffset, 0]
+ } else if (placement === 'center') {
+ return [0, 0]
+ } else if (placement === 'right') {
+ return [defaultXOffset, 0]
+ } else if (placement === 'bottom-left') {
+ return [-defaultXOffset, defaultYOffset]
+ } else if (placement === 'bottom') {
+ return [0, defaultYOffset]
+ } else if (placement === 'bottom-right') {
+ return [defaultXOffset, defaultYOffset]
+ }
+ return [0, 0]
+}
+
+/** Calculates the width of a feature text given a text and a text scale */
+function calculateFeatureTextWidth(text: string, textScale: number): number {
+ const canvas = document.createElement('canvas')
+ const context = canvas.getContext('2d')
+ // In unit tests the context is not available
+ if (!context) {
+ return 0
+ }
+ context.font = `normal ${FEATURE_FONT_SIZE * textScale}px ${FEATURE_FONT}`
+ return context.measureText(text).width
+}
+
+/**
+ * Returns offset (compared to marker) for text around it, depending on the text position and if the
+ * description should be shown on the map too.
+ */
+function getElementOffsets(editableFeature?: EditableFeature): {
+ top: [number, number]
+ bottom: [number, number]
+} {
+ if (!editableFeature) {
+ return {
+ top: [0, 0],
+ bottom: [0, 0],
+ }
+ }
+
+ const offsetTopElement: [number, number] = [...editableFeature.textOffset] as [number, number]
+ const offsetBottomElement: [number, number] = [...editableFeature.textOffset] as [
+ number,
+ number,
+ ]
+
+ if (editableFeature.showDescriptionOnMap && editableFeature.description) {
+ const isTextAtBottom =
+ editableFeature.textPlacement === 'bottom' ||
+ editableFeature.textPlacement === 'bottom-left' ||
+ editableFeature.textPlacement === 'bottom-right'
+ const isTextAtCenter =
+ editableFeature.textPlacement === 'center' ||
+ editableFeature.textPlacement === 'left' ||
+ editableFeature.textPlacement === 'right'
+
+ const descriptionLineWrapCount = editableFeature.description.split('\n').length ?? 0
+ const descriptionBlocHeight = descriptionLineWrapCount * FEATURE_FONT_SIZE_SMALL
+ const extraOffsetTopElement = (descriptionLineWrapCount + 1) * FEATURE_FONT_SIZE_SMALL
+ const extraOffsetBottomElement =
+ descriptionLineWrapCount > 1
+ ? ((descriptionLineWrapCount - 1) * FEATURE_FONT_SIZE_SMALL) / 2.0
+ : 0
+
+ offsetTopElement[1] = offsetTopElement[1] - extraOffsetTopElement
+ offsetBottomElement[1] = offsetBottomElement[1] - extraOffsetBottomElement
+
+ if (isTextAtCenter) {
+ // adding half the height of the description to all offsetY, to better center the elements vertically
+ offsetBottomElement[1] = offsetBottomElement[1] + descriptionBlocHeight / 2.0
+ offsetTopElement[1] = offsetTopElement[1] + descriptionBlocHeight / 2.0
+ }
+ if (isTextAtBottom) {
+ // adding the full height of the description to both elements
+ offsetBottomElement[1] = offsetBottomElement[1] + descriptionBlocHeight
+ offsetTopElement[1] = offsetTopElement[1] + descriptionBlocHeight
+ }
+ }
+ return {
+ top: offsetTopElement,
+ bottom: offsetBottomElement,
+ }
+}
+
+/**
+ * Style function that renders a feature with the distinct Geoadmin style. Meaning, by default, all
+ * red.
+ *
+ * If an editableFeature is found attached to the feature, its properties will be used to set
+ * color/text and such things.
+ *
+ * To style a selected feature, within the drawing module context, please use
+ * {@link editingFeatureStyleFunction}
+ *
+ * @param feature OpenLayers feature to style
+ * @param resolution The resolution of the map in map units / pixel (which is equatorial meters /
+ * pixel for the webmercator projection used in this project)
+ */
+function geoadminStyleFunction(
+ feature: FeatureLike,
+ resolution?: number
+): Style | Style[] | undefined {
+ const editableFeature = feature.get('editableFeature') as EditableFeature | undefined
+
+ const styleConfig = {
+ fillColor: editableFeature?.fillColor ?? RED,
+ strokeColor: editableFeature?.strokeColor ?? RED,
+ textColor: editableFeature?.textColor ?? RED,
+ }
+
+ // Tells if we are drawing a polygon for the first time, in this case we want
+ // to fill this polygon with a transparent white (instead of red)
+ const isDrawing = !!feature.get('isDrawing')
+
+ const { top: offsetTopElement, bottom: offsetBottomElement } =
+ getElementOffsets(editableFeature)
+ let image: Icon | undefined
+ if (editableFeature?.icon) {
+ image = new Icon({
+ src: iconsAPI.generateIconURL(editableFeature.icon, editableFeature.fillColor?.fill),
+ crossOrigin: 'Anonymous',
+ anchor: editableFeature.icon.anchor,
+ scale: editableFeature.iconSize?.iconScale,
+ })
+ }
+ const styles = [
+ new Style({
+ geometry: feature.get('geodesic')?.getGeodesicGeom() ?? feature.getGeometry(),
+ image,
+ text: new Text({
+ text: editableFeature?.title ?? feature.get('name'),
+ font: `normal ${FEATURE_FONT_SIZE}px ${FEATURE_FONT}`,
+ fill: new Fill({
+ color: styleConfig.textColor.fill,
+ }),
+ stroke: new Stroke({
+ color: styleConfig.textColor.border,
+ width: 3,
+ }),
+ scale: editableFeature?.textSize?.textScale ?? 1,
+ // only applying the text offset if the feature is a marker (has an icon)
+ offsetX: editableFeature?.icon ? offsetTopElement[0] : undefined,
+ offsetY: editableFeature?.icon ? offsetTopElement[1] : undefined,
+ }),
+ stroke:
+ editableFeature?.featureType === 'MEASURE'
+ ? styleUtils.dashedRedStroke
+ : new Stroke({
+ color: styleConfig.fillColor.fill,
+ width: 3,
+ }),
+ // filling a polygon with white if first time being drawn (otherwise fallback to user set color)
+ fill: isDrawing
+ ? styleUtils.whiteSketchFill
+ : new Fill({
+ color: [...fromString(styleConfig.fillColor.fill).slice(0, 3), 0.4],
+ }),
+ zIndex: styleUtils.StyleZIndex.MainStyle,
+ }),
+ ]
+ if (editableFeature?.showDescriptionOnMap && editableFeature?.description) {
+ styles.push(
+ new Style({
+ text: new Text({
+ text: editableFeature.description,
+ font: `normal ${FEATURE_FONT_SIZE_SMALL}px ${FEATURE_FONT}`,
+ fill: new Fill({
+ color: styleConfig.textColor.fill,
+ }),
+ stroke: new Stroke({
+ color: styleConfig.textColor.border,
+ width: 2,
+ }),
+ offsetX: offsetBottomElement[0],
+ offsetY: offsetBottomElement[1],
+ }),
+ })
+ )
+ }
+ const polygonGeom = feature.get('geodesic')?.getGeodesicPolygonGeom()
+ if (polygonGeom) {
+ styles.push(
+ new Style({
+ geometry: polygonGeom,
+ fill: isDrawing
+ ? styleUtils.whiteSketchFill
+ : new Fill({
+ color: [...fromString(styleConfig.fillColor.fill).slice(0, 3), 0.4],
+ }),
+ zIndex: styleUtils.StyleZIndex.AzimuthCircle,
+ stroke: new Stroke({
+ color: styleConfig.strokeColor.fill,
+ width: 3,
+ }),
+ })
+ )
+ }
+ /* This function is also called when saving the feature to KML, where "feature.get('geodesic')"
+ is not there anymore, thats why we have to check for it here */
+ if (resolution && editableFeature?.featureType === 'MEASURE' && feature.get('geodesic')) {
+ styles.push(...feature.get('geodesic').getMeasureStyles(resolution))
+ }
+ return styles
+}
+
+/** Default offset of title for the default marker */
+const DEFAULT_MARKER_TITLE_OFFSET = calculateTextOffset(
+ MEDIUM.textScale,
+ MEDIUM.iconScale,
+ [0, 0.875],
+ DEFAULT_ICON_SIZE,
+ 'top',
+ ''
+)
+
+export const featureStyleUtils = {
+ generateTextShadow,
+ generateRGBFillString,
+ BLACK,
+ BLUE,
+ GRAY,
+ GREEN,
+ ORANGE,
+ RED,
+ WHITE,
+ YELLOW,
+ allStylingColors,
+ FEATURE_FONT_SIZE,
+ FEATURE_FONT_SIZE_SMALL,
+ FEATURE_FONT,
+ generateFontString,
+ SMALL,
+ MEDIUM,
+ LARGE,
+ EXTRA_LARGE,
+ allStylingSizes,
+ getStyle,
+ getFeatureStyleColor,
+ getTextSize,
+ getTextColor,
+ calculateTextOffset,
+ calculateTextXYOffset,
+ calculateTextOffsetFromPlacement,
+ calculateFeatureTextWidth,
+ geoadminStyleFunction,
+ DEFAULT_MARKER_TITLE_OFFSET,
+}
+
+export default featureStyleUtils
diff --git a/packages/api/src/utils/gpxUtils.ts b/packages/api/src/utils/gpxUtils.ts
new file mode 100644
index 0000000000..42b64fea2a
--- /dev/null
+++ b/packages/api/src/utils/gpxUtils.ts
@@ -0,0 +1,76 @@
+import type { FlatExtent, CoordinateSystem } from '@swissgeo/coordinates'
+import type { Feature } from 'ol'
+import type { Geometry } from 'ol/geom'
+
+import { WGS84, registerProj4 } from '@swissgeo/coordinates'
+import log from '@swissgeo/log'
+import { styleUtils } from '@swissgeo/theme'
+import { gpx as gpxToGeoJSON } from '@tmcw/togeojson'
+import { bbox } from '@turf/turf'
+import { isEmpty as isExtentEmpty } from 'ol/extent'
+import GPX from 'ol/format/GPX'
+import { register } from 'ol/proj/proj4'
+import proj4 from 'proj4'
+
+/**
+ * Parse the GPX extent from the GPX tracks or features
+ *
+ * Will return null if the extent is not parsable.
+ *
+ * @param content GPX content as a string
+ */
+function getGpxExtent(content: string): FlatExtent | undefined {
+ const parseGpx = new DOMParser().parseFromString(content, 'text/xml')
+ const extent = bbox(gpxToGeoJSON(parseGpx))
+ if (isExtentEmpty(extent)) {
+ return
+ }
+ return extent as FlatExtent
+}
+
+/**
+ * Parses a GPX's data into OL Features, including deserialization of features
+ *
+ * @param gpxData KML content to parse
+ * @param projection Projection to use for the OL Feature
+ * @returns List of OL Features, or null of the gpxData or projection is invalid/empty
+ */
+function parseGpx(gpxData: string, projection: CoordinateSystem): Feature[] | undefined {
+ log.debug({
+ title: 'GPX Utils',
+ messages: ['Parsing GPX data with projection', projection.epsg],
+ })
+ // Register projections with proj4 only if they're not already defined
+ const projectionDefined = proj4.defs(projection.epsg)
+ const wgs84Defined = proj4.defs(WGS84.epsg)
+
+ if (!projectionDefined || !wgs84Defined) {
+ // Register all Swiss projections (LV95, LV03, WebMercator) with proj4
+ registerProj4(proj4)
+ }
+ register(proj4)
+ // currently points which contain a timestamp are displayed with an offset due to a bug
+ // therefore they are removed here as they are not needed for displaying (see PB-785)
+ gpxData = gpxData.replace(/