diff --git a/.prettierignore b/.prettierignore index fa5f33999b..bb2a06918f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,8 +3,7 @@ pnpm-lock.yaml node_modules/ **/node_modules -packages/**/node_modules/ -packages/mapviewer/index.html -packages/mapviewer/public/icon.svg -packages/mapviewer/src/assets/svg/swiss-flag.svg +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 74e633a451..bf628614ac 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/mapviewer/src/store/README.md) for more information. +See the [store read-me](packages/viewer/src/store/README.md) for more information. ### Best practices @@ -117,7 +117,7 @@ See the [store read-me](packages/mapviewer/src/store/README.md) for more informa - 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/mapviewer/src/store/README.md#best-practices) +See also [Store Best Practices](packages/viewer/src/store/README.md#best-practices) ### Vue Composition API diff --git a/README.md b/README.md index 0e3362993a..39cb837d16 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) ## Check External Layer Provider list -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 +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 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/mapviewer/scripts/check-external-layers-providers.js +./packages/viewer/scripts/check-external-layers-providers.js ``` You can use `-h` option to get more detail on the script. diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 1ea1ac8e76..0000000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import js from '@eslint/js' -import markdown from '@eslint/markdown' -import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' -import { - configureVueProject, - defineConfigWithVueTs, - vueTsConfigs, -} from '@vue/eslint-config-typescript' -import pluginCypress from 'eslint-plugin-cypress/flat' -import mocha from 'eslint-plugin-mocha' -import perfectionist from 'eslint-plugin-perfectionist' -import pluginVue from 'eslint-plugin-vue' -import globals from 'globals' - -configureVueProject({ - scriptLangs: ['ts', 'js'], -}) - -export default defineConfigWithVueTs( - js.configs.recommended, - ...pluginVue.configs['flat/recommended'], - pluginCypress.configs.recommended, - vueTsConfigs.recommendedTypeCheckedOnly, - { - ignores: ['.gitignore', '**/node_modules', '**/.github', '**/dist', '**/*.md'], - }, - { - plugins: { - mocha, - perfectionist, - }, - - languageOptions: { - ecmaVersion: 'latest', - - globals: { - ...globals.browser, - ...globals.vitest, - ...globals.node, - defineModel: 'readonly', - __APP_VERSION__: true, - __VITE_ENVIRONMENT__: true, - __CESIUM_STATIC_PATH__: true, - __IS_TESTING_WITH_CYPRESS__: true, - }, - - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - - sourceType: 'module', - }, - - rules: { - eqeqeq: ['error', 'always'], - 'mocha/no-exclusive-tests': 'error', - 'no-console': 'error', - 'no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - destructuredArrayIgnorePattern: '^_', - }, - ], - 'no-var': 'error', - 'perfectionist/sort-imports': [ - 'error', - { type: 'alphabetical', internalPattern: ['^@/.*'] }, - ], - 'vue/html-indent': ['error', 4], - }, - }, - { - files: ['**/*.ts', '**/*.tsx'], - // switching to TypeScript unused var rule (instead of JS rule), so that no error is raised - // on unused param from abstract function arguments - rules: { - 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'error', - }, - }, - { - files: ['tests/**/*.{js,ts,jsx,tsx}', 'src/**/__test__/**/*.spec.js', 'scripts/**'], - rules: { - 'no-console': 'off', - 'no-prototype-builtins': 'off', - }, - }, - { - files: ['**/*.md'], - ignores: ['!**/*.md', '**/LICENSE.md'], - plugins: { - markdown: markdown, - }, - processor: 'markdown/markdown', - rules: { - 'no-irregular-whitespace': 'off', - }, - }, - // skip the formatting in the linting process - skipFormatting -) diff --git a/package.json b/package.json index bb015b3de3..3cfe35b471 100644 --- a/package.json +++ b/package.json @@ -1,79 +1,47 @@ { "name": "web-mapviewer-monorepo", "private": true, + "type": "module", "scripts": { - "build": "pnpm run --recursive build", - "build-libs": "pnpm --filter=!web-mapviewer run --recursive build", - "build:dev": "pnpm --recursive run build:dev", + "build": "pnpm run --recursive --if-present build", + "build-libs": "pnpm --filter=!web-mapviewer run --recursive --if-present build", + "build:dev": "pnpm --recursive --if-present run build:dev", "build:dev:watch": "pnpm --filter=!web-mapviewer --recursive --parallel --if-present run build:dev:watch", - "build:int": "pnpm --recursive run build:int", - "build:prod": "pnpm --recursive run build:prod", + "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 ", "format": "prettier --write .", "format:check": "prettier --check .", - "lint": "eslint --fix || stylelint \"**/*.{vue,scss}\" --fix", - "lint:no-fix": "eslint && stylelint \"**/*.{vue,scss}\"", - "lint:styles": "stylelint \"**/*.{vue,scss}\" --fix", - "lint:styles:no-fix": "stylelint \"**/*.{vue,scss}\"", + "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:test": "pnpm run build-libs && pnpm --filter=web-mapviewer run preview:test", "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", "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", "test:e2e:ci": "pnpm --filter=web-mapviewer run test:e2e:ci", "test:e2e:headless": "pnpm --filter=web-mapviewer run test:e2e:headless", - "test:unit": "pnpm run --recursive --if-present test:unit", - "test:unit:watch": "pnpm run --recursive --if-present test:unit:watch" + "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" }, - "dependencies": { - "cesium": "catalog:" + "engines": { + "node": ">=22.18", + "pnpm": ">=10.15" }, "devDependencies": { - "@eslint/js": "catalog:", - "@eslint/markdown": "catalog:", - "@prettier/plugin-xml": "catalog:", - "@types/geojson": "catalog:", - "@types/jsdom": "catalog:", - "@types/node": "catalog:", - "@types/proj4": "catalog:", - "@vue/eslint-config-prettier": "catalog:", - "@vue/eslint-config-typescript": "catalog:", - "@vue/tsconfig": "catalog:", - "eslint": "catalog:", - "eslint-plugin-cypress": "catalog:", - "eslint-plugin-mocha": "catalog:", - "eslint-plugin-perfectionist": "catalog:", - "eslint-plugin-prettier": "catalog:", - "eslint-plugin-vue": "catalog:", - "globals": "catalog:", - "postcss-html": "catalog:", - "prettier": "catalog:", - "prettier-plugin-jsdoc": "catalog:", - "prettier-plugin-packagejson": "catalog:", - "prettier-plugin-tailwindcss": "catalog:", - "stylelint": "catalog:", - "stylelint-config-recommended-scss": "catalog:", - "stylelint-config-recommended-vue": "catalog:", - "stylelint-order": "catalog:", - "stylelint-scss": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:", - "vite": "catalog:", - "vite-plugin-dts": "catalog:", - "vitest": "catalog:", - "vue-tsc": "catalog:" - }, - "engines": { - "node": ">=22", - "pnpm": "10.*.*" + "yaml": "catalog:" }, "pnpm": { "onlyBuiltDependencies": [ + "@parcel/watcher", + "@tailwindcss/oxide", "cypress", - "sharp" + "sharp", + "vue-demi" ], "ignoredBuiltDependencies": [ "core-js", @@ -82,6 +50,9 @@ "esbuild", "protobufjs", "sharp" - ] + ], + "patchedDependencies": { + "vite": "patches/vite.patch" + } } } diff --git a/packages/coordinates/eslint.config.mts b/packages/coordinates/eslint.config.mts new file mode 100644 index 0000000000..7d1eb59e8d --- /dev/null +++ b/packages/coordinates/eslint.config.mts @@ -0,0 +1,12 @@ +import defaultConfig from '@swissgeo/eslint-config' + +export default [ + ...defaultConfig, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + } +] diff --git a/packages/coordinates/package.json b/packages/coordinates/package.json new file mode 100644 index 0000000000..0751d57e51 --- /dev/null +++ b/packages/coordinates/package.json @@ -0,0 +1,57 @@ +{ + "name": "@swissgeo/coordinates", + "version": "0.0.1", + "description": "Projection definition and coordinates utils for geoadmin", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.umd.cjs", + "module": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm run type-check && pnpm run generate-types && vite build", + "build:dev": "pnpm run build --mode development", + "build:dev:watch": "pnpm run build --watch --mode development", + "build:int": "pnpm run build --mode integration", + "build:prod": "pnpm run build --mode production", + "lint": "eslint --fix", + "lint:no-fix": "eslint", + "dev": "vite", + "generate-types": "tsc --declaration", + "preview": "vite preview", + "test:unit": "vitest --run --mode development --environment jsdom", + "test:unit:watch": "vitest --mode development --environment jsdom", + "type-check": "tsc -p tsconfig.json" + }, + "dependencies": { + "@swissgeo/log": "workspace:*", + "@swissgeo/numbers": "workspace:*", + "lodash": "catalog:" + }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "@swissgeo/eslint-config": "workspace:*", + "@swissgeo/typescript-config": "workspace:*", + "@turf/turf": "catalog:", + "@types/chai": "catalog:", + "@types/geojson": "catalog:", + "@types/lodash": "catalog:", + "chai": "catalog:", + "eslint": "catalog:", + "typescript": "catalog:", + "unplugin-dts": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "ol": "catalog:", + "proj4": "catalog:" + } +} diff --git a/packages/geoadmin-coordinates/setup-vitest.ts b/packages/coordinates/setup-vitest.ts similarity index 100% rename from packages/geoadmin-coordinates/setup-vitest.ts rename to packages/coordinates/setup-vitest.ts diff --git a/packages/coordinates/src/__test__/coordinatesUtils.spec.ts b/packages/coordinates/src/__test__/coordinatesUtils.spec.ts new file mode 100644 index 0000000000..54e8acaafa --- /dev/null +++ b/packages/coordinates/src/__test__/coordinatesUtils.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' + +import type { Single3DCoordinate, SingleCoordinate } from '@/coordinatesUtils' +import coordinatesUtils from '@/coordinatesUtils' +import { CoordinateSystem, LV95, WEBMERCATOR, WGS84 } from '@/proj' + +describe('Unit test for coordinatesUtils', () => { + describe('toRoundedString', () => { + it('rounds without decimal if 0 is given as digits', () => { + expect(coordinatesUtils.toRoundedString([1.49, 2.49], 0)).to.eq( + '1, 2', + 'it should floor any number lower than .5' + ) + expect(coordinatesUtils.toRoundedString([1.5, 2.5], 0)).to.eq( + '2, 3', + 'it should raise any number greater or equal to .5' + ) + }) + it('rounds with decimal if a number is given as digits', () => { + expect(coordinatesUtils.toRoundedString([1.44, 2.44], 1)).to.eq('1.4, 2.4') + expect(coordinatesUtils.toRoundedString([1.45, 2.45], 1)).to.eq('1.5, 2.5') + }) + it('correctly enforcers digits when asked for', () => { + expect(coordinatesUtils.toRoundedString([1.44, 2.44], 5, false, true)).to.eq( + '1.44000, 2.44000' + ) + expect(coordinatesUtils.toRoundedString([1, 2], 3, false, true)).to.eq('1.000, 2.000') + expect(coordinatesUtils.toRoundedString([1234.5678, 1234.5678], 6, true, true)).to.eq( + "1'234.567800, 1'234.567800" + ) + }) + }) + + describe('wrapXCoordinates()', () => { + it('can wrap a single coordinate', () => { + function testLowerWrap(projection: CoordinateSystem): void { + const bounds = projection.bounds + expect(bounds).to.be.an('Object') + expect( + coordinatesUtils.wrapXCoordinates( + [bounds!.lowerX - 1, bounds!.center[1]], + projection + ) + ).to.deep.equal([bounds!.upperX - 1, bounds!.center[1]]) + } + testLowerWrap(WGS84) + testLowerWrap(WEBMERCATOR) + + function testUpperWrap(projection: CoordinateSystem) { + const bounds = projection.bounds + expect(bounds).to.be.an('Object') + expect( + coordinatesUtils.wrapXCoordinates( + [bounds!.upperX + 1, bounds!.center[1]], + projection + ) + ).to.deep.equal([bounds!.lowerX + 1, bounds!.center[1]]) + } + testUpperWrap(WGS84) + testUpperWrap(WEBMERCATOR) + }) + it('do not wrap if projection is not global (world-wide)', () => { + const justOffBoundCoordinate: SingleCoordinate = [ + LV95.bounds.lowerX - 1, + LV95.bounds.center[1], + ] + expect(coordinatesUtils.wrapXCoordinates(justOffBoundCoordinate, LV95)).to.deep.equal( + justOffBoundCoordinate + ) + }) + it('can wrap every coordinates of an array of coordinates', () => { + function testMultipleWrap(projection: CoordinateSystem) { + const bounds = projection.bounds + expect(bounds).to.be.an('Object') + const lowOutOfBoundCoordinate: SingleCoordinate = [ + bounds!.lowerX - 1, + bounds!.center[1], + ] + const inBoundCoordinate: SingleCoordinate = [bounds!.lowerX, bounds!.center[1]] + const inBoundCoordinate2: SingleCoordinate = [bounds!.center[0], bounds!.center[1]] + const inBoundCoordinate3: SingleCoordinate = [bounds!.upperX, bounds!.center[1]] + const upOutOfBoundCoordinate: SingleCoordinate = [ + bounds!.upperX + 1, + bounds!.center[1], + ] + const original = [ + lowOutOfBoundCoordinate, + inBoundCoordinate, + inBoundCoordinate2, + inBoundCoordinate3, + upOutOfBoundCoordinate, + ] + const result = coordinatesUtils.wrapXCoordinates(original, projection) + expect(result).to.be.an('Array').lengthOf(original.length) + const [first, second, third, fourth, fifth] = result + expect(first).to.deep.equal([bounds!.upperX - 1, lowOutOfBoundCoordinate[1]]) + expect(second).to.deep.equal(inBoundCoordinate, 'wrong lowerX handling') + expect(third).to.deep.equal(inBoundCoordinate2, 'wrong center handling') + expect(fourth).to.deep.equal(inBoundCoordinate3, 'wrong upperX handling') + expect(fifth).to.deep.equal([bounds!.lowerX + 1, upOutOfBoundCoordinate[1]]) + } + testMultipleWrap(WGS84) + testMultipleWrap(WEBMERCATOR) + }) + }) + + describe('unwrapGeometryCoordinates(coordinates)', () => { + it('returns the input if nothing is required', () => { + expect(coordinatesUtils.unwrapGeometryCoordinates([])).to.be.an('Array').lengthOf(0) + const alreadyUnwrappedCoordinates: SingleCoordinate[] = [ + [1, 2], + [3, 4], + [5, 6], + ] + expect(coordinatesUtils.unwrapGeometryCoordinates(alreadyUnwrappedCoordinates)).to.eql( + alreadyUnwrappedCoordinates + ) + }) + it('unwraps when required', () => { + const expectedOutcome: SingleCoordinate[] = [ + [1, 2], + [3, 4], + [5, 6], + ] + const wrappedCoordinates = [expectedOutcome] + expect(coordinatesUtils.unwrapGeometryCoordinates(wrappedCoordinates)).to.eql( + expectedOutcome + ) + }) + }) + + describe('removeZValues', () => { + it('returns the input if an empty array is given', () => { + expect(coordinatesUtils.removeZValues([])).to.eql([]) + }) + it('returns coordinate untouched if they have no Z values', () => { + const coordinates: SingleCoordinate[] = [ + [1, 2], + [3, 4], + [5, 6], + ] + expect(coordinatesUtils.removeZValues(coordinates)).to.eql(coordinates) + }) + it('removes Z values when needed', () => { + const coordinateWithoutZValues: SingleCoordinate[] = [ + [1, 2], + [3, 4], + [5, 6], + ] + expect( + coordinatesUtils.removeZValues( + coordinateWithoutZValues.map( + (coordinate): Single3DCoordinate => [ + coordinate[0], + coordinate[1], + Math.floor(1 + 10 * Math.random()), + ] + ) + ) + ).to.eql(coordinateWithoutZValues) + // testing with only one coordinate + expect(coordinatesUtils.removeZValues([[1, 2, 3]])).to.eql([[1, 2]]) + }) + }) +}) diff --git a/packages/coordinates/src/__test__/extentUtils.spec.ts b/packages/coordinates/src/__test__/extentUtils.spec.ts new file mode 100644 index 0000000000..3ff04a36d7 --- /dev/null +++ b/packages/coordinates/src/__test__/extentUtils.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import coordinatesUtils, { type SingleCoordinate } from '@/coordinatesUtils' +import { type FlatExtent, getExtentIntersectionWithCurrentProjection } from '@/extentUtils' +import { LV95, WGS84 } from '@/proj' + +describe('Test extent utils', () => { + describe('reproject and cut extent within projection bounds', () => { + function expectExtentIs( + toBeTested: FlatExtent, + expected: FlatExtent, + acceptableDelta = 0.5 + ) { + expect(toBeTested).to.be.an('Array').lengthOf(4) + expected.forEach((value, index) => { + expect(toBeTested[index]).to.be.approximately(value, acceptableDelta) + }) + } + + it('reproject extent of a single coordinate inside the bounds of the projection', () => { + const singleCoordinate: SingleCoordinate = [8.2, 47.5] + const singleCoordinateInLV95 = coordinatesUtils.reprojectAndRound( + WGS84, + LV95, + singleCoordinate + ) + const extent = [singleCoordinate, singleCoordinate].flat() as FlatExtent + const result = getExtentIntersectionWithCurrentProjection(extent, WGS84, LV95) + expect(result).to.be.an('Array').lengthOf(4) + expectExtentIs(result!, [...singleCoordinateInLV95, ...singleCoordinateInLV95]) + }) + it('returns undefined if a single coordinate outside of bounds is given', () => { + const singleCoordinateOutOfLV95Bounds = [8.2, 40] + const extent = [ + singleCoordinateOutOfLV95Bounds, + singleCoordinateOutOfLV95Bounds, + ].flat() as FlatExtent + expect(getExtentIntersectionWithCurrentProjection(extent, WGS84, LV95)).to.be.undefined + }) + it('returns undefined if the extent given is completely outside of the projection bounds', () => { + const extent: FlatExtent = [-25.0, -20.0, -5.0, -45.0] + expect(getExtentIntersectionWithCurrentProjection(extent, WGS84, LV95)).to.be.undefined + }) + it('reproject and cut an extent that is greater than LV95 extent on all sides', () => { + const result = getExtentIntersectionWithCurrentProjection( + [-2.4, 35, 21.3, 51.7], + WGS84, + LV95 + ) + expect(result).to.be.an('Array').lengthOf(4) + expectExtentIs(result!, [...LV95.bounds.bottomLeft, ...LV95.bounds.topRight]) + }) + it('reproject and cut an extent that is partially bigger than LV95 bounds', () => { + const result = getExtentIntersectionWithCurrentProjection( + // extent of file linked to PB-1221 + [-122.08, -33.85, 151.21, 51.5], + WGS84, + LV95 + ) + expect(result).to.be.an('Array').lengthOf(4) + expectExtentIs(result!, [...LV95.bounds.bottomLeft, ...LV95.bounds.topRight]) + }) + it('only gives back the portion of an extent that is within LV95 bounds', () => { + const singleCoordinateInsideLV95: SingleCoordinate = [7.54, 48.12] + const singleCoordinateInLV95 = coordinatesUtils.reprojectAndRound( + WGS84, + LV95, + singleCoordinateInsideLV95 + ) + const overlappingExtent: FlatExtent = [0, 0, ...singleCoordinateInsideLV95] + const result = getExtentIntersectionWithCurrentProjection( + overlappingExtent, + WGS84, + LV95 + ) + expect(result).to.be.an('Array').lengthOf(4) + expectExtentIs(result!, [...LV95.bounds.bottomLeft, ...singleCoordinateInLV95]) + }) + }) +}) diff --git a/packages/geoadmin-coordinates/src/utils.ts b/packages/coordinates/src/coordinatesUtils.ts similarity index 78% rename from packages/geoadmin-coordinates/src/utils.ts rename to packages/coordinates/src/coordinatesUtils.ts index aa5818c54b..931eb6ef83 100644 --- a/packages/geoadmin-coordinates/src/utils.ts +++ b/packages/coordinates/src/coordinatesUtils.ts @@ -1,10 +1,11 @@ -import { formatThousand, isNumber, round } from '@geoadmin/numbers' +import { formatThousand, isNumber, round } from '@swissgeo/numbers' import proj4 from 'proj4' +import { allCoordinateSystems, WGS84 } from '@/proj' import CoordinateSystem from '@/proj/CoordinateSystem' export type SingleCoordinate = [number, number] -type Single3DCoordinate = [number, number, number] +export type Single3DCoordinate = [number, number, number] /** * Returns rounded coordinate with thousands separator and comma. @@ -18,12 +19,12 @@ type Single3DCoordinate = [number, number, number] * @returns Formatted coordinate. * @see https://stackoverflow.com/a/2901298/4840446 */ -export function toRoundedString( +function toRoundedString( coordinate: SingleCoordinate, digits: number, withThousandsSeparator: boolean = true, enforceDigit: boolean = false -): string | null { +): string | undefined { if ( !Array.isArray(coordinate) || coordinate.length !== 2 || @@ -32,7 +33,7 @@ export function toRoundedString( (value) => value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY ) ) { - return null + return } return coordinate .map((value) => { @@ -59,7 +60,7 @@ export function toRoundedString( * @param projection Projection of the coordinates * @returns Coordinates wrapped on the X axis */ -export function wrapXCoordinates( +function wrapXCoordinates( coordinates: T, projection: CoordinateSystem ): T { @@ -90,7 +91,7 @@ export function wrapXCoordinates coordinate.length === 2)) { return coordinates @@ -120,7 +119,7 @@ export function removeZValues( throw new Error('Invalid coordinates received, cannot remove Z values') } -export function reprojectAndRound( +function reprojectAndRound( from: CoordinateSystem, into: CoordinateSystem, coordinates: SingleCoordinate @@ -133,12 +132,34 @@ export function reprojectAndRound( ) as SingleCoordinate } -const coordinates = { +function parseCRS(crs?: string): CoordinateSystem | undefined { + const epsgNumber = crs?.split(':').pop() + if (!epsgNumber) { + return + } + + if (epsgNumber === 'WGS84') { + return WGS84 + } + return allCoordinateSystems.find((system) => system.epsg === `EPSG:${epsgNumber}`) +} + +export interface GeoadminCoordinatesUtils { + toRoundedString: typeof toRoundedString + wrapXCoordinates: typeof wrapXCoordinates + unwrapGeometryCoordinates: typeof unwrapGeometryCoordinates + removeZValues: typeof removeZValues + reprojectAndRound: typeof reprojectAndRound + parseCRS: typeof parseCRS +} + +const coordinatesUtils: GeoadminCoordinatesUtils = { toRoundedString, wrapXCoordinates, unwrapGeometryCoordinates, removeZValues, reprojectAndRound, + parseCRS, } -export { coordinates } -export default coordinates +export { coordinatesUtils } +export default coordinatesUtils diff --git a/packages/coordinates/src/extentUtils.ts b/packages/coordinates/src/extentUtils.ts new file mode 100644 index 0000000000..64a69050e8 --- /dev/null +++ b/packages/coordinates/src/extentUtils.ts @@ -0,0 +1,192 @@ +import { round } from '@swissgeo/numbers' +import { bbox, buffer, point } from '@turf/turf' +import { type Extent, getIntersection as getExtentIntersection } from 'ol/extent' +import proj4 from 'proj4' + +import type { SingleCoordinate } from '@/coordinatesUtils' + +import { CoordinateSystem, WGS84 } from '@/proj' + +export type FlatExtent = [number, number, number, number] +export type NormalizedExtent = [[number, number], [number, number]] + +/** + * @param fromProj Current projection used to describe the extent + * @param toProj Target projection we want the extent be expressed in + * @param extent An extent, described as `[minx, miny, maxx, maxy].` or `[[minx, miny], [maxx, + * maxy]]` + * @returns The reprojected extent + */ +export function projExtent( + fromProj: CoordinateSystem, + toProj: CoordinateSystem, + extent: T +): T { + if (extent.length === 4) { + const bottomLeft = proj4(fromProj.epsg, toProj.epsg, [ + extent[0], + extent[1], + ]) as SingleCoordinate + const topRight = proj4(fromProj.epsg, toProj.epsg, [ + extent[2], + extent[3], + ]) as SingleCoordinate + return [...bottomLeft, ...topRight].map((value) => toProj.roundCoordinateValue(value)) as T + } else if (extent.length === 2) { + const bottomLeft = proj4(fromProj.epsg, toProj.epsg, extent[0]).map((value) => + toProj.roundCoordinateValue(value) + ) + const topRight = proj4(fromProj.epsg, toProj.epsg, extent[1]).map((value) => + toProj.roundCoordinateValue(value) + ) + return [bottomLeft, topRight] as T + } + return extent +} + +/** + * Return an extent normalized to [[x, y], [x, y]] from a flat extent + * + * @param extent Extent to normalize + * @returns Extent in the form [[x, y], [x, y]] + */ +export function normalizeExtent(extent: FlatExtent | NormalizedExtent): NormalizedExtent { + let extentNormalized = extent + if (extent?.length === 4) { + // convert to the flat extent to [[x, y], [x, y]] + extentNormalized = [ + [extent[0], extent[1]], + [extent[2], extent[3]], + ] + } + return extentNormalized as NormalizedExtent +} + +/** + * Flatten extent + * + * @param extent Extent to flatten + * @returns Flatten extent in from [minx, miny, maxx, maxy] + */ +export function flattenExtent(extent: FlatExtent | NormalizedExtent): FlatExtent { + let flattenExtent = extent + if (extent?.length === 2) { + flattenExtent = [...extent[0], ...extent[1]] + } + return flattenExtent as FlatExtent +} + +/** + * Get the intersection of the extent with the current projection, as a flatten extent expressed in + * the current projection + * + * @param extent Such as [minx, miny, maxx, maxy]. or [bottomLeft, topRight] + * @param extentProjection + * @param currentProjection + */ +export function getExtentIntersectionWithCurrentProjection( + extent: FlatExtent | NormalizedExtent, + extentProjection: CoordinateSystem, + currentProjection: CoordinateSystem +): FlatExtent | undefined { + if ( + (extent?.length !== 4 && extent?.length !== 2) || + !extentProjection || + !currentProjection || + !currentProjection.bounds + ) { + return undefined + } + let currentProjectionAsExtentProjection: FlatExtent = currentProjection.bounds + .flatten as FlatExtent + if (extentProjection.epsg !== currentProjection.epsg) { + // We used to reproject the extent here, but there's problem arising if current projection is LV95 and + // the extent is going a little bit out of Switzerland. + // As LV95 is quite location-locked, the further we get, the bigger the mathematical errors start growing. + // So to counteract that, we transform the current projection bounds in the extent projection to do the comparison. + currentProjectionAsExtentProjection = projExtent( + currentProjection, + extentProjection, + currentProjectionAsExtentProjection + ) + } + let finalExtent: Extent = getExtentIntersection( + flattenExtent(extent), + currentProjectionAsExtentProjection + ) + if ( + !finalExtent || + // OL now populates the extent with Infinity when nothing is in common, instead returning a null value + finalExtent.every((value) => Math.abs(value) === Infinity) + ) { + return undefined + } + if (extentProjection.epsg !== currentProjection.epsg) { + // if we transformed the current projection extent above, we now need to output the correct proj + finalExtent = projExtent(extentProjection, currentProjection, finalExtent as FlatExtent) + } + + return flattenExtent(finalExtent as FlatExtent) +} + +interface ConfigCreatePixelExtentAround { + /** + * Number of pixels the extent should be (if s100 is given, a box of 100x100 pixels with the + * coordinate at its center will be returned) + */ + size: number + /** Where the center of the "size" pixel(s) extent should be. */ + coordinate: SingleCoordinate + /** Projection used to describe the coordinates */ + projection: CoordinateSystem + /** Current map resolution, necessary to calculate how much distance "size" pixel(s) means. */ + resolution: number + /** Tells if the extent's value should be rounded before being returned. Default is `false` */ + rounded?: boolean +} + +export function createPixelExtentAround( + config: ConfigCreatePixelExtentAround +): FlatExtent | undefined { + const { size, coordinate, projection, resolution, rounded = false } = config + if (!size || !coordinate || !projection || !resolution) { + return undefined + } + let coordinatesWgs84 = coordinate + if (projection.epsg !== WGS84.epsg) { + coordinatesWgs84 = proj4(projection.epsg, WGS84.epsg, coordinate) + } + const bufferAround = buffer( + point(coordinatesWgs84), + // sphere of the wanted number of pixels as radius around the coordinate + size * resolution, + { units: 'meters' } + ) + if (!bufferAround) { + return undefined + } + const extent: FlatExtent = projExtent(WGS84, projection, bbox(bufferAround) as FlatExtent) + + if (rounded) { + return extent.map((value: number) => round(value)) as FlatExtent + } + return extent +} + +export interface GeoadminExtentUtils { + projExtent: typeof projExtent + normalizeExtent: typeof normalizeExtent + flattenExtent: typeof flattenExtent + getExtentIntersectionWithCurrentProjection: typeof getExtentIntersectionWithCurrentProjection + createPixelExtentAround: typeof createPixelExtentAround +} + +const extentUtils: GeoadminExtentUtils = { + projExtent, + normalizeExtent, + flattenExtent, + getExtentIntersectionWithCurrentProjection, + createPixelExtentAround, +} +export { extentUtils } +export default extentUtils diff --git a/packages/coordinates/src/index.ts b/packages/coordinates/src/index.ts new file mode 100644 index 0000000000..f54ea758dd --- /dev/null +++ b/packages/coordinates/src/index.ts @@ -0,0 +1,25 @@ +/** @module geoadmin/coordinates */ + +import proj4 from 'proj4' + +import { coordinatesUtils, type GeoadminCoordinatesUtils } from '@/coordinatesUtils' +import { extentUtils, type GeoadminExtentUtils } from '@/extentUtils' +import crs, { type GeoadminCoordinateCRS } from '@/proj' +import registerProj4 from '@/registerProj4' + +export * from '@/proj' +export * from '@/registerProj4' +export * from '@/coordinatesUtils' +export * from '@/extentUtils' + +// registering local instance of proj4, needed for some @geoadmin/coordinates functions +registerProj4(proj4) + +interface GeoadminCoordinates extends GeoadminCoordinateCRS { + coordinatesUtils: GeoadminCoordinatesUtils + extentUtils: GeoadminExtentUtils + registerProj4: typeof registerProj4 +} + +const coordinates: GeoadminCoordinates = { ...crs, coordinatesUtils, extentUtils, registerProj4 } +export default coordinates diff --git a/packages/geoadmin-coordinates/src/proj/CoordinateSystem.ts b/packages/coordinates/src/proj/CoordinateSystem.ts similarity index 95% rename from packages/geoadmin-coordinates/src/proj/CoordinateSystem.ts rename to packages/coordinates/src/proj/CoordinateSystem.ts index 4433b5509d..3eaf8fff27 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinateSystem.ts +++ b/packages/coordinates/src/proj/CoordinateSystem.ts @@ -1,8 +1,9 @@ -import { round } from '@geoadmin/numbers' +import { round } from '@swissgeo/numbers' import { earthRadius } from '@turf/turf' import proj4 from 'proj4' -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils' +import type { ResolutionStep } from '@/proj/types' import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' @@ -29,15 +30,6 @@ export const SWISS_ZOOM_LEVEL_1_25000_MAP: number = 8 export const PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES: number = (2 * Math.PI * earthRadius) / 256 -export interface ResolutionStep { - /** Resolution of this step, in meters/pixel */ - resolution: number - /** Corresponding zoom level for this resolution step */ - zoom?: number - /** Name of the map product shown at this resolution/zoom */ - label?: string -} - export interface CoordinateSystemProps { /** * EPSG:xxxx representation of this coordinate system, but only the numerical part (without the @@ -174,10 +166,10 @@ export default abstract class CoordinateSystem { * * @param {CoordinateSystem} coordinateSystem The target coordinate system we want bounds * expressed in - * @returns {CoordinateSystemBounds | null} Bounds, expressed in the coordinate system, or null - * if bounds are undefined or coordinate system is invalid + * @returns {CoordinateSystemBounds | undefined} Bounds, expressed in the coordinate system, or + * undefined if bounds are undefined or the coordinate system is invalid */ - getBoundsAs(coordinateSystem: CoordinateSystem): CoordinateSystemBounds | null { + getBoundsAs(coordinateSystem: CoordinateSystem): CoordinateSystemBounds | undefined { if (this.bounds) { if (coordinateSystem.epsg === this.epsg) { return this.bounds @@ -196,7 +188,7 @@ export default abstract class CoordinateSystem { customCenter, }) } - return null + return } /** diff --git a/packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts b/packages/coordinates/src/proj/CoordinateSystemBounds.ts similarity index 89% rename from packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts rename to packages/coordinates/src/proj/CoordinateSystemBounds.ts index 8ea75f4627..eb8e370996 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts +++ b/packages/coordinates/src/proj/CoordinateSystemBounds.ts @@ -11,8 +11,8 @@ import { } from '@turf/turf' import { sortBy } from 'lodash' +import type { SingleCoordinate } from '@/coordinatesUtils.ts' import type { CoordinatesChunk } from '@/proj/CoordinatesChunk' -import type { SingleCoordinate } from '@/utils' interface CoordinateSystemBoundsProps { lowerX: number @@ -38,14 +38,17 @@ function reassembleLineSegments( const orderedFeatures: Feature[] = [] while (candidateFeatures.length > 0) { candidateFeatures = sortBy(candidateFeatures, (f) => { - if (f.geometry) { + if (f.geometry && f.geometry.coordinates && f.geometry.coordinates[0]) { return distance(origin, f.geometry.coordinates[0]) } else { throw new Error('Feature missing geometry') } }) - const closest = candidateFeatures.shift() as Feature - origin = closest.geometry.coordinates[closest.geometry.coordinates.length - 1] + const closest = candidateFeatures.shift()! + const closestOrigin = closest.geometry.coordinates[closest.geometry.coordinates.length - 1] + if (closestOrigin) { + origin = closestOrigin + } orderedFeatures.push(closest) } return orderedFeatures @@ -115,25 +118,27 @@ export default class CoordinateSystemBounds { * Can be helpful when requesting information from our backends, but said backend doesn't * support world-wide coverage. Typical example is service-profile, if we give it coordinates * outside LV95 bounds it will fill what it doesn't know with coordinates following LV95 extent - * instead of returning null + * instead of returning undefined * * @param {[Number, Number][]} coordinates Coordinates `[[x1,y1],[x2,y2],...]` expressed in the * same coordinate system (projection) as the bounds - * @returns {null | CoordinatesChunk[]} + * @returns {CoordinatesChunk[] | undefined} */ - splitIfOutOfBounds(coordinates: SingleCoordinate[]): CoordinatesChunk[] | null { + splitIfOutOfBounds(coordinates: SingleCoordinate[]): CoordinatesChunk[] | undefined { if (!Array.isArray(coordinates) || coordinates.length <= 1) { - return null + return } // checking that all coordinates are well-formed if (coordinates.find((coordinate) => coordinate.length !== 2)) { - return null + return } // checking if we require splitting if (coordinates.find((coordinate) => !this.isInBounds(coordinate[0], coordinate[1]))) { const boundsAsPolygon = bboxPolygon(this.flatten) const paths = lineSplit(lineString(coordinates), boundsAsPolygon) - paths.features = reassembleLineSegments(coordinates[0], paths) + if (coordinates[0]) { + paths.features = reassembleLineSegments(coordinates[0], paths) + } return paths.features.map((chunk) => { return { coordinates: chunk.geometry.coordinates, diff --git a/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts b/packages/coordinates/src/proj/CoordinatesChunk.ts similarity index 87% rename from packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts rename to packages/coordinates/src/proj/CoordinatesChunk.ts index 84a50e5690..cbcfe184a6 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts +++ b/packages/coordinates/src/proj/CoordinatesChunk.ts @@ -1,4 +1,4 @@ -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils' /** * Group of coordinates resulting in a "split by bounds" function. Will also contain information if diff --git a/packages/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts b/packages/coordinates/src/proj/CustomCoordinateSystem.ts similarity index 94% rename from packages/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts rename to packages/coordinates/src/proj/CustomCoordinateSystem.ts index 66857ff3d8..60b0352865 100644 --- a/packages/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts +++ b/packages/coordinates/src/proj/CustomCoordinateSystem.ts @@ -1,10 +1,10 @@ +import type { SingleCoordinate } from '@/coordinatesUtils' import type CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' -import type { SingleCoordinate } from '@/utils' import CoordinateSystem, { type CoordinateSystemProps } from '@/proj/CoordinateSystem' export interface CustomCoordinateSystemProps extends CoordinateSystemProps { - /** With custom coordinate system, bounds are mandatory. */ + /** With a custom coordinate system, bounds are mandatory. */ bounds: CoordinateSystemBounds } diff --git a/packages/geoadmin-coordinates/src/proj/LV03CoordinateSystem.ts b/packages/coordinates/src/proj/LV03CoordinateSystem.ts similarity index 100% rename from packages/geoadmin-coordinates/src/proj/LV03CoordinateSystem.ts rename to packages/coordinates/src/proj/LV03CoordinateSystem.ts diff --git a/packages/geoadmin-coordinates/src/proj/LV95CoordinateSystem.ts b/packages/coordinates/src/proj/LV95CoordinateSystem.ts similarity index 100% rename from packages/geoadmin-coordinates/src/proj/LV95CoordinateSystem.ts rename to packages/coordinates/src/proj/LV95CoordinateSystem.ts diff --git a/packages/geoadmin-coordinates/src/proj/StandardCoordinateSystem.ts b/packages/coordinates/src/proj/StandardCoordinateSystem.ts similarity index 93% rename from packages/geoadmin-coordinates/src/proj/StandardCoordinateSystem.ts rename to packages/coordinates/src/proj/StandardCoordinateSystem.ts index 5a8d59b1c5..7ce9b9d024 100644 --- a/packages/geoadmin-coordinates/src/proj/StandardCoordinateSystem.ts +++ b/packages/coordinates/src/proj/StandardCoordinateSystem.ts @@ -17,6 +17,6 @@ export default abstract class StandardCoordinateSystem extends CoordinateSystem } getDefaultZoom(): number { - return STANDARD_ZOOM_LEVEL_1_25000_MAP + return this.get1_25000ZoomLevel() } } diff --git a/packages/geoadmin-coordinates/src/proj/SwissCoordinateSystem.ts b/packages/coordinates/src/proj/SwissCoordinateSystem.ts similarity index 71% rename from packages/geoadmin-coordinates/src/proj/SwissCoordinateSystem.ts rename to packages/coordinates/src/proj/SwissCoordinateSystem.ts index 78f14210c0..d74c320612 100644 --- a/packages/geoadmin-coordinates/src/proj/SwissCoordinateSystem.ts +++ b/packages/coordinates/src/proj/SwissCoordinateSystem.ts @@ -1,12 +1,24 @@ -import { closest, round } from '@geoadmin/numbers' +import { closest, round } from '@swissgeo/numbers' + +import type { ResolutionStep } from '@/proj/types' import { - type ResolutionStep, + PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES, STANDARD_ZOOM_LEVEL_1_25000_MAP, SWISS_ZOOM_LEVEL_1_25000_MAP, } from '@/proj/CoordinateSystem' import CustomCoordinateSystem from '@/proj/CustomCoordinateSystem' +/** + * Latitude where the LV95 plane is anchored to the Mercator system. Used to calculate/transform + * LV95 zoom level into Mercator zoom level + * + * Value can be found in the PROJ4 matrix on epsg.io + * + * @see https://epsg.io/2056 + */ +const LV95_LATITUDE_CENTER_IN_WGS84: number = 46.9524055555556 + /** * Resolutions for each LV95 zoom level, from 0 to 14 * @@ -135,6 +147,8 @@ export default class SwissCoordinateSystem extends CustomCoordinateSystem { transformStandardZoomLevelToCustom(standardZoomLevel: number): number { // checking first if the standard zoom level is within range of swiss zooms we have available if ( + typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] === 'number' && + typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] === 'number' && standardZoomLevel >= SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] && standardZoomLevel <= SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] ) { @@ -142,10 +156,16 @@ export default class SwissCoordinateSystem extends CustomCoordinateSystem { (zoom) => zoom < standardZoomLevel ).length } - if (standardZoomLevel < SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0]) { + if ( + typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] === 'number' && + standardZoomLevel < SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] + ) { return 0 } - if (standardZoomLevel > SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14]) { + if ( + typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] === 'number' && + standardZoomLevel > SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] + ) { return 14 } // if no matching zoom level was found, we return the one for the 1:25'000 map @@ -163,31 +183,48 @@ export default class SwissCoordinateSystem extends CustomCoordinateSystem { * level to show the 1:25'000 map if the input is invalid */ transformCustomZoomLevelToStandard(customZoomLevel: number): number { - const key = Math.floor(customZoomLevel) - if (SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX.length - 1 >= key) { - return SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[key] - } - // if no matching zoom level was found, we return the one for the 1:25'000 map - return STANDARD_ZOOM_LEVEL_1_25000_MAP + const lv95Resolution: number = this.getResolutionForZoomAndCenter(customZoomLevel) + // reverting formula from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale + return Math.log2( + 1.0 / + (lv95Resolution / + PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES / + Math.cos((Math.PI * LV95_LATITUDE_CENTER_IN_WGS84) / 180.0)) + ) } getResolutionForZoomAndCenter(zoom: number): number { // ignoring the center, as it won't have any effect on the chosen zoom level - return LV95_RESOLUTIONS[Math.round(zoom)] + const roundedZoom: number = Math.floor(zoom) + const resolutions: ResolutionStep[] = this.getResolutionSteps() + const resolutionMatchingZoom: ResolutionStep | undefined = resolutions.find( + (step) => step.zoom === roundedZoom + ) + if (resolutionMatchingZoom) { + const nextStep: ResolutionStep | undefined = resolutions.find( + (step) => step.zoom === roundedZoom + 1 + ) + if (!nextStep) { + return resolutionMatchingZoom.resolution + } + const zoomFactor: number = resolutionMatchingZoom.resolution / nextStep.resolution + return resolutionMatchingZoom.resolution / Math.pow(zoomFactor, zoom % 1.0) + } + return LV95_RESOLUTIONS[roundedZoom]! } getZoomForResolutionAndCenter(resolution: number): number { // ignoring the center, as it won't have any effect on the resolution - const matchingResolution = LV95_RESOLUTIONS.find( - (lv95Resolution) => lv95Resolution <= resolution - ) - if (matchingResolution) { - return LV95_RESOLUTIONS.indexOf(matchingResolution) + const matchingResolutionStep: ResolutionStep | undefined = this.getResolutionSteps() + .filter((step) => step.zoom) + .find((step) => step.resolution <= resolution) + if (matchingResolutionStep && matchingResolutionStep.zoom !== undefined) { + return matchingResolutionStep.zoom } // if no match was found, we have to decide if the resolution is too great, // or too small to be matched and return the zoom accordingly const smallestResolution = LV95_RESOLUTIONS.slice(-1)[0] - if (smallestResolution > resolution) { + if (smallestResolution && smallestResolution > resolution) { // if the resolution was smaller than the smallest available, we return the zoom level corresponding // to the smallest available resolution return LV95_RESOLUTIONS.indexOf(smallestResolution) diff --git a/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts b/packages/coordinates/src/proj/WGS84CoordinateSystem.ts similarity index 80% rename from packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts rename to packages/coordinates/src/proj/WGS84CoordinateSystem.ts index 88611d0424..e2d8046e94 100644 --- a/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts +++ b/packages/coordinates/src/proj/WGS84CoordinateSystem.ts @@ -1,6 +1,6 @@ -import { round } from '@geoadmin/numbers' +import { round } from '@swissgeo/numbers' -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils.ts' import { PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES } from '@/proj/CoordinateSystem' import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' @@ -42,24 +42,25 @@ export default class WGS84CoordinateSystem extends StandardCoordinateSystem { Math.abs( (PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES * Math.cos((center[1] * Math.PI) / 180.0)) / - Math.pow(2, zoom) + Math.pow(2, zoom) ), 2 ) } /** - * Ensures an extent is in X,Y order (longitude, latitude). - * If coordinates are in Y,X order (latitude, longitude), swaps them. - * WGS84 traditionally uses latitude-first (Y,X) axis order [minY, minX, maxY, maxX] - * Some WGS84 implementations may use X,Y order therefore we need to check and swap if needed. - * - * TODO: This method works for the common coordinates in and around switzerland but will not work for the whole world. - * Therefore a better solution should be implemented if we want to support coordinates and extents of the whole world. - * - * @link Problem description https://docs.geotools.org/latest/userguide/library/referencing/order.html + * Ensures an extent is in X,Y order (longitude, latitude). If coordinates are in Y,X order + * (latitude, longitude), swaps them. WGS84 traditionally uses latitude-first (Y,X) axis order + * [minY, minX, maxY, maxX] Some WGS84 implementations may use X,Y order therefore we need to + * check and swap if needed. + * + * TODO: This method works for the common coordinates in and around switzerland but will not + * work for the whole world. Therefore a better solution should be implemented if we want to + * support coordinates and extents of the whole world. + * * @param extent - Input extent [minX, minY, maxX, maxY] or [minY, minX, maxY, maxX] * @returns Extent guaranteed to be in [minX, minY, maxX, maxY] order + * @link Problem description https://docs.geotools.org/latest/userguide/library/referencing/order.html */ getExtentInOrderXY(extent: [number, number, number, number]): [number, number, number, number] { if (extent[0] > extent[1]) { @@ -88,8 +89,8 @@ export default class WGS84CoordinateSystem extends StandardCoordinateSystem { return Math.abs( Math.log2( resolution / - PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES / - Math.cos((center[1] * Math.PI) / 180.0) + PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES / + Math.cos((center[1] * Math.PI) / 180.0) ) ) } diff --git a/packages/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts b/packages/coordinates/src/proj/WebMercatorCoordinateSystem.ts similarity index 89% rename from packages/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts rename to packages/coordinates/src/proj/WebMercatorCoordinateSystem.ts index 6bc5454051..171e0e5cae 100644 --- a/packages/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts +++ b/packages/coordinates/src/proj/WebMercatorCoordinateSystem.ts @@ -1,7 +1,7 @@ -import { round } from '@geoadmin/numbers' +import { round } from '@swissgeo/numbers' import proj4 from 'proj4' -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils.ts' import { WGS84 } from '@/proj' import { PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES } from '@/proj/CoordinateSystem' @@ -40,9 +40,12 @@ export default class WebMercatorCoordinateSystem extends StandardCoordinateSyste * resolution = 156543.03 meters / pixel * cos(latitude) / (2 ^ zoom level) */ getResolutionForZoomAndCenter(zoom: number, center: SingleCoordinate): number { - const centerInRad = proj4(this.epsg, WGS84.epsg, center).map( + const centerInRad: SingleCoordinate = proj4(this.epsg, WGS84.epsg, center).map( (coordinate) => (coordinate * Math.PI) / 180.0 - ) + ) as SingleCoordinate + if (typeof centerInRad[1] !== 'number') { + return 0 + } return round( Math.abs( (PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES * Math.cos(centerInRad[1])) / @@ -72,6 +75,9 @@ export default class WebMercatorCoordinateSystem extends StandardCoordinateSyste const centerInRad = proj4(this.epsg, WGS84.epsg, center).map( (coordinate) => (coordinate * Math.PI) / 180.0 ) + if (typeof centerInRad[1] !== 'number') { + return 0 + } return Math.abs( Math.log2( resolution / diff --git a/packages/coordinates/src/proj/__test__/CoordinateSystem.spec.ts b/packages/coordinates/src/proj/__test__/CoordinateSystem.spec.ts new file mode 100644 index 0000000000..fa9609595d --- /dev/null +++ b/packages/coordinates/src/proj/__test__/CoordinateSystem.spec.ts @@ -0,0 +1,106 @@ +import { assertType, describe, expect, it } from 'vitest' + +import { LV95, type ResolutionStep, WEBMERCATOR, WGS84 } from '@/proj' +import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' +import StandardCoordinateSystem from '@/proj/StandardCoordinateSystem' +import { LV95_RESOLUTIONS } from '@/proj/SwissCoordinateSystem' + +class BoundlessCoordinateSystem extends StandardCoordinateSystem { + constructor() { + super({ + usesMercatorPyramid: false, + proj4transformationMatrix: 'test', + label: 'test', + epsgNumber: 1234, + }) + } + getResolutionForZoomAndCenter(): number { + return 0 + } + + getZoomForResolutionAndCenter(): number { + return 0 + } + + roundCoordinateValue(): number { + return 0 + } +} + +describe('CoordinateSystem', () => { + const coordinateSystemWithouBounds = new BoundlessCoordinateSystem() + describe('getBoundsAs', () => { + it('returns undefined if the bounds are not defined', () => { + expect(coordinateSystemWithouBounds.getBoundsAs(WEBMERCATOR)).to.be.undefined + }) + it('transforms LV95 into WebMercator correctly', () => { + const result = LV95.getBoundsAs(WEBMERCATOR) + expect(result).to.be.an.instanceOf(CoordinateSystemBounds) + // numbers are coming from epsg.io's transform tool + const acceptableDelta = 0.01 + expect(result!.lowerX).to.approximately(572215.44, acceptableDelta) + expect(result!.lowerY).to.approximately(5684416.96, acceptableDelta) + expect(result!.upperX).to.approximately(1277662.36, acceptableDelta) + expect(result!.upperY).to.approximately(6145307.39, acceptableDelta) + }) + it('transforms LV95 into WGS84 correctly', () => { + const result = LV95.getBoundsAs(WGS84) + expect(result).to.be.an.instanceOf(CoordinateSystemBounds) + // numbers are coming from epsg.io's transform tool + const acceptableDelta = 0.0001 + expect(result!.lowerX).to.approximately(5.14029, acceptableDelta) + expect(result!.lowerY).to.approximately(45.39812, acceptableDelta) + expect(result!.upperX).to.approximately(11.47744, acceptableDelta) + expect(result!.upperY).to.approximately(48.23062, acceptableDelta) + }) + }) + describe('isInBound', () => { + it('returns false if no bounds are defined', () => { + expect(coordinateSystemWithouBounds.isInBounds(0, 0)).to.be.false + expect(coordinateSystemWithouBounds.isInBounds(1, 1)).to.be.false + }) + // the remaining tests for this function are handled in the CoordinateSystemBounds.spec.ts file + }) + describe('getResolutionSteps', () => { + it('returns all standard (Mercator) resolutions', () => { + const resolutions = WEBMERCATOR.getResolutionSteps() + expect(resolutions).to.be.an('Array').lengthOf(21) + + // mashup of values from https://wiki.openstreetmap.org/wiki/Zoom_levels (from zoom 0 to 18) + // and https://wiki.openstreetmap.org/wiki/Zoom_levels (zoom 19 and 20) + const expectedResolutions = [ + 156543.03, 78271.52, 39135.76, 19567.88, 9783.94, 4891.97, 2445.98, 1222.99, 611.5, + 305.75, 152.87, 76.437, 38.219, 19.109, 9.5546, 4.7773, 2.3887, 1.1943, 0.5972, + 0.299, 0.149, + ] + + resolutions.forEach((resolutionStep, index) => { + expect(resolutionStep).toBeDefined() + assertType(resolutionStep) + expect(resolutionStep.zoom).to.eq(index) + expect(resolutionStep.resolution).to.be.greaterThan(0) + + // see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale + // the formula that was used is : resolution = 156543.03 meters/pixel * cos(latitude) / (2 ^ zoomlevel) + // with latitude being 47° (about Bern's latitude) + expect( + resolutionStep.resolution, + `zoom level ${index} resolution is wrongly calculated` + ).to.be.closeTo(expectedResolutions[index]!, expectedResolutions[index]! / 100.0) // 1% tolerance + }) + }) + it.skip('returns all LV95 resolutions', () => { + const resolutions = LV95.getResolutionSteps() + expect(resolutions).to.be.an('Array').lengthOf(LV95_RESOLUTIONS.length) + + resolutions.forEach((resolutionStep, index) => { + expect(resolutionStep).to.be.an('Object') + expect(resolutionStep.zoom).to.eq(index) + expect( + resolutionStep.resolution, + `wrong LV95 resolution at zoom level ${index}` + ).to.be.eq(LV95_RESOLUTIONS[index]) + }) + }) + }) +}) diff --git a/packages/coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts b/packages/coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts new file mode 100644 index 0000000000..a26c95b289 --- /dev/null +++ b/packages/coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import type { SingleCoordinate } from '@/coordinatesUtils' + +import { LV95 } from '@/proj' +import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' + +describe('CoordinateSystemBounds', () => { + describe('splitIfOutOfBounds(coordinates, bounds)', () => { + let bounds: CoordinateSystemBounds + + beforeEach(() => { + bounds = new CoordinateSystemBounds({ lowerX: 0, upperX: 100, lowerY: 50, upperY: 100 }) + }) + + it('returns a single CoordinatesChunk if no split is needed', () => { + const coordinatesWithinBounds: SingleCoordinate[] = [ + [bounds.lowerX + 1, bounds.upperY - 1], + [bounds.lowerX + 2, bounds.upperY - 2], + [bounds.lowerX + 3, bounds.upperY - 3], + [bounds.lowerX + 4, bounds.upperY - 4], + [bounds.lowerX + 5, bounds.upperY - 5], + [bounds.lowerX + 6, bounds.upperY - 6], + [bounds.lowerX + 7, bounds.upperY - 7], + ] + const result = bounds.splitIfOutOfBounds(coordinatesWithinBounds) + expect(result).to.be.an('Array').of.length(1) + const [singleChunk] = result! + expect(singleChunk).to.be.an('Object').that.has.ownProperty('coordinates') + expect(singleChunk).to.haveOwnProperty('isWithinBounds') + expect(singleChunk!.isWithinBounds).to.be.true + expect(singleChunk!.coordinates).to.eql(coordinatesWithinBounds) + }) + it('splits the given coordinates in two chunks if part of it is outside bounds', () => { + const yValue = 50 + const coordinatesOverlappingBounds: SingleCoordinate[] = [ + // starting by adding coordinates out of bounds + [bounds.lowerX - 1, yValue], + // split should occur here as we start to be in bounds + [bounds.lowerX + 1, yValue], + [50, yValue], + [bounds.upperX - 1, yValue], + ] + const result = bounds.splitIfOutOfBounds(coordinatesOverlappingBounds) + expect(result).to.be.an('Array').of.length(2) + const [outOfBoundChunk, inBoundChunk] = result! + expect(outOfBoundChunk).to.haveOwnProperty('isWithinBounds') + expect(outOfBoundChunk!.isWithinBounds).to.be.false + expect(outOfBoundChunk!.coordinates).to.be.an('Array').of.length(2) + expect(outOfBoundChunk!.coordinates[0]).to.eql(coordinatesOverlappingBounds[0]) + // checking that the split happened on the bounds + const intersectingCoordinate = outOfBoundChunk!.coordinates[1] + expect(intersectingCoordinate).to.be.an('Array').of.length(2) + expect(intersectingCoordinate).to.eql([bounds.lowerX, yValue]) + // next chunk must start by the intersecting coordinate + expect(inBoundChunk).to.haveOwnProperty('isWithinBounds') + expect(inBoundChunk!.isWithinBounds).to.be.true + expect(inBoundChunk!.coordinates).to.be.an('Array').of.length(4) + const [firstInBoundCoordinate] = inBoundChunk!.coordinates + expect(firstInBoundCoordinate).to.be.an('Array').of.length(2) + expect(firstInBoundCoordinate).to.eql([bounds.lowerX, yValue]) + // checking that further coordinates have been correctly copied + coordinatesOverlappingBounds.slice(1).forEach((coordinate, index) => { + expect(inBoundChunk!.coordinates[index + 1]![0]).to.eq(coordinate[0]) + expect(inBoundChunk!.coordinates[index + 1]![1]).to.eq(coordinate[1]) + }) + }) + it('gives similar results if coordinates are given in the reverse order', () => { + const yValue = 50 + // same test data as previous test, but reversed + const coordinatesOverlappingBounds: SingleCoordinate[] = [ + [bounds.lowerX - 1, yValue] as SingleCoordinate, + [bounds.lowerX + 1, yValue] as SingleCoordinate, + [50, yValue] as SingleCoordinate, + [bounds.upperX - 1, yValue] as SingleCoordinate, + ].toReversed() + const result = bounds.splitIfOutOfBounds(coordinatesOverlappingBounds) + expect(result).to.be.an('Array').of.length(2) + const [inBoundChunk, outOfBoundChunk] = result! + + // first chunk must now be the in bound one + expect(inBoundChunk).to.haveOwnProperty('isWithinBounds') + expect(inBoundChunk!.isWithinBounds).to.be.true + expect(inBoundChunk!.coordinates).to.be.an('Array').of.length(4) + const lastInBoundCoordinate = inBoundChunk!.coordinates.splice(-1)[0] + expect(lastInBoundCoordinate).to.be.an('Array').of.length(2) + expect(lastInBoundCoordinate).to.eql([bounds.lowerX, yValue]) + + expect(outOfBoundChunk).to.haveOwnProperty('isWithinBounds') + expect(outOfBoundChunk!.isWithinBounds).to.be.false + expect(outOfBoundChunk!.coordinates).to.be.an('Array').of.length(2) + expect(outOfBoundChunk!.coordinates[0]).to.eql([bounds.lowerX, yValue]) + }) + it('handles properly a line going multiple times out of bounds', () => { + const coordinatesGoingBackAndForth: SingleCoordinate[] = [ + [-1, 51], // outside + [1, 51], // inside going in the X direction + [1, 101], // outside going in the Y direction + [101, 101], // outside + [99, 99], // inside going both directions + [1, 51], // inside moving on the other side of the bounds + ] + const expectedFirstIntersection: SingleCoordinate = [bounds.lowerX, 51] + const expectedSecondIntersection: SingleCoordinate = [1, bounds.upperY] + const expectedThirdIntersection: SingleCoordinate = [bounds.upperX, bounds.upperY] + + const result = bounds.splitIfOutOfBounds(coordinatesGoingBackAndForth) + expect(result).to.be.an('Array').of.length(4) + const [firstChunk, secondChunk, thirdChunk, fourthChunk] = result! + // first chunk should have two coordinates, the first from the list and the first intersection + expect(firstChunk!.isWithinBounds).to.be.false + expect(firstChunk!.coordinates).to.be.an('Array').of.length(2) + expect(firstChunk!.coordinates[0]).to.eql(coordinatesGoingBackAndForth[0]) + expect(firstChunk!.coordinates[1]).to.eql(expectedFirstIntersection) + // second chunk should start with the first intersection, then include the second coord + // and finish with the second intersection + expect(secondChunk!.coordinates).to.be.an('Array').of.length(3) + expect(secondChunk!.isWithinBounds).to.be.true + expect(secondChunk!.coordinates[0]).to.eql(expectedFirstIntersection) + expect(secondChunk!.coordinates[1]).to.eql(coordinatesGoingBackAndForth[1]) + expect(secondChunk!.coordinates[2]).to.eql(expectedSecondIntersection) + // third chunk should be : intersection2, coord3, coord4, intersection3 + expect(thirdChunk!.coordinates).to.be.an('Array').of.length(4) + expect(thirdChunk!.isWithinBounds).to.be.false + expect(thirdChunk!.coordinates[0]).to.eql(expectedSecondIntersection) + expect(thirdChunk!.coordinates[1]).to.eql(coordinatesGoingBackAndForth[2]) + expect(thirdChunk!.coordinates[2]).to.eql(coordinatesGoingBackAndForth[3]) + expect(thirdChunk!.coordinates[3]).to.eql(expectedThirdIntersection) + // last chunk should be : intersection3, coord5, coord6 + expect(fourthChunk!.coordinates).to.be.an('Array').of.length(3) + expect(fourthChunk!.isWithinBounds).to.be.true + expect(fourthChunk!.coordinates[0]).to.eql(expectedThirdIntersection) + expect(fourthChunk!.coordinates[1]).to.eql(coordinatesGoingBackAndForth[4]) + expect(fourthChunk!.coordinates[2]).to.eql(coordinatesGoingBackAndForth[5]) + }) + it('splits correctly a line crossing bounds two times in a straight line (no stop inside)', () => { + const coordinatesGoingThrough: SingleCoordinate[] = [ + [-1, 50], // outside + [101, 50], // outside + ] + const expectedFirstIntersection: SingleCoordinate = [bounds.lowerX, 50] + const expectedSecondIntersection: SingleCoordinate = [bounds.upperX, 50] + + const result = bounds.splitIfOutOfBounds(coordinatesGoingThrough) + expect(result).to.be.an('Array').of.length(3) + const [firstChunk, secondChunk, thirdChunk] = result! + + expect(firstChunk!.isWithinBounds).to.be.false + expect(firstChunk!.coordinates).to.be.an('Array').of.length(2) + expect(firstChunk!.coordinates[0]).to.eql(coordinatesGoingThrough[0]) + expect(firstChunk!.coordinates[1]).to.eql(expectedFirstIntersection) + + expect(secondChunk!.isWithinBounds).to.be.true + expect(secondChunk!.coordinates).to.be.an('Array').of.length(2) + expect(secondChunk!.coordinates[0]).to.eql(expectedFirstIntersection) + expect(secondChunk!.coordinates[1]).to.eql(expectedSecondIntersection) + + expect(thirdChunk!.isWithinBounds).to.be.false + expect(thirdChunk!.coordinates).to.be.an('Array').of.length(2) + expect(thirdChunk!.coordinates[0]).to.eql(expectedSecondIntersection) + expect(thirdChunk!.coordinates[1]).to.eql(coordinatesGoingThrough[1]) + }) + it('handles some "real" use case well', () => { + const sample1: SingleCoordinate[] = [ + [2651000, 1392000], + [2932500, 894500], + ] + const result = LV95.bounds.splitIfOutOfBounds(sample1) + expect(result).to.be.an('Array').of.length(3) + const [firstChunk, secondChunk, thirdChunk] = result! + + expect(firstChunk!.isWithinBounds).to.be.false + expect(firstChunk!.coordinates).to.be.an('Array').of.length(2) + expect(firstChunk!.coordinates[0]).to.eql(sample1[0]) + expect(firstChunk!.coordinates[1]![0]).to.approximately(2674764.8, 0.1) + expect(firstChunk!.coordinates[1]![1]).to.approximately(1350000, 0.1) + + expect(secondChunk!.isWithinBounds).to.be.true + expect(secondChunk!.coordinates).to.be.an('Array').of.length(2) + expect(secondChunk!.coordinates[0]![0]).to.approximately(2674764.8, 0.1) + expect(secondChunk!.coordinates[0]![1]).to.approximately(1350000, 0.1) + expect(secondChunk!.coordinates[1]![0]).to.approximately(2855830.1, 0.1) + expect(secondChunk!.coordinates[1]![1]).to.approximately(1030000, 0.1) + + expect(thirdChunk!.isWithinBounds).to.be.false + expect(thirdChunk!.coordinates).to.be.an('Array').of.length(2) + expect(thirdChunk!.coordinates[0]![0]).to.approximately(2855830.1, 0.1) + expect(thirdChunk!.coordinates[0]![1]).to.approximately(1030000, 0.1) + expect(thirdChunk!.coordinates[1]).to.eql(sample1[1]) + + const reversedResult = LV95.bounds.splitIfOutOfBounds(sample1.toReversed()) + expect(reversedResult).to.be.an('Array').of.length(3) + const [firstReversedChunk, secondReversedChunk, thirdReversedChunk] = reversedResult! + + expect(firstReversedChunk!.isWithinBounds).to.be.false + expect(firstReversedChunk!.coordinates).to.be.an('Array').of.length(2) + expect(firstReversedChunk!.coordinates[0]).to.eql(sample1[1]) + expect(firstReversedChunk!.coordinates[1]![0]).to.approximately(2855830.1, 0.1) + expect(firstReversedChunk!.coordinates[1]![1]).to.approximately(1030000, 0.1) + + expect(secondReversedChunk!.isWithinBounds).to.be.true + expect(secondReversedChunk!.coordinates).to.be.an('Array').of.length(2) + expect(secondReversedChunk!.coordinates[0]![0]).to.approximately(2855830.1, 0.1) + expect(secondReversedChunk!.coordinates[0]![1]).to.approximately(1030000, 0.1) + expect(secondReversedChunk!.coordinates[1]![0]).to.approximately(2674764.8, 0.1) + expect(secondReversedChunk!.coordinates[1]![1]).to.approximately(1350000, 0.1) + + expect(thirdReversedChunk!.isWithinBounds).to.be.false + expect(thirdReversedChunk!.coordinates).to.be.an('Array').of.length(2) + expect(thirdReversedChunk!.coordinates[0]![0]).to.approximately(2674764.8, 0.1) + expect(thirdReversedChunk!.coordinates[0]![1]).to.approximately(1350000, 0.1) + expect(thirdReversedChunk!.coordinates[1]).to.eql(sample1[0]) + }) + }) + describe('isInBounds(x, y)', () => { + const testInstance = new CoordinateSystemBounds({ + lowerX: -1, + upperX: 1, + lowerY: -1, + upperY: 1, + }) + it('returns true if we are on the border of the bounds', () => { + expect(testInstance.isInBounds(-1, -1)).to.be.true + expect(testInstance.isInBounds(-1, 1)).to.be.true + expect(testInstance.isInBounds(1, -1)).to.be.true + expect(testInstance.isInBounds(1, 1)).to.be.true + }) + it('returns true if we are in bounds not touching any border', () => { + expect(testInstance.isInBounds(0, 0)).to.be.true + }) + it('returns false if only one parameter (X or Y) is out of bound', () => { + expect(testInstance.isInBounds(-1, -2)).to.be.false + expect(testInstance.isInBounds(-2, -1)).to.be.false + expect(testInstance.isInBounds(-1, 2)).to.be.false + expect(testInstance.isInBounds(2, -1)).to.be.false + expect(testInstance.isInBounds(1, -2)).to.be.false + expect(testInstance.isInBounds(-2, 1)).to.be.false + expect(testInstance.isInBounds(1, 2)).to.be.false + expect(testInstance.isInBounds(2, 1)).to.be.false + }) + }) + describe('flatten', () => { + const lowerX = 123 + const upperX = 456 + const lowerY = 345 + const upperY = 678 + const testInstance = new CoordinateSystemBounds({ lowerX, upperX, lowerY, upperY }) + it('produces a flatten array correctly', () => { + expect(testInstance.flatten).to.eql([lowerX, lowerY, upperX, upperY]) + }) + }) +}) diff --git a/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js b/packages/coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts similarity index 81% rename from packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js rename to packages/coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts index ca388c69fa..c61bfe60b2 100644 --- a/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js +++ b/packages/coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from 'vitest' import { LV03, LV95 } from '@/proj' -import { - LV95_RESOLUTIONS, - SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX, -} from '@/proj/SwissCoordinateSystem' +import { LV95_RESOLUTIONS, SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX } from '@/proj/SwissCoordinateSystem' describe('Unit test functions from SwissCoordinateSystem', () => { describe('transformCustomZoomLevelToStandard', () => { @@ -38,10 +35,10 @@ describe('Unit test functions from SwissCoordinateSystem', () => { SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX.forEach( (mercatorZoom, swisstopoZoom) => { expect(LV95.transformStandardZoomLevelToCustom(mercatorZoom)).to.eq( - parseInt(swisstopoZoom) + swisstopoZoom ) expect(LV03.transformStandardZoomLevelToCustom(mercatorZoom)).to.eq( - parseInt(swisstopoZoom) + swisstopoZoom ) } ) @@ -55,7 +52,7 @@ describe('Unit test functions from SwissCoordinateSystem', () => { return { start: 0, end: - SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] - + SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0]! - acceptableDeltaInMercatorZoomLevel, expected: 0, } @@ -68,7 +65,7 @@ describe('Unit test functions from SwissCoordinateSystem', () => { } } const nextZoomLevel = - SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[lv95Zoom + 1] + SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[lv95Zoom + 1]! return { start: mercatorZoom + acceptableDeltaInMercatorZoomLevel, end: nextZoomLevel, @@ -83,11 +80,11 @@ describe('Unit test functions from SwissCoordinateSystem', () => { zoomLevel += acceptableDeltaInMercatorZoomLevel ) { expect(LV95.transformStandardZoomLevelToCustom(zoomLevel)).to.eq( - parseInt(range.expected), + range.expected, `Mercator zoom ${zoomLevel} was not translated to LV95 correctly` ) expect(LV03.transformStandardZoomLevelToCustom(zoomLevel)).to.eq( - parseInt(range.expected), + range.expected, `Mercator zoom ${zoomLevel} was not translated to LV03 correctly` ) } @@ -96,35 +93,29 @@ describe('Unit test functions from SwissCoordinateSystem', () => { }) describe('getZoomForResolutionAndY', () => { it('returns zoom=0 if the resolution is too great', () => { - expect(LV95.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[0] + 1)).to.eq(0) - expect(LV03.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[0] + 1)).to.eq(0) + expect(LV95.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[0]! + 1)).to.eq(0) + expect(LV03.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[0]! + 1)).to.eq(0) }) it('returns zoom correctly while resolution is exactly on a threshold', () => { for (let i = 0; i < LV95_RESOLUTIONS.length - 1; i++) { - expect(LV95.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[i])).to.eq(i) - expect(LV03.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[i])).to.eq(i) + expect(LV95.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[i]!)).to.eq(i) + expect(LV03.getZoomForResolutionAndCenter(LV95_RESOLUTIONS[i]!)).to.eq(i) } }) it('returns zoom correctly while resolution is in between the two thresholds', () => { - for (let i = 0; i < LV95_RESOLUTIONS.length - 2; i++) { - for ( - let resolution = LV95_RESOLUTIONS[i] - 1; - resolution > LV95_RESOLUTIONS[i + 1]; - resolution-- - ) { - expect(LV95.getZoomForResolutionAndCenter(resolution)).to.eq( - i + 1, - `resolution ${resolution} was misinterpreted` - ) - expect(LV03.getZoomForResolutionAndCenter(resolution)).to.eq( - i + 1, - `resolution ${resolution} was misinterpreted` - ) - } - } + LV95.getResolutionSteps().filter((step) => step.zoom !== undefined).forEach((stepWithZoomLevel, i) => { + expect(LV95.getZoomForResolutionAndCenter(stepWithZoomLevel.resolution)).to.eq( + stepWithZoomLevel.zoom, + `LV95 resolution ${stepWithZoomLevel.resolution} was misinterpreted` + ) + expect(LV03.getZoomForResolutionAndCenter(stepWithZoomLevel.resolution)).to.eq( + stepWithZoomLevel.zoom, + `LV03 resolution ${stepWithZoomLevel.resolution} was misinterpreted` + ) + }) }) it('returns the max zoom available, event if the resolution is smaller than expected', () => { - const smallestResolution = LV95_RESOLUTIONS[LV95_RESOLUTIONS.length - 1] + const smallestResolution = LV95_RESOLUTIONS[LV95_RESOLUTIONS.length - 1]! expect(LV95.getZoomForResolutionAndCenter(smallestResolution - 0.1)).to.eq( LV95_RESOLUTIONS.indexOf(smallestResolution) ) diff --git a/packages/geoadmin-coordinates/src/proj/index.ts b/packages/coordinates/src/proj/index.ts similarity index 50% rename from packages/geoadmin-coordinates/src/proj/index.ts rename to packages/coordinates/src/proj/index.ts index fa8df52442..f5d25c9c15 100644 --- a/packages/geoadmin-coordinates/src/proj/index.ts +++ b/packages/coordinates/src/proj/index.ts @@ -2,9 +2,15 @@ import CoordinateSystem, { STANDARD_ZOOM_LEVEL_1_25000_MAP, SWISS_ZOOM_LEVEL_1_25000_MAP, } from '@/proj/CoordinateSystem' +import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' +import CustomCoordinateSystem from '@/proj/CustomCoordinateSystem' import LV03CoordinateSystem from '@/proj/LV03CoordinateSystem' import LV95CoordinateSystem from '@/proj/LV95CoordinateSystem' -import { LV95_RESOLUTIONS, SWISSTOPO_TILEGRID_RESOLUTIONS } from '@/proj/SwissCoordinateSystem' +import StandardCoordinateSystem from '@/proj/StandardCoordinateSystem' +import SwissCoordinateSystem, { + LV95_RESOLUTIONS, + SWISSTOPO_TILEGRID_RESOLUTIONS, +} from '@/proj/SwissCoordinateSystem' import WebMercatorCoordinateSystem from '@/proj/WebMercatorCoordinateSystem' import WGS84CoordinateSystem from '@/proj/WGS84CoordinateSystem' @@ -13,24 +19,47 @@ export const LV03: LV03CoordinateSystem = new LV03CoordinateSystem() export const WGS84: WGS84CoordinateSystem = new WGS84CoordinateSystem() export const WEBMERCATOR: WebMercatorCoordinateSystem = new WebMercatorCoordinateSystem() -export * from '@/proj/CoordinatesChunk' +export type * from '@/proj/types' /** Representation of many (available in this app) projection systems */ export const allCoordinateSystems: CoordinateSystem[] = [LV95, LV03, WGS84, WEBMERCATOR] -const constants = { +interface GeoadminCoordinateConstants { + STANDARD_ZOOM_LEVEL_1_25000_MAP: number + SWISS_ZOOM_LEVEL_1_25000_MAP: number + LV95_RESOLUTIONS: number[] + SWISSTOPO_TILEGRID_RESOLUTIONS: number[] +} + +const constants: GeoadminCoordinateConstants = { STANDARD_ZOOM_LEVEL_1_25000_MAP, SWISS_ZOOM_LEVEL_1_25000_MAP, LV95_RESOLUTIONS, SWISSTOPO_TILEGRID_RESOLUTIONS, } -const crs = { +export interface GeoadminCoordinateCRS { + LV95: LV95CoordinateSystem + LV03: LV03CoordinateSystem + WGS84: WGS84CoordinateSystem + WEBMERCATOR: WebMercatorCoordinateSystem + allCoordinateSystems: CoordinateSystem[] +} + +const crs: GeoadminCoordinateCRS = { LV95, LV03, WGS84, WEBMERCATOR, allCoordinateSystems, } -export { crs, constants, CoordinateSystem } +export { + crs, + constants, + CoordinateSystem, + CoordinateSystemBounds, + CustomCoordinateSystem, + StandardCoordinateSystem, + SwissCoordinateSystem, +} export default crs diff --git a/packages/coordinates/src/proj/types.ts b/packages/coordinates/src/proj/types.ts new file mode 100644 index 0000000000..d8f320d3de --- /dev/null +++ b/packages/coordinates/src/proj/types.ts @@ -0,0 +1,22 @@ +import type { SingleCoordinate } from '@/coordinatesUtils' + +/** + * Group of coordinates resulting in a "split by bounds" function. Will also contain information if + * this chunk is within or outside the bounds from which it was cut from. + */ +export interface CoordinatesChunk { + /** Coordinates of this chunk */ + coordinates: SingleCoordinate[] + /** Will be true if this chunk contains coordinates that are located within bounds */ + isWithinBounds: boolean +} + +/** Representation of a resolution step in a coordinate system. Can be linked to a zoom level or not. */ +export interface ResolutionStep { + /** Resolution of this step, in meters/pixel */ + resolution: number + /** Corresponding zoom level for this resolution step */ + zoom?: number + /** Name of the map product shown at this resolution/zoom */ + label?: string +} diff --git a/packages/geoadmin-coordinates/src/registerProj4.ts b/packages/coordinates/src/registerProj4.ts similarity index 88% rename from packages/geoadmin-coordinates/src/registerProj4.ts rename to packages/coordinates/src/registerProj4.ts index cc1bf93f6e..afa201d8e4 100644 --- a/packages/geoadmin-coordinates/src/registerProj4.ts +++ b/packages/coordinates/src/registerProj4.ts @@ -1,4 +1,4 @@ -import log from '@geoadmin/log' +import log from '@swissgeo/log' import type CoordinateSystem from '@/proj/CoordinateSystem' @@ -25,8 +25,9 @@ const registerProj4 = ( try { proj4.defs(projection.epsg, projection.proj4transformationMatrix) } catch (err) { - log.error('Error while setting up projection in proj4', projection.epsg, err) - throw err + const error = err ? (err as Error) : new Error('Unknown error') + log.error('Error while setting up projection in proj4', projection.epsg, error) + throw error } }) } diff --git a/packages/coordinates/tsconfig.json b/packages/coordinates/tsconfig.json new file mode 100644 index 0000000000..07bf8ffe25 --- /dev/null +++ b/packages/coordinates/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@swissgeo/typescript-config/tsconfig.base.json", + "include": [ + "**/*.ts" + ], + "exclude": [ + "eslint.config.mts" + ] +} diff --git a/packages/geoadmin-coordinates/vite.config.js b/packages/coordinates/vite.config.ts similarity index 64% rename from packages/geoadmin-coordinates/vite.config.js rename to packages/coordinates/vite.config.ts index 77cf08bece..fc04c78e11 100644 --- a/packages/geoadmin-coordinates/vite.config.js +++ b/packages/coordinates/vite.config.ts @@ -1,12 +1,13 @@ import { resolve } from 'path' +import dts from 'unplugin-dts/vite' import { fileURLToPath, URL } from 'url' -import dts from 'vite-plugin-dts' +import type { UserConfig } from 'vite' -export default { +const config: UserConfig = { build: { lib: { entry: [resolve(__dirname, 'src/index.ts')], - name: '@geoadmin/coordinates', + name: '@swissgeo/coordinates', }, rollupOptions: { output: { @@ -19,8 +20,14 @@ export default { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, - plugins: [dts()], + plugins: [ + dts({ + bundleTypes: true, + }), + ], test: { setupFiles: ['setup-vitest.ts'], }, } + +export default config diff --git a/packages/geoadmin-elevation-profile/env.d.ts b/packages/elevation-profile/env.d.ts similarity index 100% rename from packages/geoadmin-elevation-profile/env.d.ts rename to packages/elevation-profile/env.d.ts diff --git a/packages/elevation-profile/eslint.config.mts b/packages/elevation-profile/eslint.config.mts new file mode 100644 index 0000000000..7d1eb59e8d --- /dev/null +++ b/packages/elevation-profile/eslint.config.mts @@ -0,0 +1,12 @@ +import defaultConfig from '@swissgeo/eslint-config' + +export default [ + ...defaultConfig, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + } +] diff --git a/packages/geoadmin-elevation-profile/index.html b/packages/elevation-profile/index.html similarity index 100% rename from packages/geoadmin-elevation-profile/index.html rename to packages/elevation-profile/index.html diff --git a/packages/geoadmin-elevation-profile/package.json b/packages/elevation-profile/package.json similarity index 72% rename from packages/geoadmin-elevation-profile/package.json rename to packages/elevation-profile/package.json index 253bffc1c1..ccda1c41d6 100644 --- a/packages/geoadmin-elevation-profile/package.json +++ b/packages/elevation-profile/package.json @@ -1,5 +1,5 @@ { - "name": "@geoadmin/elevation-profile", + "name": "@swissgeo/elevation-profile", "version": "0.0.1", "description": "Components to request and display an elevation profile over Switzerland", "type": "module", @@ -24,6 +24,10 @@ "build:dev:watch": "pnpm run build --watch --mode development", "build:int": "pnpm run build --mode integration", "build:prod": "pnpm run build --mode production", + "lint": "eslint --fix && pnpm run lint:styles", + "lint:no-fix": "eslint && pnpm run lint:styles:no-fix", + "lint:styles": "stylelint \"**/*.{vue,scss}\" --fix", + "lint:styles:no-fix": "stylelint \"**/*.{vue,scss}\"", "dev": "vite", "generate-types": "vue-tsc --declaration", "preview": "vite preview", @@ -36,10 +40,10 @@ "@fortawesome/free-regular-svg-icons": "catalog:", "@fortawesome/free-solid-svg-icons": "catalog:", "@fortawesome/vue-fontawesome": "catalog:", - "@geoadmin/coordinates": "workspace:*", - "@geoadmin/log": "workspace:*", - "@geoadmin/numbers": "workspace:*", - "@geoadmin/tooltip": "workspace:*", + "@swissgeo/coordinates": "workspace:*", + "@swissgeo/log": "workspace:*", + "@swissgeo/numbers": "workspace:*", + "@swissgeo/tooltip": "workspace:*", "@turf/turf": "catalog:", "axios": "catalog:", "chart.js": "catalog:", @@ -51,15 +55,25 @@ }, "devDependencies": { "@intlify/core-base": "catalog:", + "@microsoft/api-extractor": "catalog:", + "@swissgeo/eslint-config": "workspace:*", + "@swissgeo/stylelint-config": "workspace:*", + "@swissgeo/typescript-config": "workspace:*", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", + "@types/chai": "catalog:", "@types/jsdom": "catalog:", "@vitejs/plugin-vue": "catalog:", "@vue/tsconfig": "catalog:", "chai": "catalog:", + "eslint": "catalog:", + "stylelint": "catalog:", "tailwindcss": "catalog:", + "typescript": "catalog:", + "unplugin-dts": "catalog:", "vite": "catalog:", "vite-plugin-vue-devtools": "catalog:", + "vite-tsconfig-paths": "catalog:", "vitest": "catalog:", "vue-tsc": "catalog:" }, diff --git a/packages/geoadmin-elevation-profile/public/favicon.ico b/packages/elevation-profile/public/favicon.ico similarity index 100% rename from packages/geoadmin-elevation-profile/public/favicon.ico rename to packages/elevation-profile/public/favicon.ico diff --git a/packages/geoadmin-elevation-profile/src/DevApp.vue b/packages/elevation-profile/src/DevApp.vue similarity index 100% rename from packages/geoadmin-elevation-profile/src/DevApp.vue rename to packages/elevation-profile/src/DevApp.vue diff --git a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue b/packages/elevation-profile/src/GeoadminElevationProfile.vue similarity index 98% rename from packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue rename to packages/elevation-profile/src/GeoadminElevationProfile.vue index 9bf4e7a20e..871d2389ac 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue +++ b/packages/elevation-profile/src/GeoadminElevationProfile.vue @@ -6,9 +6,9 @@ import { LV95, type SingleCoordinate, WGS84, -} from '@geoadmin/coordinates' -import log from '@geoadmin/log' -import GeoadminTooltip from '@geoadmin/tooltip' +} from '@swissgeo/coordinates' +import log from '@swissgeo/log' +import GeoadminTooltip from '@swissgeo/tooltip' import { lineString, simplify as simplifyGeometry } from '@turf/turf' import proj4 from 'proj4' import { computed, onMounted, ref, watch } from 'vue' @@ -62,7 +62,7 @@ const hasData = computed(() => !!profileData.value?.metadata?.hasElevat const profileMetadata = computed(() => { if (!profileData.value) { - return undefined + return } return profileData.value.metadata }) diff --git a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue b/packages/elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue similarity index 89% rename from packages/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue rename to packages/elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue index 6d92f15978..760741e4de 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue +++ b/packages/elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue @@ -1,13 +1,14 @@