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) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/api) | API utilities to interact with SWISSGEO's backend services | +| [`@swissgeo/coordinates`](https://www.npmjs.com/package/@swissgeo/coordinates) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/coordinates) | Projection definition and coordinates utils for SWISSGEO projects | +| [`@swissgeo/drawing`](https://www.npmjs.com/package/@swissgeo/drawing) | ![npm (scoped)](https://img.shields.io/npm/v/@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) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/elevation-profile) | Components to request and display an elevation profile over Switzerland | +| [`@swissgeo/layers`](https://www.npmjs.com/package/@swissgeo/layers) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/layers) | Layers definition for SwissGeo | +| [`@swissgeo/log`](https://www.npmjs.com/package/@swissgeo/log) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/log) | Logging utils for SWISSGEO projects | +| [`@swissgeo/numbers`](https://www.npmjs.com/package/@swissgeo/numbers) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/numbers) | Numbers utils for SWISSGEO projects | +| [`@swissgeo/theme`](https://www.npmjs.com/package/@swissgeo/theme) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/theme) | Shared SCSS variable and theme utilities | +| [`@swissgeo/tooltip`](https://www.npmjs.com/package/@swissgeo/tooltip) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/tooltip) | Tooltip for geoadmin | + +### Configuration Packages + +| Package | Version | Description | +| ------- | ------- | ----------- | +| [`@swissgeo/config-eslint`](https://www.npmjs.com/package/@swissgeo/config-eslint) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/config-eslint) | Shared ESLint config for SWISSGEO projects | +| [`@swissgeo/config-prettier`](https://www.npmjs.com/package/@swissgeo/config-prettier) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/config-prettier) | Shared Prettier config for SWISSGEO projects | +| [`@swissgeo/config-stylelint`](https://www.npmjs.com/package/@swissgeo/config-stylelint) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/config-stylelint) | Shared Stylelint config for SWISSGEO projects | +| [`@swissgeo/config-typescript`](https://www.npmjs.com/package/@swissgeo/config-typescript) | ![npm (scoped)](https://img.shields.io/npm/v/@swissgeo/config-typescript) | TypeScript configuration for SWISSGEO projects | +| [`@swissgeo/staging-config`](https://www.npmjs.com/package/@swissgeo/staging-config) | ![npm (scoped)](https://img.shields.io/npm/v/@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(/