diff --git a/eslint.config.mjs b/eslint.config.mjs index 1ea1ac8e76..702ba746a0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -78,7 +78,18 @@ export default defineConfigWithVueTs( // on unused param from abstract function arguments rules: { 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + // as we are adding dispatcher reference in all our store action, but won't be using + // them directly in the action, we must ignore these unused variables too + argsIgnorePattern: '^(_|dispatcher)', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + }, + ], + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'error', }, }, { diff --git a/package.json b/package.json index bb015b3de3..2813f06611 100644 --- a/package.json +++ b/package.json @@ -38,9 +38,9 @@ "@types/geojson": "catalog:", "@types/jsdom": "catalog:", "@types/node": "catalog:", - "@types/proj4": "catalog:", "@vue/eslint-config-prettier": "catalog:", "@vue/eslint-config-typescript": "catalog:", + "@vue/language-core": "catalog:", "@vue/tsconfig": "catalog:", "eslint": "catalog:", "eslint-plugin-cypress": "catalog:", @@ -61,8 +61,8 @@ "stylelint-scss": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", + "unplugin-dts": "catalog:", "vite": "catalog:", - "vite-plugin-dts": "catalog:", "vitest": "catalog:", "vue-tsc": "catalog:" }, @@ -73,7 +73,8 @@ "pnpm": { "onlyBuiltDependencies": [ "cypress", - "sharp" + "sharp", + "vue-demi" ], "ignoredBuiltDependencies": [ "core-js", diff --git a/packages/geoadmin-coordinates/package.json b/packages/geoadmin-coordinates/package.json index 32af3e38a9..f2ca6effdb 100644 --- a/packages/geoadmin-coordinates/package.json +++ b/packages/geoadmin-coordinates/package.json @@ -15,10 +15,10 @@ ], "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", + "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", "dev": "vite", "generate-types": "vue-tsc --declaration", "preview": "vite preview", @@ -32,11 +32,17 @@ "lodash": "catalog:" }, "devDependencies": { + "@microsoft/api-extractor": "catalog:", "@turf/turf": "catalog:", + "@types/chai": "catalog:", "@types/lodash": "catalog:", - "chai": "catalog:" + "chai": "catalog:", + "unplugin-dts": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" }, "peerDependencies": { + "ol": "catalog:", "proj4": "catalog:" } } diff --git a/packages/geoadmin-coordinates/src/__test__/coordinatesUtils.spec.ts b/packages/geoadmin-coordinates/src/__test__/coordinatesUtils.spec.ts new file mode 100644 index 0000000000..2df3a7c4c1 --- /dev/null +++ b/packages/geoadmin-coordinates/src/__test__/coordinatesUtils.spec.ts @@ -0,0 +1,167 @@ +import { expect } from 'chai' +import { describe, 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/geoadmin-coordinates/src/__test__/extentUtils.spec.ts b/packages/geoadmin-coordinates/src/__test__/extentUtils.spec.ts new file mode 100644 index 0000000000..cb59f40fe5 --- /dev/null +++ b/packages/geoadmin-coordinates/src/__test__/extentUtils.spec.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai' +import { describe, 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/__test__/utils.spec.js b/packages/geoadmin-coordinates/src/__test__/utils.spec.js deleted file mode 100644 index 86a4049877..0000000000 --- a/packages/geoadmin-coordinates/src/__test__/utils.spec.js +++ /dev/null @@ -1,195 +0,0 @@ -import { expect } from 'chai' -import { describe, it } from 'vitest' - -import { LV95, WEBMERCATOR, WGS84 } from '@/' -import { - removeZValues, - toRoundedString, - unwrapGeometryCoordinates, - wrapXCoordinates, -} from '@/utils' - -describe('Unit test functions from utils.ts', () => { - describe('toRoundedString', () => { - it('can handle wrong inputs', () => { - expect(toRoundedString(null)).to.be.null - expect(toRoundedString(undefined)).to.be.null - expect(toRoundedString([])).to.be.null - expect(toRoundedString(1)).to.be.null - expect(toRoundedString('')).to.be.null - expect(toRoundedString([null, null])).to.be.null - expect(toRoundedString([2, null])).to.be.null - expect(toRoundedString([2, ''])).to.be.null - expect(toRoundedString([2, 'three'])).to.be.null - expect(toRoundedString([2, Number.NaN])).to.be.null - expect(toRoundedString([2, Number.POSITIVE_INFINITY])).to.be.null - }) - it('rounds without decimal if 0 is given as digits', () => { - expect(toRoundedString([1.49, 2.49], 0)).to.eq( - '1, 2', - 'it should floor any number lower than .5' - ) - expect(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(toRoundedString([1.44, 2.44], 1)).to.eq('1.4, 2.4') - expect(toRoundedString([1.45, 2.45], 1)).to.eq('1.5, 2.5') - }) - it('correctly enforcers digits when asked for', () => { - expect(toRoundedString([1.44, 2.44], 5, false, true)).to.eq('1.44000, 2.44000') - expect(toRoundedString([1, 2], 3, false, true)).to.eq('1.000, 2.000') - expect(toRoundedString([1234.5678, 1234.5678], 6, true, true)).to.eq( - "1'234.567800, 1'234.567800" - ) - }) - }) - - describe('wrapXCoordinates()', () => { - it('can handle wrong inputs', () => { - expect(wrapXCoordinates(null, WGS84)).to.be.null - expect(wrapXCoordinates(undefined, WGS84)).to.be.undefined - expect(wrapXCoordinates([], WGS84)).to.be.an('Array').lengthOf(0) - expect(wrapXCoordinates([1], WGS84)) - .to.be.an('Array') - .lengthOf(1) - }) - it('can wrap a single coordinate', () => { - function testLowerWrap(projection) { - expect( - wrapXCoordinates( - [projection.bounds.lowerX - 1, projection.bounds.center[1]], - projection - ) - ).to.deep.equal([projection.bounds.upperX - 1, projection.bounds.center[1]]) - } - testLowerWrap(WGS84) - testLowerWrap(WEBMERCATOR) - - function testUpperWrap(projection) { - expect( - wrapXCoordinates( - [projection.bounds.upperX + 1, projection.bounds.center[1]], - projection - ) - ).to.deep.equal([projection.bounds.lowerX + 1, projection.bounds.center[1]]) - } - testUpperWrap(WGS84) - testUpperWrap(WEBMERCATOR) - }) - it('do not wrap if projection is not global (world-wide)', () => { - const justOffBoundCoordinate = [LV95.bounds.lowerX - 1, LV95.bounds.center[1]] - expect(wrapXCoordinates(justOffBoundCoordinate, LV95)).to.deep.equal( - justOffBoundCoordinate - ) - }) - it('can wrap every coordinates of an array of coordinates', () => { - function testMultipleWrap(projection) { - const lowOutOfBoundCoordinate = [ - projection.bounds.lowerX - 1, - projection.bounds.center[1], - ] - const inBoundCoordinate = [projection.bounds.lowerX, projection.bounds.center[1]] - const inBoundCoordinate2 = [ - projection.bounds.center[0], - projection.bounds.center[1], - ] - const inBoundCoordinate3 = [projection.bounds.upperX, projection.bounds.center[1]] - const upOutOfBoundCoordinate = [ - projection.bounds.upperX + 1, - projection.bounds.center[1], - ] - const original = [ - lowOutOfBoundCoordinate, - inBoundCoordinate, - inBoundCoordinate2, - inBoundCoordinate3, - upOutOfBoundCoordinate, - ] - const result = wrapXCoordinates(original, projection) - expect(result).to.be.an('Array').lengthOf(original.length) - const [first, second, third, fourth, fifth] = result - expect(first).to.deep.equal([ - projection.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([ - projection.bounds.lowerX + 1, - upOutOfBoundCoordinate[1], - ]) - } - testMultipleWrap(WGS84) - testMultipleWrap(WEBMERCATOR) - }) - }) - - describe('unwrapGeometryCoordinates(coordinates)', () => { - it('returns the input if nothing is required', () => { - expect(unwrapGeometryCoordinates(null)).to.be.null - expect(unwrapGeometryCoordinates(undefined)).to.be.undefined - expect(unwrapGeometryCoordinates([])).to.be.an('Array').lengthOf(0) - const alreadyUnwrappedCoordinates = [ - [1, 2], - [3, 4], - [5, 6], - ] - expect(unwrapGeometryCoordinates(alreadyUnwrappedCoordinates)).to.eql( - alreadyUnwrappedCoordinates - ) - }) - it('unwraps when required', () => { - const expectedOutcome = [ - [1, 2], - [3, 4], - [5, 6], - ] - const wrappedCoordinates = [expectedOutcome] - expect(unwrapGeometryCoordinates(wrappedCoordinates)).to.eql(expectedOutcome) - }) - }) - - describe('removeZValues', () => { - it('raises an error the input if invalid', () => { - expect(() => removeZValues(null)).to.throw( - 'Invalid coordinates received, cannot remove Z values' - ) - expect(() => removeZValues(undefined)).to.throw( - 'Invalid coordinates received, cannot remove Z values' - ) - }) - it('returns the input if an empty array is given', () => { - expect(removeZValues([])).to.eql([]) - }) - it('returns coordinate untouched if they have no Z values', () => { - const coordinates = [ - [1, 2], - [3, 4], - [5, 6], - ] - expect(removeZValues(coordinates)).to.eql(coordinates) - }) - it('removes Z values when needed', () => { - const coordinateWithoutZValues = [ - [1, 2], - [3, 4], - [5, 6], - ] - expect( - removeZValues( - coordinateWithoutZValues.map((coordinate) => [ - coordinate[0], - coordinate[1], - Math.floor(1 + 10 * Math.random()), - ]) - ) - ).to.eql(coordinateWithoutZValues) - // testing with only one coordinate - expect(removeZValues([[1, 2, 3]])).to.eql([[1, 2]]) - }) - }) -}) diff --git a/packages/geoadmin-coordinates/src/utils.ts b/packages/geoadmin-coordinates/src/coordinatesUtils.ts similarity index 80% rename from packages/geoadmin-coordinates/src/utils.ts rename to packages/geoadmin-coordinates/src/coordinatesUtils.ts index aa5818c54b..2100f5aa01 100644 --- a/packages/geoadmin-coordinates/src/utils.ts +++ b/packages/geoadmin-coordinates/src/coordinatesUtils.ts @@ -1,10 +1,11 @@ import { formatThousand, isNumber, round } from '@geoadmin/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/geoadmin-coordinates/src/extentUtils.ts b/packages/geoadmin-coordinates/src/extentUtils.ts new file mode 100644 index 0000000000..0c24550436 --- /dev/null +++ b/packages/geoadmin-coordinates/src/extentUtils.ts @@ -0,0 +1,192 @@ +import { round } from '@geoadmin/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/geoadmin-coordinates/src/index.ts b/packages/geoadmin-coordinates/src/index.ts index f88919e4d6..f54ea758dd 100644 --- a/packages/geoadmin-coordinates/src/index.ts +++ b/packages/geoadmin-coordinates/src/index.ts @@ -2,16 +2,24 @@ import proj4 from 'proj4' -import crs from '@/proj' +import { coordinatesUtils, type GeoadminCoordinatesUtils } from '@/coordinatesUtils' +import { extentUtils, type GeoadminExtentUtils } from '@/extentUtils' +import crs, { type GeoadminCoordinateCRS } from '@/proj' import registerProj4 from '@/registerProj4' -import { coordinates as utils } from '@/utils' export * from '@/proj' export * from '@/registerProj4' -export * from '@/utils' +export * from '@/coordinatesUtils' +export * from '@/extentUtils' // registering local instance of proj4, needed for some @geoadmin/coordinates functions registerProj4(proj4) -const coordinates = { ...crs, utils, registerProj4 } +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/geoadmin-coordinates/src/proj/CoordinateSystem.ts index 4433b5509d..de1108bd79 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinateSystem.ts +++ b/packages/geoadmin-coordinates/src/proj/CoordinateSystem.ts @@ -2,7 +2,7 @@ import { round } from '@geoadmin/numbers' import { earthRadius } from '@turf/turf' import proj4 from 'proj4' -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils.ts' import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' @@ -174,10 +174,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 +196,7 @@ export default abstract class CoordinateSystem { customCenter, }) } - return null + return } /** diff --git a/packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts b/packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts index 8ea75f4627..cdda0461bd 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinateSystemBounds.ts +++ b/packages/geoadmin-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 @@ -115,19 +115,19 @@ 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]))) { diff --git a/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts b/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts index 84a50e5690..9bd2e5f763 100644 --- a/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts +++ b/packages/geoadmin-coordinates/src/proj/CoordinatesChunk.ts @@ -1,4 +1,4 @@ -import type { SingleCoordinate } from '@/utils' +import type { SingleCoordinate } from '@/coordinatesUtils.ts' /** * 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/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts index 66857ff3d8..88349fc043 100644 --- a/packages/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts +++ b/packages/geoadmin-coordinates/src/proj/CustomCoordinateSystem.ts @@ -1,5 +1,5 @@ +import type { SingleCoordinate } from '@/coordinatesUtils.ts' import type CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' -import type { SingleCoordinate } from '@/utils' import CoordinateSystem, { type CoordinateSystemProps } from '@/proj/CoordinateSystem' diff --git a/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts b/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts index 88611d0424..d9a8cca937 100644 --- a/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts +++ b/packages/geoadmin-coordinates/src/proj/WGS84CoordinateSystem.ts @@ -1,6 +1,6 @@ import { round } from '@geoadmin/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/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts index 6bc5454051..c3e4ecb346 100644 --- a/packages/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts +++ b/packages/geoadmin-coordinates/src/proj/WebMercatorCoordinateSystem.ts @@ -1,7 +1,7 @@ import { round } from '@geoadmin/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' diff --git a/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.class.spec.js b/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.spec.ts similarity index 62% rename from packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.class.spec.js rename to packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.spec.ts index 6e52fba31f..e40fc2e79a 100644 --- a/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.class.spec.js +++ b/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystem.spec.ts @@ -7,16 +7,21 @@ import StandardCoordinateSystem from '@/proj/StandardCoordinateSystem' class BoundlessCoordinateSystem extends StandardCoordinateSystem { constructor() { super({ + usesMercatorPyramid: false, proj4transformationMatrix: 'test', label: 'test', epsgNumber: 1234, }) } - getResolutionForZoomAndCenter() { + getResolutionForZoomAndCenter(): number { return 0 } - getZoomForResolutionAndCenter() { + getZoomForResolutionAndCenter(): number { + return 0 + } + + roundCoordinateValue(): number { return 0 } } @@ -24,28 +29,28 @@ class BoundlessCoordinateSystem extends StandardCoordinateSystem { describe('CoordinateSystem', () => { const coordinateSystemWithouBounds = new BoundlessCoordinateSystem() describe('getBoundsAs', () => { - it('returns null if the bounds are not defined', () => { - expect(coordinateSystemWithouBounds.getBoundsAs(WEBMERCATOR)).to.be.null + 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) + 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) + 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', () => { @@ -53,6 +58,6 @@ describe('CoordinateSystem', () => { expect(coordinateSystemWithouBounds.isInBounds(0, 0)).to.be.false expect(coordinateSystemWithouBounds.isInBounds(1, 1)).to.be.false }) - // remaining test for this function are handled in the CoordinateSystemBounds.class.spec.js file + // the remaining tests for this function are handled in the CoordinateSystemBounds.spec.ts file }) }) diff --git a/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.class.spec.js b/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts similarity index 89% rename from packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.class.spec.js rename to packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts index b396b0b579..1483cac125 100644 --- a/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.class.spec.js +++ b/packages/geoadmin-coordinates/src/proj/__test__/CoordinateSystemBounds.spec.ts @@ -1,27 +1,21 @@ -import { LV95 } from '@' import { expect } from 'chai' import { beforeEach, describe, it } from 'vitest' -import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds.js' +import type { SingleCoordinate } from '@/coordinatesUtils' + +import { LV95 } from '@/proj' +import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds' describe('CoordinateSystemBounds', () => { describe('splitIfOutOfBounds(coordinates, bounds)', () => { - let bounds + let bounds: CoordinateSystemBounds beforeEach(() => { bounds = new CoordinateSystemBounds({ lowerX: 0, upperX: 100, lowerY: 50, upperY: 100 }) }) - it('returns null if invalid/malformed coordinates are given', () => { - expect(bounds.splitIfOutOfBounds(null)).to.be.null - expect(bounds.splitIfOutOfBounds(1)).to.be.null - expect(bounds.splitIfOutOfBounds('test')).to.be.null - expect(bounds.splitIfOutOfBounds([1, 2])).to.be.null - expect(bounds.splitIfOutOfBounds([[3]])).to.be.null - expect(bounds.splitIfOutOfBounds([[1, 2, 3]])).to.be.null - }) it('returns a single CoordinatesChunk if no split is needed', () => { - const coordinatesWithinBounds = [ + const coordinatesWithinBounds: SingleCoordinate[] = [ [bounds.lowerX + 1, bounds.upperY - 1], [bounds.lowerX + 2, bounds.upperY - 2], [bounds.lowerX + 3, bounds.upperY - 3], @@ -32,7 +26,7 @@ describe('CoordinateSystemBounds', () => { ] const result = bounds.splitIfOutOfBounds(coordinatesWithinBounds) expect(result).to.be.an('Array').of.length(1) - const [singleChunk] = result + const [singleChunk] = result! expect(singleChunk).to.be.an('Object').that.has.ownProperty('coordinates') expect(singleChunk).to.haveOwnProperty('isWithinBounds') expect(singleChunk.isWithinBounds).to.be.true @@ -40,7 +34,7 @@ describe('CoordinateSystemBounds', () => { }) it('splits the given coordinates in two chunks if part of it is outside bounds', () => { const yValue = 50 - const coordinatesOverlappingBounds = [ + 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 @@ -50,7 +44,7 @@ describe('CoordinateSystemBounds', () => { ] const result = bounds.splitIfOutOfBounds(coordinatesOverlappingBounds) expect(result).to.be.an('Array').of.length(2) - const [outOfBoundChunk, inBoundChunk] = result + 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) @@ -75,15 +69,15 @@ describe('CoordinateSystemBounds', () => { 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 = [ - [bounds.lowerX - 1, yValue], - [bounds.lowerX + 1, yValue], - [50, yValue], - [bounds.upperX - 1, yValue], + 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 + const [inBoundChunk, outOfBoundChunk] = result! // first chunk must now be the in bound one expect(inBoundChunk).to.haveOwnProperty('isWithinBounds') @@ -99,7 +93,7 @@ describe('CoordinateSystemBounds', () => { expect(outOfBoundChunk.coordinates[0]).to.eql([bounds.lowerX, yValue]) }) it('handles properly a line going multiple times out of bounds', () => { - const coordinatesGoingBackAndForth = [ + const coordinatesGoingBackAndForth: SingleCoordinate[] = [ [-1, 51], // outside [1, 51], // inside going in the X direction [1, 101], // outside going in the Y direction @@ -107,13 +101,13 @@ describe('CoordinateSystemBounds', () => { [99, 99], // inside going both directions [1, 51], // inside moving on the other side of the bounds ] - const expectedFirstIntersection = [bounds.lowerX, 51] - const expectedSecondIntersection = [1, bounds.upperY] - const expectedThirdIntersection = [bounds.upperX, bounds.upperY] + 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 + 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) @@ -141,16 +135,16 @@ describe('CoordinateSystemBounds', () => { 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 = [ + const coordinatesGoingThrough: SingleCoordinate[] = [ [-1, 50], // outside [101, 50], // outside ] - const expectedFirstIntersection = [bounds.lowerX, 50] - const expectedSecondIntersection = [bounds.upperX, 50] + 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 + const [firstChunk, secondChunk, thirdChunk] = result! expect(firstChunk.isWithinBounds).to.be.false expect(firstChunk.coordinates).to.be.an('Array').of.length(2) @@ -168,13 +162,13 @@ describe('CoordinateSystemBounds', () => { expect(thirdChunk.coordinates[1]).to.eql(coordinatesGoingThrough[1]) }) it('handles some "real" use case well', () => { - const sample1 = [ + 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 + const [firstChunk, secondChunk, thirdChunk] = result! expect(firstChunk.isWithinBounds).to.be.false expect(firstChunk.coordinates).to.be.an('Array').of.length(2) @@ -197,7 +191,7 @@ describe('CoordinateSystemBounds', () => { const reversedResult = LV95.bounds.splitIfOutOfBounds(sample1.toReversed()) expect(reversedResult).to.be.an('Array').of.length(3) - const [firstReversedChunk, secondReversedChunk, thirdReversedChunk] = reversedResult + const [firstReversedChunk, secondReversedChunk, thirdReversedChunk] = reversedResult! expect(firstReversedChunk.isWithinBounds).to.be.false expect(firstReversedChunk.coordinates).to.be.an('Array').of.length(2) diff --git a/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js b/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts similarity index 97% rename from packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js rename to packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts index ca388c69fa..46fd9f8c5c 100644 --- a/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.class.spec.js +++ b/packages/geoadmin-coordinates/src/proj/__test__/SwissCoordinateSystem.spec.ts @@ -38,10 +38,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 ) } ) @@ -83,11 +83,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` ) } diff --git a/packages/geoadmin-coordinates/src/proj/index.ts b/packages/geoadmin-coordinates/src/proj/index.ts index fa8df52442..38f85fbfa9 100644 --- a/packages/geoadmin-coordinates/src/proj/index.ts +++ b/packages/geoadmin-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/CoordinatesChunk' /** 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/geoadmin-coordinates/vite.config.js b/packages/geoadmin-coordinates/vite.config.js index 77cf08bece..9b2a112317 100644 --- a/packages/geoadmin-coordinates/vite.config.js +++ b/packages/geoadmin-coordinates/vite.config.js @@ -1,6 +1,6 @@ import { resolve } from 'path' +import dts from 'unplugin-dts/vite' import { fileURLToPath, URL } from 'url' -import dts from 'vite-plugin-dts' export default { build: { @@ -19,7 +19,11 @@ export default { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, - plugins: [dts()], + plugins: [ + dts({ + bundleTypes: true, + }), + ], test: { setupFiles: ['setup-vitest.ts'], }, diff --git a/packages/geoadmin-elevation-profile/package.json b/packages/geoadmin-elevation-profile/package.json index 253bffc1c1..0732ee7326 100644 --- a/packages/geoadmin-elevation-profile/package.json +++ b/packages/geoadmin-elevation-profile/package.json @@ -51,13 +51,16 @@ }, "devDependencies": { "@intlify/core-base": "catalog:", + "@microsoft/api-extractor": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", + "@types/chai": "catalog:", "@types/jsdom": "catalog:", "@vitejs/plugin-vue": "catalog:", "@vue/tsconfig": "catalog:", "chai": "catalog:", "tailwindcss": "catalog:", + "unplugin-dts": "catalog:", "vite": "catalog:", "vite-plugin-vue-devtools": "catalog:", "vitest": "catalog:", diff --git a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue index 9bf4e7a20e..edc975e098 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.vue +++ b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfile.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/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue index 6d92f15978..99fe0ee333 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue +++ b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileCesiumBridge.vue @@ -26,7 +26,7 @@ const coordinate = computed(() => { if (getPointBeingHovered) { return getPointBeingHovered()?.coordinate } - return undefined + return }) const trackingPointPosition: Cartesian3 = new Cartesian3() diff --git a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileOpenLayersBridge.vue b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileOpenLayersBridge.vue index 7a1b90f64b..ad21b6d1ce 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileOpenLayersBridge.vue +++ b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfileOpenLayersBridge.vue @@ -17,7 +17,7 @@ const coordinate = computed(() => { if (getPointBeingHovered) { return getPointBeingHovered()?.coordinate } - return undefined + return }) const overlayAdded = ref(false) diff --git a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfilePlot.vue b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfilePlot.vue index 3ae4a83c6e..eaf6b4e672 100644 --- a/packages/geoadmin-elevation-profile/src/GeoadminElevationProfilePlot.vue +++ b/packages/geoadmin-elevation-profile/src/GeoadminElevationProfilePlot.vue @@ -196,7 +196,7 @@ const chartJsScalesConfiguration: ComputedRef< { [key: string]: ScaleOptions<'linear'> } | undefined > = computed(() => { if (!profileMetadata.value) { - return undefined + return } const scales: { [key: string]: ScaleOptions<'linear'> } = { x: { @@ -276,7 +276,7 @@ const chartJsTooltipConfiguration = computed(() => { /** Configuration for the pinch/zoom function */ const chartJsZoomOptions: ComputedRef = computed(() => { if (!profileMetadata.value) { - return undefined + return } const zoomOptions: ZoomPluginOptions = { limits: { @@ -327,7 +327,7 @@ const chartJsOptions: ComputedRef | undefined> = computed(( !chartJsZoomOptions.value || !profile ) { - return undefined + return } const options: ChartOptions<'line'> = { animation: { diff --git a/packages/geoadmin-elevation-profile/src/profile.api.ts b/packages/geoadmin-elevation-profile/src/profile.api.ts index 42aa425eaf..3e5dc1557b 100644 --- a/packages/geoadmin-elevation-profile/src/profile.api.ts +++ b/packages/geoadmin-elevation-profile/src/profile.api.ts @@ -1,6 +1,6 @@ import type { CoordinatesChunk, CoordinateSystem, SingleCoordinate } from '@geoadmin/coordinates' -import { LV95, removeZValues } from '@geoadmin/coordinates' +import { LV95, coordinatesUtils } from '@geoadmin/coordinates' import log from '@geoadmin/log' import axios from 'axios' import proj4 from 'proj4' @@ -57,9 +57,9 @@ export interface ElevationProfile { */ const MAX_REQUEST_POINT_LENGTH: number = 3000 -export function splitIfTooManyPoints(chunk: CoordinatesChunk): CoordinatesChunk[] | null { +export function splitIfTooManyPoints(chunk: CoordinatesChunk): CoordinatesChunk[] | undefined { if (!chunk) { - return null + return } if (chunk.coordinates.length <= MAX_REQUEST_POINT_LENGTH) { return [chunk] @@ -120,8 +120,8 @@ export async function getProfileDataForChunk( 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[] | null = splitIfTooManyPoints(chunk) - if (coordinatesToRequest === null) { + const coordinatesToRequest: CoordinatesChunk[] | undefined = splitIfTooManyPoints(chunk) + if (!coordinatesToRequest) { return [] } @@ -252,7 +252,7 @@ function sanitizeCoordinates( // 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) => removeZValues(coordinates)) + .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 @@ -290,7 +290,7 @@ export default async ( 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[] | null = + const coordinateChunks: CoordinatesChunk[] | undefined = LV95.bounds.splitIfOutOfBounds(coordinates) if (!coordinateChunks) { diff --git a/packages/geoadmin-elevation-profile/vite.config.js b/packages/geoadmin-elevation-profile/vite.config.js index dcf7118baf..2710d96ccc 100644 --- a/packages/geoadmin-elevation-profile/vite.config.js +++ b/packages/geoadmin-elevation-profile/vite.config.js @@ -1,11 +1,10 @@ import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' +import dts from 'unplugin-dts/vite' import { fileURLToPath, URL } from 'url' -import dts from 'vite-plugin-dts' import vueDevTools from 'vite-plugin-vue-devtools' - export default { build: { lib: { @@ -18,7 +17,7 @@ export default { exports: 'named', globals: { vue: 'Vue', - } + }, }, }, }, @@ -31,6 +30,9 @@ export default { tailwindcss(), vue(), vueDevTools(), - dts(), + dts({ + bundleTypes: true, + processor: 'vue', + }), ], } diff --git a/packages/geoadmin-layers/package.json b/packages/geoadmin-layers/package.json new file mode 100644 index 0000000000..cab5fccdeb --- /dev/null +++ b/packages/geoadmin-layers/package.json @@ -0,0 +1,80 @@ +{ + "name": "@geoadmin/layers", + "version": "0.0.1", + "description": "Layers definition for geoadmin", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.umd.cjs" + }, + "./api": { + "types": "./dist/api/index.d.ts", + "import": "./dist/api.js", + "require": "./dist/api.cjs" + }, + "./utils": { + "types": "./dist/utils/index.d.ts", + "import": "./dist/utils.js", + "require": "./dist/utils.cjs" + }, + "./parsers": { + "types": "./dist/parsers/index.d.ts", + "import": "./dist/parsers.js", + "require": "./dist/parsers.cjs" + }, + "./vue": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/vue.js", + "require": "./dist/vue.cjs" + }, + "./validation": { + "types": "./dist/validation/index.d.ts", + "import": "./dist/validation.js", + "require": "./dist/validation.cjs" + } + }, + "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", + "dev": "vite", + "generate-types": "vue-tsc --declaration", + "test:unit": "vitest --run --mode development --environment jsdom", + "test:unit:watch": "vitest --mode development --environment jsdom", + "type-check": "vue-tsc -p tsconfig.json" + }, + "dependencies": { + "@geoadmin/coordinates": "workspace:*", + "@geoadmin/log": "workspace:*", + "@geoadmin/numbers": "workspace:^", + "axios": "catalog:", + "lodash": "catalog:", + "luxon": "catalog:", + "ol": "catalog:", + "proj4": "catalog:", + "uuid": "catalog:" + }, + "peerDependencies": { + "vue": "catalog:" + }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "@types/chai": "catalog:", + "@types/lodash": "catalog:", + "@types/luxon": "catalog:", + "@types/openlayers": "catalog:", + "chai": "catalog:", + "unplugin-dts": "catalog:", + "vite": "catalog:", + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:", + "vue-tsc": "catalog:" + } +} diff --git a/packages/geoadmin-layers/src/api/__test__/internal.spec.ts b/packages/geoadmin-layers/src/api/__test__/internal.spec.ts new file mode 100644 index 0000000000..f4d2e9d8ab --- /dev/null +++ b/packages/geoadmin-layers/src/api/__test__/internal.spec.ts @@ -0,0 +1,263 @@ +import { assertType, describe, expect, it } from 'vitest' + +import { generateClassForLayerConfig } from '@/api' +import { + type GeoAdminAggregateLayer, + type GeoAdminGeoJSONLayer, + type GeoAdminLayer, + type GeoAdminWMSLayer, + type GeoAdminWMTSLayer, + YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA, +} from '@/types' + +import rawLayerConfig from './rawLayerConfig.json' + +function parseLayer(layerId: string): GeoAdminLayer | undefined { + return generateClassForLayerConfig( + // @ts-expect-error no idea why TS complains here... + rawLayerConfig[layerId] as Record, + layerId, + rawLayerConfig, + 'en', + 'production' + ) +} + +describe('Test layer config parsing', () => { + describe('WMS', () => { + it('parses a WMS layer defining a gutter correctly', () => { + const layerId = 'ch.vbs.kataster-belasteter-standorte-militaer' + const layer = parseLayer(layerId) + + // @ts-expect-error raising type from GeoAdminLayer to GeoAdminWMSLayer + assertType(layer) + const wmsLayer = layer as GeoAdminWMSLayer + assertType(wmsLayer.gutter) + + expect(wmsLayer.id).to.eq(layerId) + expect(wmsLayer.gutter).to.eq(15) + + expect(wmsLayer.hasTooltip).to.be.true + expect(wmsLayer.name).to.eq('CCS military') + expect(wmsLayer.isExternal).to.be.false + expect(wmsLayer.hasLegend).to.be.true + expect(wmsLayer.isHighlightable).to.be.true + expect(wmsLayer.searchable).to.be.true + expect(wmsLayer.timeConfig).to.be.undefined + expect(wmsLayer.opacity).to.eq(0.75) + }) + it('parses a WMS with multiple year-timestamps correctly', () => { + const layerId = 'ch.bafu.gewaesserschutz-chemischer_zustand_doc' + const layer = parseLayer(layerId) + + // @ts-expect-error raising type from GeoAdminLayer to GeoAdminWMSLayer + assertType(layer) + const wmsLayer = layer as GeoAdminWMSLayer + + expect(wmsLayer.timeConfig).to.not.be.undefined + expect(wmsLayer.timeConfig?.timeEntries).to.toHaveLength(13) + expect( + wmsLayer.timeConfig?.timeEntries.map((entry) => + parseInt(entry.timestamp.substring(0, 4)) + ) + ).to.eql([2023, 2022, 2021, 2020, 2019, 2018, 2017, 2016, 2015, 2014, 2013, 2012, 2011]) + }) + }) + describe('WMTS', () => { + it('parses a WMTS with full-date timestamp correctly', () => { + const layerId = 'ch.swisstopo.lubis-luftbilder-dritte-firmen' + const layer = parseLayer(layerId) + + // @ts-expect-error raising type from GeoAdminLayer to GeoAdminWMTSLayer + assertType(layer) + const wmtsLayer = layer as GeoAdminWMTSLayer + + expect(wmtsLayer.timeConfig).to.not.be.undefined + expect(wmtsLayer.timeConfig?.timeEntries).to.toHaveLength(92) + expect( + wmtsLayer.timeConfig?.timeEntries.map((entry) => + parseInt(entry.timestamp.substring(0, 4)) + ) + ).to.eql([ + YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA, + 2016, + 2011, + 2010, + 2009, + 2008, + 2007, + 2006, + 2005, + 2004, + 2003, + 2002, + 2001, + 2000, + 1999, + 1998, + 1997, + 1996, + 1995, + 1994, + 1993, + 1992, + 1991, + 1990, + 1989, + 1988, + 1987, + 1986, + 1985, + 1984, + 1983, + 1982, + 1981, + 1980, + 1979, + 1978, + 1977, + 1976, + 1975, + 1974, + 1973, + 1972, + 1971, + 1970, + 1969, + 1968, + 1967, + 1966, + 1965, + 1964, + 1963, + 1962, + 1961, + 1960, + 1959, + 1958, + 1957, + 1956, + 1955, + 1954, + 1953, + 1952, + 1951, + 1950, + 1949, + 1948, + 1947, + 1946, + 1945, + 1943, + 1939, + 1938, + 1937, + 1936, + 1935, + 1934, + 1933, + 1932, + 1931, + 1930, + 1929, + 1928, + 1927, + 1926, + 1925, + 1924, + 1923, + 1922, + 1921, + 1920, + 1919, + 1918, + ]) + }) + it('parses a WMTS with year only timestamp correctly', () => { + const layerId = 'ch.swisstopo.lubis-terrestrische_aufnahmen' + const layer = parseLayer(layerId) + + // @ts-expect-error raising type from GeoAdminLayer to GeoAdminWMTSLayer + assertType(layer) + const wmtsLayer = layer as GeoAdminWMTSLayer + + expect(wmtsLayer.timeConfig).to.not.be.undefined + expect(wmtsLayer.timeConfig?.timeEntries).to.toHaveLength(34) + expect( + wmtsLayer.timeConfig?.timeEntries.map((entry) => parseInt(entry.timestamp)) + ).to.eql([ + YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA, + 1961, + 1947, + 1945, + 1944, + 1943, + 1942, + 1941, + 1940, + 1939, + 1938, + 1937, + 1936, + 1935, + 1934, + 1933, + 1932, + 1931, + 1930, + 1929, + 1928, + 1927, + 1926, + 1925, + 1924, + 1923, + 1922, + 1921, + 1920, + 1919, + 1918, + 1917, + 1916, + 1915, + ]) + }) + }) + describe('Aggregate', () => { + it('parses an aggregate layer and its sub-layers correctly', () => { + const layerId = 'ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung' + const layer = parseLayer(layerId) + + // @ts-expect-error raising type from GeoAdminLayer to GeoAdminAggregateLayer + assertType(layer) + const aggregateLayer = layer as GeoAdminAggregateLayer + + expect(aggregateLayer.subLayers).toHaveLength(2) + expect(aggregateLayer.subLayers[0].subLayerId).to.eq( + 'ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wmts' + ) + expect(aggregateLayer.subLayers[0].minResolution).to.eq(10) + expect(aggregateLayer.subLayers[1].subLayerId).to.eq( + 'ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wms' + ) + expect(aggregateLayer.subLayers[1].maxResolution).to.eq(10) + }) + }) + describe('GeoJSON', () => { + it('parses a GeoJSON layer correctly', () => { + const layerId = 'ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min' + const layer = parseLayer(layerId) + + // @ts-expect-error + assertType(layer) + const geoJsonLayer = layer as GeoAdminGeoJSONLayer + + expect(geoJsonLayer.geoJsonUrl).to.eq( + 'https://data.geo.admin.ch/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min_en.json' + ) + expect(geoJsonLayer.styleUrl).to.eq( + 'https://api3.geo.admin.ch/static/vectorStyles/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min.json' + ) + }) + }) +}) diff --git a/packages/geoadmin-layers/src/api/__test__/rawLayerConfig.json b/packages/geoadmin-layers/src/api/__test__/rawLayerConfig.json new file mode 100644 index 0000000000..141792db56 --- /dev/null +++ b/packages/geoadmin-layers/src/api/__test__/rawLayerConfig.json @@ -0,0 +1,327 @@ +{ + "ch.swisstopo.lubis-luftbilder-dritte-firmen": { + "type": "wmts", + "timeBehaviour": "last", + "tooltip": true, + "topics": "api,ech,geol,inspire,isos,luftbilder,service-wms", + "background": false, + "timestamps": [ + "99991231", + "20161231", + "20111231", + "20101231", + "20091231", + "20081231", + "20071231", + "20061231", + "20051231", + "20041231", + "20031231", + "20021231", + "20011231", + "20001231", + "19991231", + "19981231", + "19971231", + "19961231", + "19951231", + "19941231", + "19931231", + "19921231", + "19911231", + "19901231", + "19891231", + "19881231", + "19871231", + "19861231", + "19851231", + "19841231", + "19831231", + "19821231", + "19811231", + "19801231", + "19791231", + "19781231", + "19771231", + "19761231", + "19751231", + "19741231", + "19731231", + "19721231", + "19711231", + "19701231", + "19691231", + "19681231", + "19671231", + "19661231", + "19651231", + "19641231", + "19631231", + "19621231", + "19611231", + "19601231", + "19591231", + "19581231", + "19571231", + "19561231", + "19551231", + "19541231", + "19531231", + "19521231", + "19511231", + "19501231", + "19491231", + "19481231", + "19471231", + "19461231", + "19451231", + "19431231", + "19391231", + "19381231", + "19371231", + "19361231", + "19351231", + "19341231", + "19331231", + "19321231", + "19311231", + "19301231", + "19291231", + "19281231", + "19271231", + "19261231", + "19251231", + "19241231", + "19231231", + "19221231", + "19211231", + "19201231", + "19191231", + "19181231" + ], + "label": "Aerial images third parties", + "attribution": "swisstopo, prv.", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.swisstopo.lubis-luftbilder-dritte-firmen", + "highlightable": true, + "format": "png", + "timeEnabled": true, + "searchable": true, + "attributionUrl": "ch.swisstopo.private.url" + }, + "ch.swisstopo.lubis-terrestrische_aufnahmen": { + "type": "wmts", + "timeBehaviour": "last", + "tooltip": true, + "topics": "api,ech,inspire,luftbilder,schule,service-wms,swisstopo", + "background": false, + "timestamps": [ + "9999", + "1961", + "1947", + "1945", + "1944", + "1943", + "1942", + "1941", + "1940", + "1939", + "1938", + "1937", + "1936", + "1935", + "1934", + "1933", + "1932", + "1931", + "1930", + "1929", + "1928", + "1927", + "1926", + "1925", + "1924", + "1923", + "1922", + "1921", + "1920", + "1919", + "1918", + "1917", + "1916", + "1915" + ], + "label": "Terrestrial images swisstopo", + "attribution": "swisstopo", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.swisstopo.lubis-terrestrische_aufnahmen", + "highlightable": true, + "format": "png", + "timeEnabled": true, + "searchable": true, + "attributionUrl": "https://www.swisstopo.admin.ch/en/home.html" + }, + "ch.swisstopo.pixelkarte-farbe": { + "type": "wmts", + "tooltip": false, + "topics": "api,service-wms,swissmaponline", + "resolutions": [ + 4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, 1500, 1250, 1000, 750, 650, + 500, 250, 100, 50, 20, 10, 5, 2.5, 2, 1.5, 1, 0.5, 0.25 + ], + "background": true, + "config3d": "ch.swisstopo.swisstlm3d-karte-farbe_3d", + "timestamps": ["current"], + "label": "National Maps (color)", + "attribution": "swisstopo", + "chargeable": true, + "hasLegend": false, + "serverLayerName": "ch.swisstopo.pixelkarte-farbe", + "highlightable": true, + "format": "jpeg", + "timeEnabled": false, + "searchable": false, + "attributionUrl": "https://www.swisstopo.admin.ch/en/home.html" + }, + "ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min": { + "type": "geojson", + "tooltip": false, + "topics": "api,ech,inspire,meteoschweiz,schule", + "background": false, + "label": "Humidity, 10 min", + "updateDelay": 300000, + "attribution": "MeteoSwiss", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min", + "highlightable": false, + "timeEnabled": false, + "searchable": false, + "styleUrl": "//api3.geo.admin.ch/static/vectorStyles/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min.json", + "geojsonUrl": "https://data.geo.admin.ch/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min_en.json", + "attributionUrl": "ch.meteoschweiz.url" + }, + "ch.bafu.gewaesserschutz-chemischer_zustand_doc": { + "type": "wms", + "timeBehaviour": "all", + "tooltip": true, + "topics": "api,bafu,ech,gewiss,inspire,schule,service-wms", + "background": false, + "wmsLayers": "ch.bafu.gewaesserschutz-chemischer_zustand_doc", + "timestamps": [ + "2023", + "2022", + "2021", + "2020", + "2019", + "2018", + "2017", + "2016", + "2015", + "2014", + "2013", + "2012", + "2011" + ], + "label": "Dissolved Organic Carbon (DOC)", + "attribution": "FOEN", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.bafu.gewaesserschutz-chemischer_zustand_doc", + "highlightable": true, + "format": "png", + "singleTile": true, + "timeEnabled": true, + "searchable": false, + "wmsUrl": "https://wms.geo.admin.ch", + "attributionUrl": "https://www.bafu.admin.ch/bafu/en/home.html" + }, + "ch.vbs.kataster-belasteter-standorte-militaer": { + "type": "wms", + "tooltip": true, + "topics": "api,ech,inspire,service-wms", + "background": false, + "wmsLayers": "ch.vbs.kataster-belasteter-standorte-militaer", + "label": "CCS military", + "attribution": "GS-DDPS", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.vbs.kataster-belasteter-standorte-militaer", + "highlightable": true, + "format": "png", + "singleTile": false, + "gutter": 15, + "opacity": 0.75, + "timeEnabled": false, + "searchable": true, + "wmsUrl": "https://wms.geo.admin.ch", + "attributionUrl": "https://www.vbs.admin.ch/en" + }, + "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung": { + "type": "aggregate", + "tooltip": true, + "topics": "api,bfs,ech,inspire,service-wms", + "background": false, + "label": "RBD: energy/heat source heating", + "attribution": "FSO", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "highlightable": true, + "subLayersIds": [ + "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wmts", + "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wms" + ], + "timeEnabled": false, + "searchable": true, + "attributionUrl": "https://www.bfs.admin.ch/bfs/en/home.html" + }, + "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wmts": { + "type": "wmts", + "tooltip": false, + "minResolution": 10, + "topics": "api,bfs,ech,inspire,service-wms", + "resolutions": [ + 4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, 1500, 1250, 1000, 750, 650, + 500, 250, 100, 50, 20, 10 + ], + "background": false, + "timestamps": ["current"], + "label": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wmts", + "parentLayerId": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "attribution": "FSO", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "highlightable": true, + "format": "png", + "timeEnabled": false, + "searchable": false, + "attributionUrl": "https://www.bfs.admin.ch/bfs/en/home.html" + }, + "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wms": { + "type": "wms", + "tooltip": false, + "minResolution": 0, + "topics": "api,bfs,ech,inspire,service-wms", + "background": false, + "maxResolution": 10, + "wmsLayers": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "label": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung_wms", + "parentLayerId": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "attribution": "FSO", + "chargeable": false, + "hasLegend": true, + "serverLayerName": "ch.bfs.gebaeude_wohnungs_register_waermequelle_heizung", + "highlightable": true, + "format": "png", + "singleTile": false, + "gutter": 20, + "timeEnabled": false, + "searchable": false, + "wmsUrl": "https://wms.geo.admin.ch", + "attributionUrl": "https://www.bfs.admin.ch/bfs/en/home.html" + } +} diff --git a/packages/geoadmin-layers/src/api/external.ts b/packages/geoadmin-layers/src/api/external.ts new file mode 100644 index 0000000000..64895e722e --- /dev/null +++ b/packages/geoadmin-layers/src/api/external.ts @@ -0,0 +1,183 @@ +import log from '@geoadmin/log' +import axios from 'axios' + +import externalWMSParser, { + type WMSCapabilitiesResponse, +} from '@/parsers/ExternalWMSCapabilitiesParser' +import externalWMTSParser, { + type WMTSCapabilitiesResponse, +} from '@/parsers/ExternalWMTSCapabilitiesParser' +import { CapabilitiesError } from '@/validation' + +/** Timeout for accessing external server in [ms] */ +export const EXTERNAL_SERVER_TIMEOUT = 30000 + +/** Sets the WMS GetCapabilities url parameters */ +export function setWmsGetCapabilitiesParams(url: URL, language?: string): URL { + // Manda: URLtory params + url.searchParams.set('SERVICE', 'WMS') + url.searchParams.set('REQUEST', 'GetCapabilities') + // Currently openlayers only supports version 1.3.0 ! + url.searchParams.set('VERSION', '1.3.0') + // Optional params + url.searchParams.set('FORMAT', 'text/xml') + if (language) { + url.searchParams.set('lang', language) + } + return url +} + +/** Sets the WMS GetMap url parameters */ +export function setWmsGetMapParams(url: URL, layer: string, crs: string, style: string): URL { + // Mandatory params + url.searchParams.set('SERVICE', 'WMS') + url.searchParams.set('REQUEST', 'GetMap') + url.searchParams.set('VERSION', '1.1.0') + url.searchParams.set('LAYERS', layer) + url.searchParams.set('STYLES', style) + url.searchParams.set('SRS', crs) + url.searchParams.set('BBOX', '10.0,10.0,10.0001,10.0001') + url.searchParams.set('WIDTH', '1') + url.searchParams.set('HEIGHT', '1') + // Optional params + url.searchParams.set('FORMAT', 'image/png') + return url +} + +/** + * Read and parse WMS GetCapabilities + * + * @param baseUrl Base URL for the WMS server + * @param language Language parameter to use if the server support localization + */ +export async function readWmsCapabilities( + baseUrl: string, + language?: string +): Promise { + const url = setWmsGetCapabilitiesParams(new URL(baseUrl), language) + log.debug(`Read WMTS Get Capabilities: ${url.toString()}`) + let response = null + try { + response = await axios.get(url.toString(), { timeout: EXTERNAL_SERVER_TIMEOUT }) + } catch (error: any) { + throw new CapabilitiesError( + `Failed to get WMS Capabilities: ${error?.toString()}`, + 'network_error' + ) + } + + if (response.status !== 200) { + const msg = `Failed to read GetCapabilities from ${url}` + log.error(msg, response) + throw new CapabilitiesError(msg, 'network_error') + } + + return parseWmsCapabilities(response.data) +} + +/** + * Parse WMS Get Capabilities string + * + * @param content Input content to parse + */ +export function parseWmsCapabilities(content: string): WMSCapabilitiesResponse { + try { + return externalWMSParser.parse(content) + } catch (error: any) { + throw new CapabilitiesError( + `Failed to parse WMS capabilities: ${error?.toString()}`, + 'invalid_wms_capabilities' + ) + } +} + +/** Sets the WMTS GetCapabilities url parameters */ +export function setWmtsGetCapParams(url: URL, language?: string): URL { + // Set mandatory parameters + url.searchParams.set('SERVICE', 'WMTS') + url.searchParams.set('REQUEST', 'GetCapabilities') + // Set optional parameter + if (language) { + url.searchParams.set('lang', language) + } + return url +} + +/** Read and parse WMTS GetCapabilities */ +export async function readWmtsCapabilities( + baseUrl: string, + language?: string +): Promise { + const url = setWmtsGetCapParams(new URL(baseUrl), language) + log.debug(`Read WMTS Get Capabilities: ${url}`) + + let response = null + try { + response = await axios.get(url.toString(), { timeout: EXTERNAL_SERVER_TIMEOUT }) + } catch (error: any) { + throw new CapabilitiesError( + `Failed to get the remote capabilities: ${error?.message}`, + 'network_error' + ) + } + + if (response.status !== 200) { + const msg = `Failed to read GetCapabilities from ${url}` + log.error(msg, response) + throw new CapabilitiesError(msg, 'network_error') + } + + return parseWmtsCapabilities(response.data, url) +} + +/** + * Parse WMTS Get Capabilities string + * + * @param content Input content to parse + * @param originUrl Origin URL of the content, this is used as default GetCapabilities URL if not + * found in the Capabilities + */ +export function parseWmtsCapabilities(content: string, originUrl: URL): WMTSCapabilitiesResponse { + try { + return externalWMTSParser.parse(content, originUrl) + } catch (error: any) { + throw new CapabilitiesError( + `Failed to parse WMTS capabilities: ${error?.toString()}`, + 'invalid_wmts_capabilities' + ) + } +} + +const ENC_PIPE = '%7C' + +/** + * Encode an external layer parameter. + * + * This percent encode the special character | used to separate external layer parameters. + * + * NOTE: We don't use encodeURIComponent here because the Vue Router will anyway do the + * encodeURIComponent() therefore by only encoding | we avoid to encode other special character + * twice. But we need to encode | twice to avoid layer parsing issue. + * + * @param {string} param Parameter to encode + * @returns {string} Percent encoded parameter + */ +export function encodeExternalLayerParam(param: string): string { + return param.replace('|', ENC_PIPE) +} + +/** + * Decode an external layer parameter. + * + * This percent decode the special character | used to separate external layer parameters. + * + * NOTE: We don't use decodeURIComponent here because the Vue Router will anyway do the + * decodeURIComponent() therefore by only decoding | we avoid to decode other special character + * twice. But we need to decode | twice to avoid layer parsing issue. + * + * @param {string} param Parameter to encode + * @returns {string} Percent encoded parameter + */ +export function decodeExternalLayerParam(param: string): string { + return param.replace(ENC_PIPE, '|') +} diff --git a/packages/geoadmin-layers/src/api/index.ts b/packages/geoadmin-layers/src/api/index.ts new file mode 100644 index 0000000000..6d8db8be43 --- /dev/null +++ b/packages/geoadmin-layers/src/api/index.ts @@ -0,0 +1,2 @@ +export * from './internal' +export * from './external' diff --git a/packages/geoadmin-layers/src/api/internal.ts b/packages/geoadmin-layers/src/api/internal.ts new file mode 100644 index 0000000000..d57fe2a94d --- /dev/null +++ b/packages/geoadmin-layers/src/api/internal.ts @@ -0,0 +1,326 @@ +import log from '@geoadmin/log' +import axios from 'axios' + +import { + BASE_URL_DEV, + BASE_URL_INT, + BASE_URL_PROD, + DEFAULT_GEOADMIN_MAX_WMTS_RESOLUTION, + type Staging, + WMTS_BASE_URL_DEV, + WMTS_BASE_URL_INT, + WMTS_BASE_URL_PROD, +} from '@/config' +import { + type AggregateSubLayer, + type GeoAdminLayer, + type LayerAttribution, + LayerType, +} from '@/index' +import { layerUtils, timeConfigUtils } from '@/utils' + +/** + * Some of our backends return URLs without the protocol, i.e. `"styleUrl": + * "//api3.geo.admin.ch/static/vectorStyles/ch.meteoschweiz.messwerte-luftfeuchtigkeit-10min.json"` + * + * This function ensures that URLs start with https:// if no protocol is set. + * + * @param partialOrFullUrl + */ +function enforceHttpsProtocol(partialOrFullUrl: string): string { + if (partialOrFullUrl.startsWith('//')) { + // adding missing protocol + return `https:${partialOrFullUrl}` + } + return partialOrFullUrl +} + +function getWmtsBaseUrlForStaging(staging: Staging = 'production'): string { + switch (staging) { + case 'development': + return WMTS_BASE_URL_DEV + case 'integration': + return WMTS_BASE_URL_INT + default: + return WMTS_BASE_URL_PROD + } +} + +function getApi3BaseUrlForStaging(staging: Staging = 'production'): string { + switch (staging) { + case 'development': + return BASE_URL_DEV + case 'integration': + return BASE_URL_INT + default: + return BASE_URL_PROD + } +} + +const _urlWithTrailingSlash = (baseUrl: string): string => { + if (baseUrl && !baseUrl.endsWith('/')) { + return baseUrl + '/' + } + return baseUrl +} + +// API file that covers the backend endpoint http://api3.geo.admin.ch/rest/services/all/MapServer/layersConfig + +/** + * Transform the backend metadata JSON object into instances of {@link GeoAdminLayer}, instantiating + * the correct type of layer for each entry ({@link GeoAdminAggregateLayer}, + * {@link GeoAdminWMTSLayer}, {@link GeoAdminWMSLayer} or {@link GeoAdminGeoJsonLayer}) + */ +export function generateClassForLayerConfig( + layerConfig: Record, + id: string, + allOtherLayers: Record, + lang: string, + staging: Staging = 'production' +): GeoAdminLayer | undefined { + if (!layerConfig) { + return + } + const { + serverLayerName, + label: name, + type, + opacity, + format, + background: isBackground, + highlightable: isHighlightable, + tooltip: hasTooltip, + attribution: attributionName, + attributionUrl: potentialAttributionUrl, + hasLegend, + searchable, + } = layerConfig + // checking if attributionUrl is a well-formed URL, otherwise we drop it + let attributionUrl = null + try { + new URL(potentialAttributionUrl) + // if we are here, no error has been raised by the URL construction + // meaning we have a valid URL in potentialAttributionUrl + attributionUrl = potentialAttributionUrl + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + // this is not a well-formed URL, we do nothing with it + } + let timestamps: any[] = [] + if (Array.isArray(layerConfig.timestamps) && layerConfig.timestamps.length > 0) { + timestamps = layerConfig.timestamps.map((timestamp) => + timeConfigUtils.makeTimeConfigEntry(timestamp) + ) + } + const timeConfig = timeConfigUtils.makeTimeConfig(layerConfig.timeBehaviour, timestamps) + const topics = layerConfig.topics ? layerConfig.topics.split(',') : [] + const attributions: LayerAttribution[] = [] + if (attributionName) { + attributions.push({ name: attributionName, url: attributionUrl }) + } + switch (type.toLowerCase()) { + case 'vector': + log.info('Vector layer format is TBD in our backends') + break + case 'wmts': { + return layerUtils.makeGeoAdminWMTSLayer({ + type: LayerType.WMTS, + name, + id, + baseUrl: _urlWithTrailingSlash(getWmtsBaseUrlForStaging(staging)), + idIn3d: layerConfig.config3d, + technicalName: serverLayerName, + opacity, + attributions, + format, + timeConfig: timeConfig, + isBackground: !!isBackground, + isHighlightable, + hasTooltip, + topics, + hasLegend: !!hasLegend, + searchable: !!searchable, + maxResolution: + layerConfig.resolutions?.slice(-1)[0] ?? DEFAULT_GEOADMIN_MAX_WMTS_RESOLUTION, + hasDescription: true, + }) + } + case 'wms': { + return layerUtils.makeGeoAdminWMSLayer({ + type: LayerType.WMS, + name, + id: id, + idIn3d: layerConfig.config3d, + technicalName: Array.isArray(layerConfig.wmsLayers) + ? layerConfig.wmsLayers.join(',') + : (layerConfig.wmsLayers ?? serverLayerName), + opacity, + attributions, + baseUrl: layerConfig.wmsUrl, + format, + timeConfig: timeConfig, + wmsVersion: '1.3.0', + lang, + gutter: layerConfig.gutter, + isHighlightable, + hasTooltip, + topics, + hasLegend: !!hasLegend, + searchable: !!searchable, + }) + } + case 'geojson': { + return layerUtils.makeGeoAdminGeoJSONLayer({ + type: LayerType.GEOJSON, + name, + id, + opacity, + isVisible: false, + attributions, + geoJsonUrl: enforceHttpsProtocol(layerConfig.geojsonUrl), + styleUrl: enforceHttpsProtocol(layerConfig.styleUrl), + updateDelay: layerConfig.updateDelay, + hasLegend: !!hasLegend, + hasTooltip: false, + technicalName: id, + hasDescription: true, + isExternal: false, + isLoading: true, + hasError: false, + hasWarning: false, + }) + } + case 'aggregate': { + // here it's a bit tricky, the aggregate layer has a main entry in the layers config (with everything as usual) + // but things get complicated with sub-layers. Each sub-layer has an entry in the config but it's ID (or + // key in the config) is not the one we should ask the server with, that would be the serverLayerName prop, + // but the parent layer will describe it's child layers with another identifier, which is the key to the + // raw config in the big backend config object. + // here's an example: + // { + // "parent.layer": { + // "serverLayerName": "i.am.a.big.aggregate.layer", + // "subLayersIds": [ + // "i.am.a.sub.layer_1", <-- that will be the key to another object + // "i.am.a.sub.layer_2", + // ] + // }, + // "i.am.a.sub.layer_1": { <-- that's one of the "subLayersIds" + // "serverLayerName": "hey.i.am.not.the.same.as.the.sublayer.id", <-- that's the ID that should be used to ask the server for tiles + // }, + // } + + // here id would be "parent.layer" in the example above + const subLayers: AggregateSubLayer[] = [] + layerConfig.subLayersIds.forEach((subLayerId: string) => { + // each subLayerId is one of the "subLayersIds", so "i.am.a.sub.layer_1" or "i.am.a.sub.layer_2" from the example above + const subLayerRawConfig = allOtherLayers[subLayerId] + // the "real" layer ID (the one that will be used to request the backend) is the serverLayerName of this config + // (see example above, that would be "hey.i.am.not.the.same.as.the.sublayer.id") + const subLayer = generateClassForLayerConfig( + subLayerRawConfig, + subLayerRawConfig.serverLayerName, + allOtherLayers, + lang, + staging + ) + if (subLayer) { + subLayers.push( + layerUtils.makeAggregateSubLayer({ + subLayerId, + layer: subLayer, + minResolution: subLayerRawConfig.minResolution, + maxResolution: subLayerRawConfig.maxResolution, + }) + ) + } + }) + return layerUtils.makeGeoAdminAggregateLayer({ + name, + id, + opacity, + isVisible: false, + attributions, + timeConfig, + isHighlightable, + hasTooltip, + topics, + subLayers, + hasLegend: !!hasLegend, + searchable, + }) + } + default: + log.error('Unknown layer type', type) + } + return +} + +/** + * Loads the legend (HTML content) for this layer ID + * + * @param {String} lang The language in which the legend should be rendered + * @param {String} layerId The unique layer ID used in our backends + * @param {Staging} staging + * @returns {Promise} HTML content of the layer's legend + */ +export function getGeoadminLayerDescription( + lang: string, + layerId: string, + staging: Staging = 'production' +): Promise { + return new Promise((resolve, reject) => { + axios + .get( + `${getApi3BaseUrlForStaging(staging)}rest/services/all/MapServer/${layerId}/legend?lang=${lang}` + ) + .then((response) => resolve(response.data)) + .catch((error) => { + log.error('Error while retrieving the legend for the layer', layerId, error) + reject(new Error(error)) + }) + }) +} + +/** + * Loads the layer config from the backend and transforms it in classes defined in this API file + * + * @param {String} lang The ISO code for the lang in which the config should be loaded (required) + * @param {Staging} staging + */ +export function loadGeoadminLayersConfig( + lang: string, + staging: Staging = 'production' +): Promise { + return new Promise((resolve, reject) => { + const layersConfig: any[] = [] + axios + .get( + `${getApi3BaseUrlForStaging(staging)}rest/services/all/MapServer/layersConfig?lang=${lang}` + ) + .then(({ data: rawLayersConfig }) => { + if (Object.keys(rawLayersConfig).length > 0) { + Object.keys(rawLayersConfig).forEach((rawLayerId) => { + const rawLayer = rawLayersConfig[rawLayerId] + const layer = generateClassForLayerConfig( + rawLayer, + rawLayerId, + rawLayersConfig, + lang + ) + if (layer) { + layersConfig.push(layer) + } + }) + resolve(layersConfig) + } else { + reject(new Error('LayersConfig loaded from backend is not defined or is empty')) + } + }) + .catch((error) => { + const message = 'Error while loading layers config from backend' + log.error(message, error) + reject(new Error(message)) + }) + }) +} diff --git a/packages/geoadmin-layers/src/config.ts b/packages/geoadmin-layers/src/config.ts new file mode 100644 index 0000000000..a4b6a54b95 --- /dev/null +++ b/packages/geoadmin-layers/src/config.ts @@ -0,0 +1,12 @@ +export const BASE_URL_PROD: string = 'https://api3.geo.admin.ch/' +export const BASE_URL_INT: string = 'https://sys-api3.int.bgdi.ch/' +export const BASE_URL_DEV: string = 'https://sys-api3.dev.bgdi.ch/' + +export const WMTS_BASE_URL_PROD: string = 'https://wmts.geo.admin.ch/' +export const WMTS_BASE_URL_INT: string = 'https://sys-wmts.int.bgdi.ch/' +export const WMTS_BASE_URL_DEV: string = 'https://sys-wmts.dev.bgdi.ch/' + +// mimicing values from https://github.com/geoadmin/web-mapviewer/blob/36043456b820b03f380804a63e2cac1a8a1850bc/packages/mapviewer/src/config/staging.config.js#L1-L7 +export type Staging = 'development' | 'integration' | 'production' + +export const DEFAULT_GEOADMIN_MAX_WMTS_RESOLUTION: number = 0.5 // meters/pixel diff --git a/packages/geoadmin-layers/src/index.ts b/packages/geoadmin-layers/src/index.ts new file mode 100644 index 0000000000..5bd4f65bcb --- /dev/null +++ b/packages/geoadmin-layers/src/index.ts @@ -0,0 +1,5 @@ +// let's export the types "globally" +export * from '@/types/layers' +export * from '@/types/timeConfig' + +export * from '@/validation' diff --git a/packages/geoadmin-layers/src/parsers/ExternalWMSCapabilitiesParser.ts b/packages/geoadmin-layers/src/parsers/ExternalWMSCapabilitiesParser.ts new file mode 100644 index 0000000000..83efbf2628 --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/ExternalWMSCapabilitiesParser.ts @@ -0,0 +1,666 @@ +import type { FlatExtent } from '@geoadmin/coordinates' + +import { + allCoordinateSystems, + coordinatesUtils, + CoordinateSystem, + extentUtils, + WEBMERCATOR, + WGS84, +} from '@geoadmin/coordinates' +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { range } from 'lodash' +import { default as olWMSCapabilities } from 'ol/format/WMSCapabilities' + +import type { CapabilitiesParser, ExternalLayerParsingOptions } from '@/parsers/parser' +import type { ExternalLayerTimeDimension, LayerTimeConfig } from '@/types' + +import { + type BoundingBox, + type ExternalLayerGetFeatureInfoCapability, + type ExternalWMSLayer, + type LayerAttribution, + type LayerLegend, + WMS_SUPPORTED_VERSIONS, +} from '@/types/layers' +import { layerUtils } from '@/utils' +import { makeTimeConfig, makeTimeConfigEntry } from '@/utils/timeConfigUtils' +import { CapabilitiesError } from '@/validation' + +interface WMSBoundingBox { + crs: string + extent: [number, number, number, number] + res: [number | null, number | null] +} + +interface WMSLegendURL { + Format: string + size: [number, number] + OnlineResource: string +} + +interface WMSCapabilityLayerStyle { + LegendURL: WMSLegendURL[] + Identifier: string + isDefault: boolean +} +interface WMSCapabilityLayerDimension { + name: string + default: string + values: string + current?: boolean +} + +export interface WMSCapabilityLayer { + Dimension?: WMSCapabilityLayerDimension[] + Name: string + parent: WMSCapabilityLayer + Title: string + Layer?: WMSCapabilityLayer[] + CRS: string[] + Abstract: string + queryable: boolean + WGS84BoundingBox?: { crs: string; dimensions: any }[] + BoundingBox?: WMSBoundingBox[] + EX_GeographicBoundingBox: [number, number, number, number] + Attribution: { + LogoUrl: { + Format: string + OnlineResource: string + size: [number, number] + } + OnlineResource: string + Title: string + } + Style: WMSCapabilityLayerStyle[] +} + +interface DCPType { + HTTP: { + Get?: { + OnlineResource: string + } + Post?: { + OnlineResource: string + } + } +} + +interface Request { + DCPType: DCPType[] + Format: string[] +} + +/** + * GetMap and GetCapabilities are mandatory according to WMS OGC specification, GetFeatureInfo is + * optional + */ +export interface WMSRequestCapabilities { + GetCapabilities: Request + GetMap: Request + GetFeatureInfo?: Request + GetLegendGraphic?: Request +} + +interface WMSCapability { + Layer?: WMSCapabilityLayer + TileMatrixSet: Array<{ + BoundingBox: BoundingBox[] + Identifier: string + SupportedCRS?: string + TileMatrix: Object[] + }> + Request: WMSRequestCapabilities + UserDefinedSymbolization?: { + SupportSLD: boolean + } +} + +export interface WMSCapabilitiesResponse { + originUrl: URL + version: string + Capability?: WMSCapability + ServiceProvider?: { + ProviderName?: string + ProviderSite?: string + } + OperationsMetadata?: Record + Service: { + Title: string + OnlineResource: string + MaxWidth?: number + MaxHeight?: number + } +} + +type WMSLayerAndItsParents = { + layer?: WMSCapabilityLayer + parents?: WMSCapabilityLayer[] +} + +function getAllCapabilitiesLayers(capabilities: WMSCapabilitiesResponse): WMSCapabilityLayer[] { + return capabilities.Capability?.Layer?.Layer ?? [] +} + +/** + * Find recursively in the WMS capabilities the matching layer ID node and its parents + * + * @returns Capability layer node and its parents or an empty object if not found + */ +function getCapabilitiesLayer( + capabilities: WMSCapabilitiesResponse, + layerId: string +): WMSCapabilityLayer | undefined { + if (!capabilities.Capability?.Layer) { + return + } + + return findLayerRecurse( + layerId, + [capabilities.Capability.Layer], + [capabilities.Capability.Layer] + ).layer +} + +function findLayerRecurse( + layerId: string, + layers: WMSCapabilityLayer[], + parents: WMSCapabilityLayer[] +): WMSLayerAndItsParents { + let found: WMSLayerAndItsParents = {} + + for (let i = 0; i < layers?.length && !found.layer; i++) { + const layer = layers[i] + if (layer && (layer.Name === layerId || layer.Title === layerId)) { + found.layer = layer + found.parents = parents + } else if (layer.Layer && layer.Layer.length > 0) { + found = findLayerRecurse(layerId, layer.Layer, [layer, ...parents]) + } + } + return found +} + +/** + * Returns the common projection identifiers of all sublayers, if the main layer doesn't have any + * CRS defined. + * + * If the main layer has CRS defined, returns it + */ +function getLayerProjections(layer: WMSCapabilityLayer): string[] { + if (layer.CRS) { + return layer.CRS + } + if (layer.Layer && layer.Layer.length > 0) { + return layer.Layer.flatMap((sublayer: WMSCapabilityLayer) => + getLayerProjections(sublayer) + ).filter((crs, index, self) => self.indexOf(crs) === index) + } else { + return [] + } +} + +function getLayerAttribution( + capabilities: WMSCapabilitiesResponse, + layerId: string, + layer: WMSCapabilityLayer +): LayerAttribution[] { + let title: string + let url: string | undefined + + try { + if (layer.Attribution || capabilities.Capability?.Layer?.Attribution) { + const attribution = layer.Attribution || capabilities.Capability?.Layer?.Attribution + url = attribution.OnlineResource + title = attribution.Title || new URL(attribution.OnlineResource).hostname + } else { + title = + capabilities.Service?.Title || + new URL(capabilities.Service?.OnlineResource).hostname + } + } catch (error) { + const msg = `Failed to get an attribution title/url for ${layerId}: ${error?.toString()}` + log.warn(msg, layer, error) + title = new URL(capabilities.originUrl).hostname + url = undefined + } + + return [{ name: title, url }] +} + +function getLayerExtent( + capabilities: WMSCapabilitiesResponse, + layerId: string, + layer: WMSCapabilityLayer, + parents: WMSCapabilityLayer[], + projection: CoordinateSystem +): FlatExtent | undefined { + // TODO PB-243 handling of extent out of projection bound (currently not properly handled) + // - extent totally out of projection bounds + // => return null and set outOfBounds flag to true + // - extent totally inside of projection bounds + // => crop extent and set outOfBounds flag to true + // - extent partially inside projection bounds + // => take intersect extent and set outOfBounds flag to true + // - no extent + // => return null and set the outOfBounds flag to false (we don't know) + let layerExtent: FlatExtent | undefined + let extentProjection: CoordinateSystem | undefined + + const matchedBbox: WMSBoundingBox | undefined = layer.BoundingBox?.find( + (bbox) => bbox.crs === projection.epsg + ) + + // First try to find a matching extent from the BoundingBox + if (matchedBbox) { + layerExtent = matchedBbox.extent + extentProjection = coordinatesUtils.parseCRS(matchedBbox.crs) + } + // Then try to find a supported CRS extent from the BoundingBox + if (!layerExtent) { + const bbox: WMSBoundingBox | undefined = layer.BoundingBox?.find((bbox) => + allCoordinateSystems.find((projection) => projection.epsg === bbox.crs) + ) + if (bbox) { + let extent: FlatExtent = bbox.extent + extentProjection = coordinatesUtils.parseCRS(bbox.crs) + + // When transforming between WGS84 (EPSG:4326) and Web Mercator (EPSG:3857) + // we have to be carefull because: + // - WGS84 traditionally uses latitude-first (Y,X) axis order [minY, minX, maxY, maxX] + // - Web Mercator uses longitude-first (X,Y) axis order [minX, minY, maxX, maxY] + // Note: Some WGS84 implementations may use X,Y order, + // thats why we need to get the extent in the right order throught the function getExtentInOrderXY + if (bbox.crs === WGS84.epsg && projection.epsg === WEBMERCATOR.epsg) { + extent = WGS84.getExtentInOrderXY(extent) + extentProjection = WGS84 + } + if (extentProjection && extentProjection.epsg !== projection.epsg) { + layerExtent = extentUtils.projExtent(extentProjection, projection, extent) + } + } + } + // Fallback to the EX_GeographicBoundingBox + if (!layerExtent && layer.EX_GeographicBoundingBox) { + if (projection.epsg !== WGS84.epsg) { + layerExtent = extentUtils.projExtent(WGS84, projection, layer.EX_GeographicBoundingBox) + } else { + layerExtent = layer.EX_GeographicBoundingBox + } + } + // Finally, search the extent in the parents + if (!layerExtent && parents.length > 0) { + return getLayerExtent(capabilities, layerId, parents[0], parents.slice(1), projection) + } + + if (!layerExtent) { + const msg = `No layer extent found for ${layerId} in ${capabilities.originUrl.toString()}` + log.error({ + title: 'WMS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [msg, layer, parents], + }) + } + + return layerExtent +} + +function getLayerLegends( + capabilities: WMSCapabilitiesResponse, + layerId: string, + layer: WMSCapabilityLayer +): LayerLegend[] { + const styles: WMSCapabilityLayerStyle[] = + layer.Style?.filter((s) => s.LegendURL?.length > 0) ?? [] + + // if we do not have access to the legend in pure WMS fashion, we check if this + // WMS follows the SLD specification, and if we can get it from there. + if ( + styles.length === 0 && + layer.queryable && + !!capabilities.Capability?.UserDefinedSymbolization?.SupportSLD + ) { + const getLegendGraphicBaseUrl = + capabilities.Capability.Request.GetLegendGraphic?.DCPType[0]?.HTTP?.Get?.OnlineResource + const getLegendGraphicFormat = capabilities.Capability.Request.GetLegendGraphic?.Format[0] + if (!!getLegendGraphicBaseUrl && !!getLegendGraphicFormat) { + const getLegendParams = new URLSearchParams({ + SERVICE: 'WMS', + REQUEST: 'GetLegendGraphic', + VERSION: capabilities.version, + FORMAT: getLegendGraphicFormat, + LAYER: layerId, + SLD_VERSION: '1.1.0', + }) + return [ + { + url: `${getLegendGraphicBaseUrl}${getLegendParams.toString()}`, + format: getLegendGraphicFormat, + }, + ] + } + } + return styles + .map((style) => + style.LegendURL.map((legend) => { + const width = legend.size?.length >= 2 ? legend.size[0] : null + const height = legend.size?.length >= 2 ? legend.size[1] : null + return { + url: legend.OnlineResource, + format: legend.Format, + width: width ?? 0, + height: height ?? 0, + } + }) + ) + .flat() +} + +function parseDimensionYear(value: string): number | undefined { + const date = new Date(value) + if (!isNaN(date.getFullYear())) { + return date.getFullYear() + } + return +} + +function getDimensions( + layerId: string, + layer: WMSCapabilityLayer +): ExternalLayerTimeDimension[] | undefined { + if (!layer.Dimension || layer.Dimension.length === 0) { + return + } + + return layer.Dimension.map((d): ExternalLayerTimeDimension => { + return { + id: d.name, + defaultValue: d.default, + values: d.values + .split(',') + .map((v) => { + if (v.includes('/')) { + const [min, max, res] = v.split('/') + const minYear = parseDimensionYear(min) + const maxYear = parseDimensionYear(max) + if (minYear === undefined || maxYear === undefined) { + log.warn( + `Unsupported dimension min/max value "${min}"/"${max}" for layer ${layerId}` + ) + return + } + let step = 1 + + const periodMatch = /P(\d+)Y/.exec(res) + + if (periodMatch) { + step = parseInt(periodMatch[1]) + } else { + log.warn( + `Unsupported dimension resolution "${res}" for layer ${layerId}, fallback to 1 year period` + ) + } + return range(minYear, maxYear, step) + } + return v + }) + .flat() + .filter((v) => !!v) + .map((v) => `${v}`), + current: d.current ?? false, + } + }) +} + +function getTimeConfig(dimensions?: ExternalLayerTimeDimension[]): LayerTimeConfig | undefined { + if (!dimensions) { + return + } + + const timeDimension = dimensions.find((d) => { + return d.id.toLowerCase() === 'time' + }) + if (!timeDimension) { + return + } + const timeEntries = + timeDimension.values?.map((value: string) => makeTimeConfigEntry(value)) ?? [] + return makeTimeConfig(timeDimension.defaultValue, timeEntries) +} + +function getLayerAttributes( + capabilities: WMSCapabilitiesResponse, + layer: WMSCapabilityLayer, + parents: WMSCapabilityLayer[], + projection: CoordinateSystem, + ignoreError = true +): Partial { + let layerId = layer.Name + // Some WMS only have a Title and no Name, in this case take the Title as layerId + if ((!layerId || layerId.length === 0) && layer.Title) { + layerId = layer.Title + } + if (!layerId || layerId.length === 0) { + // Without layerID we cannot use the layer in our viewer + const msg = `No layerId found in WMS capabilities for layer in ${capabilities.originUrl.toString()}` + log.error(msg, layer) + if (ignoreError) { + return {} + } + throw new CapabilitiesError(msg, 'no_layer_found') + } + + if (!capabilities.version || !WMS_SUPPORTED_VERSIONS.includes(capabilities.version)) { + let msg = '' + if (!capabilities.version) { + msg = `No WMS version found in Capabilities of ${capabilities.originUrl.toString()}` + } else { + msg = `WMS version ${capabilities.version} of ${capabilities.originUrl.toString()} not supported` + } + log.error(msg, layer) + if (ignoreError) { + return {} + } + throw new CapabilitiesError(msg, 'no_wms_version_found') + } + + let availableProjections: CoordinateSystem[] = getLayerProjections(layer) + .filter((crs) => + allCoordinateSystems.some((projection) => projection.epsg === crs.toUpperCase()) + ) + .map((crs) => + allCoordinateSystems.find((projection) => projection.epsg === crs.toUpperCase()) + ) as CoordinateSystem[] // let's assume that the filtering won't remove any for now + + // by default, WGS84 must be supported + if (availableProjections.length === 0) { + availableProjections = [WGS84] + } + // filtering out double inputs + availableProjections = availableProjections.filter( + (projection, index, self) => self.indexOf(projection) === index + ) + if (availableProjections.length === 0) { + const msg = `No projections found for layer ${layerId}` + if (!ignoreError) { + throw new CapabilitiesError(msg) + } else { + log.error(msg, layer) + } + } + const dimensions = getDimensions(layerId, layer) + + return { + id: layerId, + name: layer.Title, + baseUrl: + capabilities.Capability?.Request?.GetMap?.DCPType[0]?.HTTP?.Get?.OnlineResource ?? + capabilities.originUrl.toString(), + wmsVersion: capabilities.version, + abstract: layer.Abstract, + attributions: getLayerAttribution(capabilities, layerId, layer), + extent: getLayerExtent(capabilities, layerId, layer, parents, projection), + legends: getLayerLegends(capabilities, layerId, layer), + hasTooltip: layer.queryable, + availableProjections, + dimensions, + timeConfig: getTimeConfig(dimensions), + } +} +function getFeatureInfoCapability( + capabilities: WMSCapabilitiesResponse, + ignoreError = true +): ExternalLayerGetFeatureInfoCapability | undefined { + if (Array.isArray(capabilities.Capability?.Request?.GetFeatureInfo?.DCPType)) { + const getFeatureInfoCapability = + capabilities.Capability.Request.GetFeatureInfo?.DCPType[0].HTTP + let baseUrl: string + let method: 'GET' | 'POST' = 'GET' + if (getFeatureInfoCapability?.Get) { + baseUrl = getFeatureInfoCapability.Get.OnlineResource + } else if (getFeatureInfoCapability?.Post) { + method = 'POST' + baseUrl = getFeatureInfoCapability.Post.OnlineResource + } else { + log.error( + "Couldn't parse GetFeatureInfo data", + capabilities.Capability.Request.GetFeatureInfo + ) + if (ignoreError) { + return + } + throw new CapabilitiesError('Invalid GetFeatureInfo data', 'invalid_get_feature_info') + } + const formats: string[] = [] + if (capabilities.Capability.Request.GetFeatureInfo.Format) { + formats.push(...capabilities.Capability.Request.GetFeatureInfo.Format) + } + return { + baseUrl, + method, + formats, + } + } + return +} + +/** + * Get ExternalWMSLayer object from capabilities for the given layer ID + * + * @param capabilities + * @param layerOrLayerId Layer ID of the layer to retrieve, or the layer object itself (from the + * capabilities) + * @param options + * @param options.outputProjection Projection currently used by the application + * @param options.opacity + * @param options.isVisible + * @param options.currentYear Current year to select for the time config. Only needed when a time + * config is present, a year is pre-selected in the url parameter. + * @param options.params URL parameters to pass to WMS server + * @param options.ignoreError Don't throw exception in case of error, but return a default value or + * undefined + * @returns Layer object, or undefined in case of error (and ignoreError is equal to true) + */ +function getExternalLayer( + capabilities: WMSCapabilitiesResponse, + layerOrLayerId: WMSCapabilityLayer | string, + options?: ExternalLayerParsingOptions +): ExternalWMSLayer | undefined { + if (!layerOrLayerId) { + // without a layer object or layer ID we can do nothing + return + } + + const { outputProjection = WGS84, initialValues = {}, ignoreErrors = true } = options ?? {} + const { currentYear, params } = initialValues + + let layerId: string + if (typeof layerOrLayerId === 'string') { + layerId = layerOrLayerId + } else { + layerId = layerOrLayerId.Name + } + if (!capabilities.Capability?.Layer?.Layer) { + return + } + + const layerAndParents = findLayerRecurse( + layerId, + [capabilities.Capability.Layer], + [capabilities.Capability.Layer] + ) + if (!layerAndParents) { + return + } + const { layer, parents } = layerAndParents + if (!layer) { + const msg = `No WMS layer ${layerId} found in Capabilities ${capabilities.originUrl.toString()}` + log.error({ + title: 'WMS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [msg, capabilities], + }) + if (ignoreErrors) { + return + } + throw new CapabilitiesError(msg, 'no_layer_found') + } + // Go through the child to get valid layers + let layers: ExternalWMSLayer[] = [] + + if (layer.Layer?.length) { + layers = layer.Layer.map((l) => getExternalLayer(capabilities, l.Name, options)).filter( + (layer) => !!layer + ) + } + return layerUtils.makeExternalWMSLayer({ + ...getLayerAttributes(capabilities, layer, parents ?? [], outputProjection, ignoreErrors), + format: 'png', + isLoading: false, + getFeatureInfoCapability: getFeatureInfoCapability(capabilities, ignoreErrors), + currentYear, + customAttributes: params, + layers, + }) +} + +function getAllExternalLayers( + capabilities: WMSCapabilitiesResponse, + options?: ExternalLayerParsingOptions +): ExternalWMSLayer[] { + return getAllCapabilitiesLayers(capabilities) + .map((layer) => getExternalLayer(capabilities, layer, options)) + .filter((layer) => !!layer) +} + +function parse(content: string, originUrl: URL): WMSCapabilitiesResponse { + const parser = new olWMSCapabilities() + try { + const capabilities = parser.read(content) as WMSCapabilitiesResponse + capabilities.originUrl = originUrl + return capabilities + } catch (error) { + log.error({ + title: 'WMS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [`Failed to parse capabilities of ${originUrl?.toString()}`, error], + }) + throw new CapabilitiesError( + `Failed to parse WMS Capabilities: invalid content: ${error?.toString()}`, + 'invalid_wms_capabilities' + ) + } +} + +export interface ExternalWMSCapabilitiesParser + extends CapabilitiesParser {} + +export const externalWMSParser: ExternalWMSCapabilitiesParser = { + parse, + getAllCapabilitiesLayers, + getCapabilitiesLayer, + getAllExternalLayers, + getExternalLayer, +} + +export default externalWMSParser diff --git a/packages/geoadmin-layers/src/parsers/ExternalWMTSCapabilitiesParser.ts b/packages/geoadmin-layers/src/parsers/ExternalWMTSCapabilitiesParser.ts new file mode 100644 index 0000000000..34c4653bd0 --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/ExternalWMTSCapabilitiesParser.ts @@ -0,0 +1,541 @@ +import type { FlatExtent, SingleCoordinate } from '@geoadmin/coordinates' + +import { allCoordinateSystems, CoordinateSystem, extentUtils, WGS84 } from '@geoadmin/coordinates' +import log, { LogPreDefinedColor } from '@geoadmin/log' +import { default as olWMTSCapabilities } from 'ol/format/WMTSCapabilities' +import { optionsFromCapabilities } from 'ol/source/WMTS' + +import type { CapabilitiesParser, ExternalLayerParsingOptions } from '@/parsers/parser' +import type { LayerTimeConfig } from '@/types/timeConfig' + +import { + type BoundingBox, + type ExternalLayerTimeDimension, + type ExternalWMTSLayer, + type LayerAttribution, + type LayerLegend, + LayerType, + type TileMatrixSet, + WMTSEncodingType, +} from '@/types' +import layerUtils from '@/utils/layerUtils' +import { makeTimeConfig, makeTimeConfigEntry } from '@/utils/timeConfigUtils' +import { CapabilitiesError } from '@/validation' + +interface WMTSBoundingBox { + lowerCorner?: SingleCoordinate + upperCorner?: SingleCoordinate + extent?: FlatExtent + crs?: string + dimensions?: number +} + +interface WMTSLegendURL { + format: string + width: number + height: number + href: string +} + +interface WMTSCapabilityLayerStyle { + LegendURL: WMTSLegendURL[] + Identifier: string + isDefault: boolean +} + +interface WMTSCapabilityLayerDimension { + Identifier: string + Default: string + Value: string +} + +interface WMTSCapabilityLayer { + Dimension?: WMTSCapabilityLayerDimension[] + ResourceURL: any + Identifier: string + Title: string + WGS84BoundingBox?: FlatExtent + BoundingBox?: WMTSBoundingBox[] + TileMatrixSetLink: WMTSTileMatrixSetLink[] + Style: WMTSCapabilityLayerStyle[] + Abstract: string +} + +interface WMTSTileMatrixSetLink { + TileMatrixSet: string + TileMatrixSetLimits: Array<{ + MaxTileCol: number + MaxTileRow: number + MinTileCol: number + MinTileRow: number + TileMatrix: string + }> +} + +interface WMTSCapabilitiesTileMatrixSet { + BoundingBox: BoundingBox[] + Identifier: string + SupportedCRS?: string + TileMatrix: Object[] +} + +export interface WMTSCapabilitiesResponse { + originUrl: URL + version: string + Contents?: { + Layer?: WMTSCapabilityLayer[] + TileMatrixSet: WMTSCapabilitiesTileMatrixSet[] + } + ServiceProvider?: { + ProviderName?: string + ProviderSite?: string + } + OperationsMetadata?: Record + ServiceIdentification: Record +} + +function parseCrs(crs?: string): CoordinateSystem | undefined { + let epsgNumber = crs?.split(':').pop() + if (!epsgNumber) { + return + } + + if (/84/.test(epsgNumber)) { + epsgNumber = '4326' + } + return allCoordinateSystems.find((system) => system.epsg === `EPSG:${epsgNumber}`) +} + +function getLayerAttribution( + capabilities: WMTSCapabilitiesResponse, + layerId: string +): LayerAttribution[] { + let title = capabilities.ServiceProvider?.ProviderName + const url = capabilities.ServiceProvider?.ProviderSite + + if (!title) { + log.warn({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [`No attribution title for layer ${layerId}`, capabilities], + }) + title = capabilities.originUrl.hostname + } + return [{ name: title, url } as LayerAttribution] +} + +function findTileMatrixSetFromLinks( + capabilities: WMTSCapabilitiesResponse, + links: WMTSTileMatrixSetLink[] +): WMTSCapabilitiesTileMatrixSet | undefined { + for (const link of links) { + const tileMatrixSet = capabilities.Contents?.TileMatrixSet?.find( + (set) => set.Identifier === link.TileMatrixSet + ) + if (tileMatrixSet) { + return tileMatrixSet + } + } + return +} + +function getLayerExtent( + capabilities: WMTSCapabilitiesResponse, + layerId: string, + layer: WMTSCapabilityLayer, + projection: CoordinateSystem +): FlatExtent | undefined { + // TODO PB-243 handling of extent out of projection bound (currently not properly handled) + let layerExtent: FlatExtent | undefined + let extentProjection: CoordinateSystem | undefined + + // First, try to get the extent from the default bounding box + if (layer.WGS84BoundingBox?.length) { + layerExtent = layer.WGS84BoundingBox + extentProjection = WGS84 + } + // Some providers don't use the WGS84BoundingBox but use the BoundingBox instead + else if (layer.BoundingBox) { + // search for a matching proj bounding box + const matching = layer.BoundingBox.find((bbox) => parseCrs(bbox.crs ?? '') === projection) + + if (matching && matching.extent) { + layerExtent = matching.extent + } else if (layer.BoundingBox.length === 1 && !layer.BoundingBox[0].crs) { + // if we have only one bounding box without CRS, then take it searching the CRS + // fom the TileMatrixSet + const tileMatrixSet = findTileMatrixSetFromLinks(capabilities, layer.TileMatrixSetLink) + extentProjection = parseCrs(tileMatrixSet?.SupportedCRS) + if (extentProjection) { + if (layer.BoundingBox && layer.BoundingBox[0] && layer.BoundingBox[0].extent) { + layerExtent = layer.BoundingBox[0].extent + } + } + } else { + // if we have multiple bounding box search for the one that specifies a supported CRS + const supported = layer.BoundingBox.find( + (bbox: BoundingBox) => bbox.crs !== undefined && parseCrs(bbox.crs) !== undefined + ) + + if (supported && supported.crs && supported.extent) { + extentProjection = parseCrs(supported.crs) + layerExtent = supported.extent + } + } + } + + // If we didn't find a valid and supported bounding box in the layer, then fallback to the + // linked TileMatrixSet. NOTE: some providers don't specify the bounding box at the layer + // level but on the TileMatrixSet + if (!layerExtent && capabilities.Contents?.TileMatrixSet) { + const tileMatrixSet = findTileMatrixSetFromLinks(capabilities, layer.TileMatrixSetLink) + const system = parseCrs(tileMatrixSet?.SupportedCRS ?? '') + + if ( + tileMatrixSet && + system && + tileMatrixSet.BoundingBox && + tileMatrixSet.BoundingBox.length === 4 + ) { + layerExtent = tileMatrixSet.BoundingBox as FlatExtent + extentProjection = system + } + } + + // Convert the extent if needed + if (layerExtent && extentProjection && projection.epsg !== extentProjection.epsg) { + layerExtent = extentUtils.projExtent(extentProjection, projection, layerExtent) + } + if (!layerExtent) { + const msg = `No layer extent found for ${layerId}` + log.error(msg, layer) + } + + return layerExtent +} + +function getLegends(layer: WMTSCapabilityLayer): LayerLegend[] { + const styles: WMTSCapabilityLayerStyle[] = + layer.Style?.filter((s) => s.LegendURL?.length > 0) ?? [] + return styles + .map((style) => + style.LegendURL.map( + (legend: WMTSLegendURL): LayerLegend => ({ + url: legend.href, + format: legend.format, + width: legend.width, + height: legend.height, + }) + ) + ) + .flat() +} + +function getAvailableProjections( + capabilities: WMTSCapabilitiesResponse, + layerId: string, + layer: WMTSCapabilityLayer, + ignoreError: boolean +): CoordinateSystem[] { + const availableProjections: CoordinateSystem[] = [] + + if (layer.WGS84BoundingBox?.length) { + availableProjections.push(WGS84) + } + + // Take the projections defined in BoundingBox + availableProjections.push( + ...(layer.BoundingBox?.map((bbox) => parseCrs(bbox.crs ?? '')).filter( + (projection) => !!projection + ) ?? []) + ) + + // Take the available projections from the tile matrix set + const tileMatrixSetCrs = findTileMatrixSetFromLinks( + capabilities, + layer.TileMatrixSetLink + )?.SupportedCRS + + if (tileMatrixSetCrs) { + const tileMatrixSetProjection = parseCrs(tileMatrixSetCrs) + if (!tileMatrixSetProjection) { + log.warn({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [`CRS ${tileMatrixSetCrs} no supported by application or invalid`], + }) + } else { + availableProjections.push(tileMatrixSetProjection) + } + } + + // Remove duplicates + const uniqueAvailableProjections = [...new Set(availableProjections)] + + if (uniqueAvailableProjections.length === 0) { + const msg = `No projections found for layer ${layerId}` + if (!ignoreError) { + throw new CapabilitiesError(msg) + } + log.error({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [msg, layer], + }) + } + return uniqueAvailableProjections +} + +function getTileMatrixSets( + capabilities: WMTSCapabilitiesResponse, + layerId: string, + layer: WMTSCapabilityLayer +): TileMatrixSet[] | undefined { + // Based on the spec at least one TileMatrixSetLink should be available + const ids = layer.TileMatrixSetLink.map((link) => link.TileMatrixSet) + + if (!capabilities.Contents?.TileMatrixSet) { + return + } + + return capabilities.Contents?.TileMatrixSet.filter((set) => ids.includes(set.Identifier)) + .map((set) => { + const projection = parseCrs(set.SupportedCRS) + if (!projection) { + log.warn({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [ + `Invalid or non supported CRS ${set.SupportedCRS} in TileMatrixSet ${set.Identifier} for layer ${layerId}}`, + ], + }) + return + } + return { + id: set.Identifier, + projection: projection, + tileMatrix: set.TileMatrix, + } + }) + .filter((set) => !!set) +} + +function getDimensions(layer: WMTSCapabilityLayer): ExternalLayerTimeDimension[] | undefined { + if (!layer.Dimension || layer.Dimension.length === 0) { + return + } + const identifiers = layer.Dimension.map((dimension) => dimension.Identifier) + const dimensions: ExternalLayerTimeDimension[] = [] + for (const identifier of identifiers) { + const entriesForIdentifier: WMTSCapabilityLayerDimension[] = layer.Dimension.filter( + (d) => d.Identifier === identifier + ) + dimensions.push({ + id: identifier, + values: entriesForIdentifier.flatMap((d) => d.Value), + defaultValue: entriesForIdentifier[0].Default, + // TODO: find if "current" is part of the WMTS spec + }) + } + return dimensions +} + +function getLayerAttributes( + capabilities: WMTSCapabilitiesResponse, + layer: WMTSCapabilityLayer, + projection: CoordinateSystem, + ignoreError = true +): Partial { + let layerId = layer.Identifier + + if (!layerId || layerId.length === 0) { + // fallback to Title + layerId = layer.Title + } + + if (!layerId || layerId.length === 0) { + const msg = `No layer identifier found in Capabilities ${capabilities.originUrl.toString()}` + log.error({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [msg, layer], + }) + if (ignoreError) { + return {} + } + throw new CapabilitiesError(msg, 'invalid_wmts_capabilities') + } + + const getCapUrl = + capabilities.OperationsMetadata?.GetCapabilities?.DCP?.HTTP?.Get[0]?.href ?? + capabilities.originUrl.toString() + + return { + id: layerId, + name: layer.Title ?? layerId, + baseUrl: getCapUrl, + abstract: layer.Abstract, + options: { + version: capabilities.version, + }, + attributions: getLayerAttribution(capabilities, layerId), + extent: getLayerExtent(capabilities, layerId, layer, projection), + legends: getLegends(layer), + availableProjections: getAvailableProjections(capabilities, layerId, layer, ignoreError), + getTileEncoding: + capabilities.OperationsMetadata?.GetTile?.DCP?.HTTP?.Get[0]?.Constraint[0] + ?.AllowedValues?.Value[0] ?? WMTSEncodingType.REST, + urlTemplate: layer.ResourceURL[0]?.template ?? '', + // Based on the spec, at least one style should be available + style: layer.Style[0].Identifier, + tileMatrixSets: getTileMatrixSets(capabilities, layerId, layer), + dimensions: getDimensions(layer), + } +} + +function getCapabilitiesLayer( + capabilities: WMTSCapabilitiesResponse, + layerId: string +): WMTSCapabilityLayer | undefined { + let layer: WMTSCapabilityLayer | undefined + + if (!capabilities.Contents?.Layer) { + return + } + + const layers = capabilities.Contents.Layer + + for (let i = 0; i < layers.length && !layer; i++) { + if (layers[i].Identifier === layerId) { + layer = layers[i] + } else if (!layers[i].Identifier && layers[i].Title === layerId) { + layer = layers[i] + } + } + + return layer +} + +function getAllCapabilitiesLayers(capabilities: WMTSCapabilitiesResponse): WMTSCapabilityLayer[] { + return capabilities.Contents?.Layer ?? [] +} + +function getTimeConfig( + dimensions: ExternalLayerTimeDimension[] | undefined +): LayerTimeConfig | undefined { + if (!dimensions) { + return + } + + const timeDimension = dimensions.find((d) => d.id.toLowerCase() === 'time') + if (!timeDimension) { + return + } + + const timeEntries = timeDimension.values?.map((value) => makeTimeConfigEntry(value)) + return makeTimeConfig(timeDimension.defaultValue, timeEntries) +} + +function getExternalLayer( + capabilities: WMTSCapabilitiesResponse, + layerOrLayerId: WMTSCapabilityLayer | string, + options?: ExternalLayerParsingOptions +): ExternalWMTSLayer | undefined { + if (!layerOrLayerId) { + // without a layer object or layer ID we can do nothing + return + } + + const { outputProjection = WGS84, initialValues = {}, ignoreErrors = true } = options ?? {} + const { opacity = 1, isVisible = true, currentYear } = initialValues + + let layerId: string + if (typeof layerOrLayerId === 'string') { + layerId = layerOrLayerId + } else { + layerId = layerOrLayerId.Identifier + } + + const layer = getCapabilitiesLayer(capabilities, layerId) + + if (!layer) { + const msg = `No WMTS layer ${layerId} found in Capabilities ${capabilities.originUrl.toString()}` + log.error({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [msg, capabilities], + }) + if (ignoreErrors) { + return + } + throw new CapabilitiesError(msg, 'no_layer_found') + } + const attributes = getLayerAttributes(capabilities, layer, outputProjection, ignoreErrors) + + if (!attributes) { + log.error(`No attributes found for layer ${layer.Identifier}`) + return + } + + return layerUtils.makeExternalWMTSLayer({ + type: LayerType.WMTS, + ...attributes, + opacity, + isVisible, + isLoading: false, + options: + optionsFromCapabilities(capabilities, { + layer: attributes.id, + projection: outputProjection.epsg, + }) ?? undefined, + timeConfig: getTimeConfig(attributes.dimensions), + currentYear, + }) +} + +function getAllExternalLayers( + capabilities: WMTSCapabilitiesResponse, + options?: ExternalLayerParsingOptions +): ExternalWMTSLayer[] { + return getAllCapabilitiesLayers(capabilities) + .map((layer) => getExternalLayer(capabilities, layer, options)) + .filter((layer) => !!layer) +} + +function parse(content: string, originUrl: URL): WMTSCapabilitiesResponse { + const parser = new olWMTSCapabilities() + try { + const capabilities = parser.read(content) as WMTSCapabilitiesResponse + if (!capabilities.version) { + throw new CapabilitiesError( + `No version found in Capabilities ${originUrl.toString()}`, + 'invalid_wmts_capabilities' + ) + } + capabilities.originUrl = originUrl + return capabilities + } catch (error) { + log.error({ + title: 'WMTS Capabilities parser', + titleColor: LogPreDefinedColor.Indigo, + messages: [`Failed to parse capabilities of ${originUrl?.toString()}`, error], + }) + throw new CapabilitiesError( + `Failed to parse WMTS Capabilities: invalid content: ${error?.toString()}`, + 'invalid_wmts_capabilities' + ) + } +} + +export interface ExternalWMTSCapabilitiesParser + extends CapabilitiesParser {} + +export const externalWMTSParser: ExternalWMTSCapabilitiesParser = { + parse, + getAllCapabilitiesLayers, + getCapabilitiesLayer, + getAllExternalLayers, + getExternalLayer, +} + +export default externalWMTSParser diff --git a/packages/geoadmin-layers/src/parsers/__test__/ExternalWMSCapabilitiesParse.spec.ts b/packages/geoadmin-layers/src/parsers/__test__/ExternalWMSCapabilitiesParse.spec.ts new file mode 100644 index 0000000000..1fb124494a --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/__test__/ExternalWMSCapabilitiesParse.spec.ts @@ -0,0 +1,1039 @@ +import type { FlatExtent } from '@geoadmin/coordinates' + +import { LV95, WEBMERCATOR, WGS84 } from '@geoadmin/coordinates' +import { readFile } from 'fs/promises' +import { assertType, beforeAll, describe, expect, expectTypeOf, it } from 'vitest' + +import type { ExternalWMSLayer, LayerAttribution, LayerLegend } from '@/types/layers' + +import externalWMSParser, { + type WMSCapabilitiesResponse, +} from '@/parsers/ExternalWMSCapabilitiesParser' + +describe('WMSCapabilitiesParser - invalid', () => { + it('Throw Error on invalid input', () => { + const invalidContent = 'Invalid input' + expect(() => + externalWMSParser.parse(invalidContent, new URL('https://example.com')) + ).toThrowError(/failed/i) + }) +}) + +describe('WMSCapabilitiesParser of wms-geoadmin-sample.xml', () => { + let capabilities: WMSCapabilitiesResponse + + beforeAll(async () => { + const content = await readFile(`${__dirname}/fixtures/wms-geoadmin-sample.xml`, 'utf8') + capabilities = externalWMSParser.parse(content, new URL('https://wms.geo.admin.ch')) + }) + it('Parse Capabilities', () => { + expect(capabilities.version).to.eql('1.3.0') + expect(capabilities.Capability).to.be.an('object') + expect(capabilities.Service).to.be.an('object') + expect(capabilities.originUrl).toBeInstanceOf(URL) + expect(capabilities.originUrl.toString()).to.eql('https://wms.geo.admin.ch/') + }) + it('Parse layer attributes', () => { + // Base layer + let layer: ExternalWMSLayer | undefined = externalWMSParser.getExternalLayer( + capabilities, + 'wms-bgdi' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('wms-bgdi') + expect(layer!.name).to.eql('WMS BGDI') + expect(layer!.abstract).to.eql('Public Federal Geo Infrastructure (BGDI)') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + + // General layer + layer = externalWMSParser.getExternalLayer(capabilities, 'ch.swisstopo-vd.official-survey') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layer!.name).to.eql('OpenData-AV') + expect(layer!.abstract).to.eql('The official survey (AV).') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + + // Layer without .Name + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('Periodic-Tracking') + expect(layer!.name).to.eql('Periodic-Tracking') + expect(layer!.abstract).to.eql('Layer without Name element should use the Title') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + }) + it('Parse layer attribution', () => { + // Attribution in root layer + let layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('The federal geoportal') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution') + + // Attribution in layer! + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('Periodic-Tracking') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('BGDI') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution-bgdi') + }) + it('Get Layer Extent in LV95', () => { + const externalLayers = externalWMSParser.getAllExternalLayers(capabilities, { + outputProjection: LV95, + }) + + expect(externalLayers).toBeDefined() + assertType(externalLayers) + + // Extent from matching CRS BoundingBox + expect(externalLayers[0].id).to.eql('ch.swisstopo-vd.official-survey') + let expected: FlatExtent = [2100000, 1030000, 2900000, 1400000] + // Here we should not do any re-projection therefore do an exact match + expect(externalLayers[0].extent).toEqual(expected) + + // Extent from non matching CRS BoundingBox + expect(externalLayers[1].id).to.eql('Periodic-Tracking') + expected = [2485071.58, 1075346.31, 2828515.82, 1299941.79] + expect(externalLayers[1].extent!.length).to.eql(4) + expected.forEach((value, index) => { + expect(externalLayers[1].extent![index]).toBeCloseTo(value, 2) + }) + }) + it('Parse layer legend', () => { + // General layer + let layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layer!.abstract).toBeDefined() + expect(layer!.abstract!.length).toBeGreaterThan(0) + expect(layer!.hasDescription).toBeTruthy() + expect(layer!.hasLegend).toBeFalsy() + expect(layer!.legends?.length).to.eql(0) + + // Layer without .Name + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('Periodic-Tracking') + expect(layer!.hasDescription).toBeTruthy() + expect(layer!.hasLegend).toBeTruthy() + expect(layer!.legends?.length).to.eql(1) + + assertType(layer!.legends?.[0]!) + expect(layer!.legends?.[0].url).to.eql( + 'https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetLegend&layer=ch.swisstopo-vd.geometa-periodische_nachfuehrung&format=image/png&STYLE=default' + ) + expect(layer!.legends?.[0].format).to.eql('image/png') + expect(layer!.legends?.[0].width).to.eql(168) + expect(layer!.legends?.[0].height).to.eql(22) + + // Layer without abstract and legend + layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.stand-oerebkataster' + ) + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.stand-oerebkataster') + expect(layer!.hasDescription).toBeFalsy() + expect(layer!.hasLegend).toBeFalsy() + expect(layer!.legends?.length).to.eql(0) + }) +}) + +describe('WMSCapabilitiesParser of wms-geoadmin-sample-sld-enabled.xml', () => { + let capabilities: WMSCapabilitiesResponse + + beforeAll(async () => { + const content = await readFile( + `${__dirname}/fixtures/wms-geoadmin-sample-sld-enabled.xml`, + 'utf8' + ) + capabilities = externalWMSParser.parse(content, new URL('https://wms.geo.admin.ch')) + }) + it('Parse layer attributes', () => { + // Base layer + let layer = externalWMSParser.getExternalLayer(capabilities, 'wms-bgdi') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('wms-bgdi') + expect(layer!.name).to.eql('WMS BGDI') + expect(layer!.abstract).to.eql('Public Federal Geo Infrastructure (BGDI)') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + + // General layer + layer = externalWMSParser.getExternalLayer(capabilities, 'ch.swisstopo-vd.official-survey') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layer!.name).to.eql('OpenData-AV') + expect(layer!.abstract).to.eql('The official survey (AV).') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + + // Layer without .Name + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('Periodic-Tracking') + expect(layer!.name).to.eql('Periodic-Tracking') + expect(layer!.abstract).to.eql('Layer without Name element should use the Title') + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/?') + }) + it('Parse layer attribution', () => { + // Attribution in root layer + let layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + expect(layer!.attributions[0].name).to.eql('The federal geoportal') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution') + + // Attribution in layer + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('Periodic-Tracking') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + expect(layer!.attributions[0].name).to.eql('BGDI') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution-bgdi') + }) + it('Get Layer Extent in LV95', () => { + const externalLayers = externalWMSParser.getAllExternalLayers(capabilities, { + outputProjection: LV95, + }) + // Extent from matching CRS BoundingBox + expect(externalLayers[0].id).to.eql('ch.swisstopo-vd.official-survey') + let expected: FlatExtent = [2100000, 1030000, 2900000, 1400000] + // Here we should not do any re-projection therefore do an exact match + expect(externalLayers[0].extent).toEqual(expected) + + // Extent from non matching CRS BoundingBox + expect(externalLayers[1].id).to.eql('Periodic-Tracking') + expected = [2485071.58, 1075346.31, 2828515.82, 1299941.79] + expect(externalLayers[1].extent).toBeDefined() + expect(externalLayers[1].extent!.length).to.eql(4) + expected.forEach((value, index) => { + expect(externalLayers[1].extent![index]).toBeCloseTo(value, 2) + }) + }) + it('Parse layer legend', () => { + // General layer + let layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layer!.abstract).not.empty + expect(layer!.hasDescription).toBeTruthy() + expect(layer!.hasLegend).toBeTruthy() + expect(layer!.legends).toBeDefined() + expect(layer!.legends!.length).to.eql(1) + expect(layer!.legends![0].url).to.eql( + 'https://wms.geo.admin.ch/?SERVICE=WMS&REQUEST=GetLegendGraphic&VERSION=1.3.0&FORMAT=image%2Fpng&LAYER=ch.swisstopo-vd.official-survey&SLD_VERSION=1.1.0' + ) + + // Layer without .Name + layer = externalWMSParser.getExternalLayer(capabilities, 'Periodic-Tracking') + expect(layer).toBeDefined() + expect(layer!.id).to.eql('Periodic-Tracking') + expect(layer!.hasDescription).toBeTruthy() + expect(layer!.hasLegend).toBeTruthy() + expect(layer!.legends).toBeDefined() + expect(layer!.legends!.length).to.eql(1) + expect(layer!.legends![0]).toBeDefined() + expect(layer!.legends![0].url).to.eql( + 'https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetLegendGraphic&sld_version=1.1.0&layer=ch.swisstopo-vd.geometa-periodische_nachfuehrung&format=image/png&STYLE=default' + ) + expect(layer!.legends![0].format).to.eql('image/png') + expect(layer!.legends![0].width).to.eql(168) + expect(layer!.legends![0].height).to.eql(22) + + // Layer without abstract and legend in styles, but with a SLD enabled legend + layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.stand-oerebkataster' + ) + expect(layer).toBeDefined() + expect(layer!.id).to.eql('ch.swisstopo-vd.stand-oerebkataster') + expect(layer!.hasDescription).toBeTruthy() + expect(layer!.hasLegend).toBeTruthy() + expect(layer!.legends).toBeDefined() + expect(layer!.legends!.length).to.eql(1) + expect(layer!.legends![0].url).to.eql( + 'https://wms.geo.admin.ch/?SERVICE=WMS&REQUEST=GetLegendGraphic&VERSION=1.3.0&FORMAT=image%2Fpng&LAYER=ch.swisstopo-vd.stand-oerebkataster&SLD_VERSION=1.1.0' + ) + }) +}) + +describe('WMSCapabilitiesParser - layer attributes', () => { + it('Parse layer url attribute', () => { + // URL from origin + let content = ` + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + let capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + let layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + expect(layer).toBeDefined() + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/') + + // URL from Capability + content = ` + + + + + text/xml + + + + + + + + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + capabilities = externalWMSParser.parse(content, new URL('https://wms.geo.admin.ch')) + layer = externalWMSParser.getExternalLayer(capabilities, 'ch.swisstopo-vd.official-survey') + expect(layer).toBeDefined() + expect(layer!.baseUrl).to.eql('https://wms.geo.admin.ch/map?') + }) +}) + +describe('WMSCapabilitiesParser - attributions', () => { + it('Parse layer attribution - no attribution in layer, fallback to service (with Title)', () => { + const content = ` + + + WMS + WMS BGDI + + + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + // No attribution, use Service + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('WMS BGDI') + expect(layer!.attributions[0].url).toBeUndefined() + }) + it('Parse layer attribution - no attribution in layer, fallback to service (no Title)', () => { + // No attribution and Service without Title + const content = ` + + + WMS + + + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + // Attribution in service + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('wms.geo.admin.ch') + expect(layer!.attributions[0].url).toBeUndefined() + }) + it('Parse layer attribution - no attribution in layer or service', () => { + // No attribution and no service + const content = ` + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + // Attribution in service + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('wms.geo.admin.ch') + expect(layer!.attributions[0].url).toBeUndefined() + }) + + it('Parse layer attribution - attribution in root layer', () => { + const content = ` + + + WMS + WMS BGDI + + + + + + WMS BGDI + + The federal geoportal + + + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + expectTypeOf(layer!.attributions).toBeArray + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('The federal geoportal') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution') + }) + + it('Parse layer attribution - attribution in layer (with Title)', () => { + const content = ` + + + WMS + WMS BGDI + + + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + BGDI Layer + + + image/png + + + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + // Attribution in layer + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + assertType(layer!.attributions) + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('BGDI Layer') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution-bgdi') + }) + it('Parse layer attribution - attribution in layer (no Title)', () => { + // Attribution without title in layer + const content = ` + + + WMS + WMS BGDI + + + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + assertType(layer!.attributions) + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('www.geo.admin.ch') + expect(layer!.attributions[0].url).to.eql('https://www.geo.admin.ch/attribution-bgdi') + }) + + it('Parse layer attribution - invalid attribution URL', () => { + const content = ` + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + + // No attribution, use Service + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey' + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).to.eql('ch.swisstopo-vd.official-survey') + assertType(layer!.attributions) + expect(layer!.attributions.length).to.eql(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).to.eql('wms.geo.admin.ch') + expect(layer!.attributions[0].url).toBeUndefined() + }) +}) + +describe('WMSCapabilitiesParser - layer extent', () => { + it('Parse layer layer extent from EX_GeographicBoundingBox', () => { + const content = ` + + + + WMS BGDI + + ch.swisstopo-vd.official-survey + OpenData-AV + + 5.96 + 10.49 + 45.82 + 47.81 + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + + const layerLV95 = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: LV95 } + ) + + expect(layerLV95).toBeDefined() + assertType(layerLV95!) + + expect(layerLV95!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layerLV95!.extent).toHaveLength(4) + let expected: FlatExtent = [2485071.58, 1075346.31, 2828515.82, 1299941.79] + + expect(layerLV95!.extent!.length).to.eql(4) + expected.forEach((value, index) => { + expect(layerLV95!.extent![index]).toBeCloseTo(value, 2) + }) + + const layerWGS84 = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: WGS84 } + ) + + expect(layerWGS84).toBeDefined() + expect(layerWGS84!.id).toEqual('ch.swisstopo-vd.official-survey') + expect(layerWGS84!.extent).toHaveLength(4) + expected = [5.96, 45.82, 10.49, 47.81] + // No re-projection expected, therefore, do an exact match + expect(layerWGS84!.extent).toEqual(expected) + + const layerWebMercator = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: WEBMERCATOR } + ) + expect(layerWebMercator).toBeDefined() + expect(layerWebMercator!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layerWebMercator!.extent).toHaveLength(4) + expected = [663464.17, 5751550.87, 1167741.46, 6075303.61] + expected.forEach((value, index) => { + expect(layerWebMercator!.extent![index]).toBeCloseTo(value, 2) + }) + }) + it('Parse layer layer extent from parent layer EX_GeographicBoundingBox', () => { + const content = ` + + + + WMS BGDI + + 5.96 + 10.49 + 45.82 + 47.81 + + + ch.swisstopo-vd.official-survey + OpenData-AV + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + // LV95 + const layerLV95 = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: LV95 } + ) + + expect(layerLV95).toBeDefined() + assertType(layerLV95!) + + expect(layerLV95!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layerLV95!.extent).toHaveLength(4) + let expected: FlatExtent = [2485071.58, 1075346.31, 2828515.82, 1299941.79] + expected.forEach((value, index) => { + expect(layerLV95!.extent![index]).toBeCloseTo(value, 2) + }) + + const layerWGS84 = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: WGS84 } + ) + + expect(layerWGS84).toBeDefined() + assertType(layerWGS84!) + + expect(layerWGS84!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layerWGS84!.extent).toHaveLength(4) + expected = [5.96, 45.82, 10.49, 47.81] + // No re-projection expected therefore do an exact match + expect(layerWGS84!.extent).toEqual(expected) + + const layerWebMercator = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: WEBMERCATOR } + ) + + expect(layerWebMercator).toBeDefined() + expect(layerWebMercator!.id).to.eql('ch.swisstopo-vd.official-survey') + expect(layerWebMercator!.extent).toHaveLength(4) + expected = [663464.17, 5751550.87, 1167741.46, 6075303.61] + expected.forEach((value, index) => { + expect(layerWebMercator!.extent![index]).toBeCloseTo(value, 2) + }) + }) +}) + +describe('EX_GeographicBoundingBox - Group of layers', () => { + it('Parse group of layers - single hierarchy', () => { + const content = ` + + + + WMS BGDI + + 5.96 + 10.49 + 45.82 + 47.81 + + + ch.swisstopo-vd.official-survey + OpenData-AV + + ch.swisstopo-vd.official-survey-1 + OpenData-AV 1 + + + ch.swisstopo-vd.official-survey-2 + OpenData-AV 2 + + + ch.swisstopo-vd.official-survey-3 + OpenData-AV 3 + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + const layers: ExternalWMSLayer[] = externalWMSParser.getAllExternalLayers(capabilities, { + outputProjection: LV95, + }) + + expect(layers).toBeDefined() + assertType(layers) + + expect(layers.length).to.eql(1) + expect(layers[0].id).to.eql('ch.swisstopo-vd.official-survey') + assertType(layers[0]) + + expect(layers[0].layers!.length).to.eql(3) + assertType(layers[0].layers![0]) + expect(layers[0].layers![0].id).to.eql('ch.swisstopo-vd.official-survey-1') + + assertType(layers[0].layers![1]) + expect(layers[0].layers![1].id).to.eql('ch.swisstopo-vd.official-survey-2') + + assertType(layers[0].layers![2]) + expect(layers[0].layers![2].id).to.eql('ch.swisstopo-vd.official-survey-3') + }) + it('Parse group of layers - multiple hierarchy', () => { + const content = ` + + + + WMS BGDI + + 5.96 + 10.49 + 45.82 + 47.81 + + + ch.swisstopo-vd.official-survey + OpenData-AV + + ch.swisstopo-vd.official-survey-1 + OpenData-AV 1 + + + ch.swisstopo-vd.official-survey-2 + OpenData-AV 2 + + + ch.swisstopo-vd.official-survey-3 + OpenData-AV 3 + + ch.swisstopo-vd.official-survey-3-sub-1 + OpenData-AV 3.1 + + + ch.swisstopo-vd.official-survey-3-sub-2 + OpenData-AV 3.2 + + ch.swisstopo-vd.official-survey-3-sub-2-1 + OpenData-AV 3.2.1 + + + ch.swisstopo-vd.official-survey-3-sub-2-2 + OpenData-AV 3.2.2 + + + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + const layers: ExternalWMSLayer[] = externalWMSParser.getAllExternalLayers(capabilities, { + outputProjection: LV95, + }) + + expect(layers).toBeDefined() + expect(layers.length).to.eql(1) + expect(layers[0]).toBeDefined() + expect(layers[0].id).to.eql('ch.swisstopo-vd.official-survey') + expect(layers[0].layers).toBeDefined() + expect(layers[0].layers!.length).to.eql(3) + assertType(layers[0].layers![0]) + expect(layers[0].layers![0].id).to.eql('ch.swisstopo-vd.official-survey-1') + + assertType(layers[0].layers![1]) + expect(layers[0].layers![1].id).to.eql('ch.swisstopo-vd.official-survey-2') + + assertType(layers[0].layers![2]) + expect(layers[0].layers![2].id).to.eql('ch.swisstopo-vd.official-survey-3') + + assertType(layers[0].layers![2].layers![0]) + expect(layers[0].layers![2].layers).toBeDefined() + expect(layers[0].layers![2].layers).toHaveLength(2) + expect(layers[0].layers![2].layers![0].id).to.eql('ch.swisstopo-vd.official-survey-3-sub-1') + + assertType(layers[0].layers![2].layers![1]) + expect(layers[0]?.layers?.[2]?.layers?.[1].id).to.eql( + 'ch.swisstopo-vd.official-survey-3-sub-2' + ) + + assertType(layers[0].layers![2].layers![1].layers![0]) + expect(layers[0]?.layers?.[2]?.layers?.[1].layers?.[0].id).to.eql( + 'ch.swisstopo-vd.official-survey-3-sub-2-1' + ) + + assertType(layers[0].layers![2].layers![1].layers![1]) + expect(layers[0].layers![2].layers![1].layers![1].id).to.eql( + 'ch.swisstopo-vd.official-survey-3-sub-2-2' + ) + }) + it('Search layer in multiple hierarchy', () => { + const content = ` + + + + WMS BGDI + + 5.96 + 10.49 + 45.82 + 47.81 + + + ch.swisstopo-vd.official-survey + OpenData-AV + + ch.swisstopo-vd.official-survey-1 + OpenData-AV 1 + + + ch.swisstopo-vd.official-survey-2 + OpenData-AV 2 + + + ch.swisstopo-vd.official-survey-3 + OpenData-AV 3 + + ch.swisstopo-vd.official-survey-3-sub-1 + OpenData-AV 3.1 + + + ch.swisstopo-vd.official-survey-3-sub-2 + OpenData-AV 3.2 + + ch.swisstopo-vd.official-survey-3-sub-2-1 + OpenData-AV 3.2.1 + + + OpenData-AV 3.2.2 + + + ch.swisstopo-vd.official-survey-3-sub-2-3 + OpenData-AV 3.2.3 + + + + + + + + ` + const capabilities: WMSCapabilitiesResponse = externalWMSParser.parse( + content, + new URL('https://wms.geo.admin.ch') + ) + + // search root layer + const layer = externalWMSParser.getExternalLayer( + capabilities, + 'ch.swisstopo-vd.official-survey', + { outputProjection: LV95 } + ) + expect(layer).toBeDefined() + expect(layer?.id).to.eql('ch.swisstopo-vd.official-survey') + + // // search first hierarchy layer + // layer = externalWMSParser.getExternalLayer( + // capabilities, + // 'ch.swisstopo-vd.official-survey-2', + // { outputProjection: LV95 } + // ) + // expect(layer).toBeDefined() + // expect(layer?.id).to.eql('ch.swisstopo-vd.official-survey-2') + // + // // Search sublayer + // layer = externalWMSParser.getExternalLayer( + // capabilities, + // 'ch.swisstopo-vd.official-survey-3-sub-2-1', + // { outputProjection: LV95 } + // ) + // expect(layer).toBeDefined() + // expect(layer?.id).to.eql('ch.swisstopo-vd.official-survey-3-sub-2-1') + // + // // Search sublayer without name + // layer = externalWMSParser.getExternalLayer(capabilities, 'OpenData-AV 3.2.2', { + // outputProjection: LV95, + // }) + // expect(layer).toBeDefined() + // expect(layer?.id).to.eql('OpenData-AV 3.2.2') + }) +}) diff --git a/packages/geoadmin-layers/src/parsers/__test__/ExternalWMTSCapabilitiesParser.spec.ts b/packages/geoadmin-layers/src/parsers/__test__/ExternalWMTSCapabilitiesParser.spec.ts new file mode 100644 index 0000000000..0835b32538 --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/__test__/ExternalWMTSCapabilitiesParser.spec.ts @@ -0,0 +1,303 @@ +import type { FlatExtent } from '@geoadmin/coordinates' + +import { LV95, WEBMERCATOR, WGS84 } from '@geoadmin/coordinates' +import { readFile } from 'fs/promises' +import { Interval } from 'luxon' +import { assertType, beforeAll, describe, expect, expectTypeOf, it } from 'vitest' + +import type { WMTSCapabilitiesResponse } from '@/parsers/ExternalWMTSCapabilitiesParser' +import type { ExternalWMTSLayer, LayerAttribution, LayerLegend } from '@/types/layers' + +import externalWMTSParser from '@/parsers/ExternalWMTSCapabilitiesParser' +import { timeConfigUtils } from '@/utils' + +describe('WMTSCapabilitiesParser - invalid', () => { + it('Throw Error on invalid input', () => { + const invalidContent = 'Invalid input' + expect(() => + externalWMTSParser.parse(invalidContent, new URL('https://example.com')) + ).toThrowError(/failed/i) + }) +}) + +describe('WMTSCapabilitiesParser of wmts-ogc-sample.xml', () => { + let capabilities: WMTSCapabilitiesResponse + + beforeAll(async () => { + const content = await readFile(`${__dirname}/fixtures/wmts-ogc-sample.xml`, 'utf8') + capabilities = externalWMTSParser.parse(content, new URL('https://example.com')) + }) + + it('Parse Capabilities', () => { + expect(capabilities.version).toBe('1.0.0') + expect(capabilities.Contents).toBeTypeOf('object') + expect(capabilities.OperationsMetadata).toBeTypeOf('object') + expect(capabilities.ServiceIdentification).toBeTypeOf('object') + expect(capabilities.ServiceProvider).toBeTypeOf('object') + expect(capabilities.originUrl).toBeInstanceOf(URL) + expect(capabilities.originUrl.toString()).toBe('https://example.com/') + }) + it('Parse layer attributes', () => { + // General layer + let layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleSecondGenerationAG', + { outputProjection: WGS84 } + ) + expect(layer).toBeDefined() + assertType(layer!) + expect(layer!.id).toBe('BlueMarbleSecondGenerationAG') + expect(layer!.name).toBe('Blue Marble Second Generation - AG') + expect(layer!.abstract).toBe('Blue Marble Second Generation Canton Aargau Product') + expect(layer!.baseUrl).toBe('http://maps.example.com/cgi-bin/map.cgi?') + + // Layer without .Identifier + layer = externalWMTSParser.getExternalLayer(capabilities, 'BlueMarbleThirdGenerationZH', { + outputProjection: WGS84, + }) + expect(layer!.id).toBe('BlueMarbleThirdGenerationZH') + expect(layer!.name).toBe('BlueMarbleThirdGenerationZH') + expect(layer!.abstract).toBe('Blue Marble Third Generation Canton Zürich Product') + expect(layer!.baseUrl).toBe('http://maps.example.com/cgi-bin/map.cgi?') + }) + it('Parse layer attribution', () => { + // General layer + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleSecondGenerationAG', + { outputProjection: WGS84 } + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).toBe('BlueMarbleSecondGenerationAG') + expectTypeOf(layer!.attributions).toBeArray() + expect(layer!.attributions.length).toBe(1) + assertType(layer!.attributions[0]) + expect(layer!.attributions[0].name).toBe('Example') + expect(layer!.attributions[0].url).toBe('http://www.example.com') + }) + it('Get Layer Extent in LV95', () => { + const externalLayers = externalWMTSParser.getAllExternalLayers(capabilities, { + outputProjection: LV95, + }) + expect(externalLayers.length).to.be.greaterThan(1) + + assertType(externalLayers) + + // Extent from WGS84BoundingBox + expect(externalLayers[0].id).toBe('BlueMarbleNextGenerationCH') + let expected: FlatExtent = [2485071.58, 1075346.31, 2828515.82, 1299941.79] + expect(externalLayers[0].extent).to.be.an('Array') + expect(externalLayers[0].extent!.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[0].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from BoundingBox in WGS84 + expect(externalLayers[1].id).toBe('BlueMarbleSecondGenerationAG') + expected = [2627438.37, 1215506.64, 2677504.99, 1277102.76] + expect(externalLayers[1].extent).to.be.an('Array') + expect(externalLayers[1].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[1].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from BoundingBox without CRS + expect(externalLayers[2].id).toBe('BlueMarbleThirdGenerationZH') + expected = [2665255.25, 1229142.44, 2720879.67, 1287842.18] + expect(externalLayers[2].extent).to.be.an('Array') + expect(externalLayers[2].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[2].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from the TileMatrixSet + expect(externalLayers[3].id).toBe('BlueMarbleFourthGenerationJU') + expected = [2552296.05, 1218970.79, 2609136.96, 1266593.74] + expect(externalLayers[3].extent).to.be.an('Array') + expect(externalLayers[3].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[3].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from matching BoundingBox + expect(externalLayers[4].id).toBe('BlueMarbleFifthGenerationGE') + expect(externalLayers[4].extent).toEqual([2484928.06, 1108705.32, 2514614.27, 1130449.26]) + }) + + it('Get Layer Extent in Web Mercator', () => { + const externalLayers = externalWMTSParser.getAllExternalLayers(capabilities, { + outputProjection: WEBMERCATOR, + }) + // Extent from WGS84BoundingBox + expect(externalLayers[0].id).toBe('BlueMarbleNextGenerationCH') + let expected: FlatExtent = [663464.17, 5751550.87, 1167741.46, 6075303.61] + expect(externalLayers[0].extent).to.be.an('Array') + expect(externalLayers[0].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[0].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from BoundingBox in WGS84 + expect(externalLayers[1].id).toBe('BlueMarbleSecondGenerationAG') + expected = [868292.03, 5956776.76, 942876.09, 6047171.27] + expect(externalLayers[1].extent).to.be.an('Array') + expect(externalLayers[1].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[1].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from BoundingBox without CRS + expect(externalLayers[2].id).toBe('BlueMarbleThirdGenerationZH') + expected = [923951.77, 5976419.03, 1007441.39, 6062053.42] + expect(externalLayers[2].extent).to.be.an('Array') + expect(externalLayers[2].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[2].extent![index]).toBeCloseTo(value, 2) + }) + + // Extent from the TileMatrixSet + expect(externalLayers[3].id).toBe('BlueMarbleFourthGenerationJU') + expected = [758085.73, 5961683.17, 841575.35, 6032314.73] + expect(externalLayers[3].extent).to.be.an('Array') + expect(externalLayers[3].extent?.length).toBe(4) + expected.forEach((value, index) => { + expect(externalLayers[3].extent![index]).toBeCloseTo(value, 2) + }) + }) + + it('Get Layer Extent in WGS84', () => { + const externalLayers = externalWMTSParser.getAllExternalLayers(capabilities, { + outputProjection: WGS84, + }) + // Extent from WGS84BoundingBox + expect(externalLayers[0].id).toBe('BlueMarbleNextGenerationCH') + expect(externalLayers[0].extent).toEqual([5.96, 45.82, 10.49, 47.81]) + + // Extent from BoundingBox in WGS84 + expect(externalLayers[1].id).toBe('BlueMarbleSecondGenerationAG') + expect(externalLayers[1].extent).toEqual([7.8, 47.09, 8.47, 47.64]) + + // Extent from BoundingBox without CRS + expect(externalLayers[2].id).toBe('BlueMarbleThirdGenerationZH') + expect(externalLayers[2].extent).toEqual([8.3, 47.21, 9.05, 47.73]) + + // Extent from the TileMatrixSet + expect(externalLayers[3].id).toBe('BlueMarbleFourthGenerationJU') + expect(externalLayers[3].extent).toEqual([6.81, 47.12, 7.56, 47.55]) + }) + it('Parse layer legend', () => { + // General layer + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleSecondGenerationAG', + { outputProjection: WGS84 } + ) + + expect(layer).toBeDefined() + assertType(layer!) + expect(layer!.id).toBe('BlueMarbleSecondGenerationAG') + + expect(layer!.legends).toBeDefined() + assertType(layer!.legends!) + expect(layer!.legends!.length).toBe(1) + assertType(layer!.legends![0]) + + expect(layer!.legends![0].url).toBe( + 'http://www.miramon.uab.es/wmts/Coastlines/coastlines_darkBlue.png' + ) + expect(layer!.legends![0].format).toBe('image/png') + }) + it('Parse layer time dimension in format YYYYMMDD', () => { + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleSecondGenerationAG', + { outputProjection: WGS84 } + ) + + expect(layer).toBeDefined() + assertType(layer!) + + expect(layer!.id).toBe('BlueMarbleSecondGenerationAG') + expect(layer!.timeConfig).toBeDefined() + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '20110805')).toBe(true) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '20081024')).toBe(true) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '20081023')).toBe(false) + + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2008)).toBeDefined() + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2005)).toBeUndefined() + + expect(layer!.timeConfig!.currentTimeEntry).toBeDefined() + expect(layer!.timeConfig?.currentTimeEntry!.timestamp).toBe('20110805') + expect(layer!.timeConfig!.currentTimeEntry!.nonTimeBasedValue).toBeUndefined() + expect(layer!.timeConfig?.currentTimeEntry?.interval?.toISO()).to.eq( + Interval.fromISO('2011-08-05/P1D').toISO() + ) + }) + it('Parse layer time dimension in format ISO format YYYY-MM-DD', () => { + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleThirdGenerationZH', + { outputProjection: WGS84 } + ) + expect(layer!.id).toBe('BlueMarbleThirdGenerationZH') + expect(layer!.timeConfig).toBeDefined() + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2011-08-05')).toBe(true) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2008-10-24')).toBe(true) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2008-10-23')).toBe(false) + + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2008)).toBeDefined() + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2005)).toBeUndefined() + + expect(layer!.timeConfig!.currentTimeEntry).toBeDefined() + expect(layer!.timeConfig!.currentTimeEntry!.timestamp).toBe('2011-08-05') + expect(layer!.timeConfig!.currentTimeEntry!.nonTimeBasedValue).toBeUndefined() + expect(layer!.timeConfig!.currentTimeEntry!.interval?.toISO()).to.eq( + Interval.fromISO('2011-08-05/P1D').toISO() + ) + }) + it('Parse layer time dimension in format full ISO format YYYY-MM-DDTHH:mm:ss.sssZ', () => { + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleFourthGenerationJU', + { outputProjection: WGS84 } + ) + expect(layer!.id).toBe('BlueMarbleFourthGenerationJU') + expect(layer!.timeConfig).toBeDefined() + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2011-08-05T01:20:34.345Z')).toBe( + true + ) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2008-10-24T01:20:34.345Z')).toBe( + true + ) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, '2008-10-23T01:20:34.345Z')).toBe( + false + ) + + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2008)).toBeDefined() + expect(timeConfigUtils.getTimeEntryForYear(layer!.timeConfig!, 2005)).toBeUndefined() + + expect(layer!.timeConfig!.currentTimeEntry).toBeDefined() + expect(layer!.timeConfig!.currentTimeEntry!.timestamp).toBe('2011-08-05T01:20:34.345Z') + expect(layer!.timeConfig!.currentTimeEntry!.nonTimeBasedValue).toBeUndefined() + expect(layer!.timeConfig!.currentTimeEntry!.interval?.toISO()).to.eq( + Interval.fromISO('2011-08-05/P1D').toISO() + ) + }) + it('Parse layer time dimension in unknown format', () => { + const layer = externalWMTSParser.getExternalLayer( + capabilities, + 'BlueMarbleFifthGenerationGE', + { outputProjection: WGS84 } + ) + expect(layer!.id).toBe('BlueMarbleFifthGenerationGE') + expect(layer!.timeConfig).toBeDefined() + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, 'Time A')).toBe(true) + expect(timeConfigUtils.hasTimestamp(layer!.timeConfig!, 'Time B')).toBe(true) + + expect(layer!.timeConfig!.currentTimeEntry).toBeDefined() + expect(layer!.timeConfig!.currentTimeEntry!.timestamp).toBe('Time A') + }) +}) diff --git a/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS1.json b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS1.json new file mode 100644 index 0000000000..aff4255cde --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS1.json @@ -0,0 +1,577 @@ +{ + "ServiceIdentification": { + "Title": "WMTS / Amt für Geoinformation Kanton Solothurn", + "Abstract": "None", + "ServiceType": "OGC WMTS", + "ServiceTypeVersion": "1.0.0" + }, + "ServiceProvider": { "ServiceContact": { "ContactInfo": {} } }, + "OperationsMetadata": { + "GetCapabilities": { + "DCP": { + "HTTP": { + "Get": [ + { + "href": "https://geo.so.ch/api/wmts?", + "Constraint": [ + { "name": "GetEncoding", "AllowedValues": { "Value": ["KVP"] } } + ] + } + ] + } + } + }, + "GetTile": { + "DCP": { + "HTTP": { + "Get": [ + { + "href": "https://geo.so.ch/api/wmts?", + "Constraint": [ + { "name": "GetEncoding", "AllowedValues": { "Value": ["KVP"] } } + ] + } + ] + } + } + }, + "GetFeatureInfo": { + "DCP": { + "HTTP": { + "Get": [ + { + "href": "https://geo.so.ch/api/wmts?", + "Constraint": [ + { "name": "GetEncoding", "AllowedValues": { "Value": ["KVP"] } } + ] + } + ] + } + } + } + }, + "version": "1.0.0", + "Contents": { + "Layer": [ + { + "Title": "ch.so.agi.hintergrundkarte_sw", + "Identifier": "ch.so.agi.hintergrundkarte_sw", + "Style": [{ "Identifier": "default", "isDefault": true }], + "Format": ["image/png"], + "TileMatrixSetLink": [ + { + "TileMatrixSet": "2056", + "TileMatrixSetLimits": [ + { + "TileMatrix": "2056:0", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:1", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:2", + "MinTileRow": 0, + "MaxTileRow": 1, + "MinTileCol": 0, + "MaxTileCol": 1 + }, + { + "TileMatrix": "2056:3", + "MinTileRow": 0, + "MaxTileRow": 2, + "MinTileCol": 0, + "MaxTileCol": 3 + }, + { + "TileMatrix": "2056:4", + "MinTileRow": 0, + "MaxTileRow": 4, + "MinTileCol": 0, + "MaxTileCol": 7 + }, + { + "TileMatrix": "2056:5", + "MinTileRow": 0, + "MaxTileRow": 10, + "MinTileCol": 0, + "MaxTileCol": 14 + }, + { + "TileMatrix": "2056:6", + "MinTileRow": 1, + "MaxTileRow": 16, + "MinTileCol": 6, + "MaxTileCol": 24 + }, + { + "TileMatrix": "2056:7", + "MinTileRow": 11, + "MaxTileRow": 32, + "MinTileCol": 24, + "MaxTileCol": 53 + }, + { + "TileMatrix": "2056:8", + "MinTileRow": 27, + "MaxTileRow": 60, + "MinTileCol": 53, + "MaxTileCol": 101 + }, + { + "TileMatrix": "2056:9", + "MinTileRow": 59, + "MaxTileRow": 115, + "MinTileCol": 112, + "MaxTileCol": 197 + }, + { + "TileMatrix": "2056:10", + "MinTileRow": 123, + "MaxTileRow": 226, + "MinTileCol": 229, + "MaxTileCol": 390 + }, + { + "TileMatrix": "2056:11", + "MinTileRow": 315, + "MaxTileRow": 559, + "MinTileCol": 580, + "MaxTileCol": 969 + }, + { + "TileMatrix": "2056:12", + "MinTileRow": 635, + "MaxTileRow": 1114, + "MinTileCol": 1166, + "MaxTileCol": 1934 + }, + { + "TileMatrix": "2056:13", + "MinTileRow": 1276, + "MaxTileRow": 2223, + "MinTileCol": 2338, + "MaxTileCol": 3864 + }, + { + "TileMatrix": "2056:14", + "MinTileRow": 3198, + "MaxTileRow": 5551, + "MinTileCol": 5854, + "MaxTileCol": 9653 + } + ] + } + ], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://geo.so.ch/api/wmts/1.0.0/ch.so.agi.hintergrundkarte_sw/default/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "ch.so.agi.hintergrundkarte_farbig", + "Identifier": "ch.so.agi.hintergrundkarte_farbig", + "Style": [{ "Identifier": "default", "isDefault": true }], + "Format": ["image/png"], + "TileMatrixSetLink": [ + { + "TileMatrixSet": "2056", + "TileMatrixSetLimits": [ + { + "TileMatrix": "2056:0", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:1", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:2", + "MinTileRow": 0, + "MaxTileRow": 1, + "MinTileCol": 0, + "MaxTileCol": 1 + }, + { + "TileMatrix": "2056:3", + "MinTileRow": 0, + "MaxTileRow": 2, + "MinTileCol": 0, + "MaxTileCol": 3 + }, + { + "TileMatrix": "2056:4", + "MinTileRow": 0, + "MaxTileRow": 4, + "MinTileCol": 0, + "MaxTileCol": 7 + }, + { + "TileMatrix": "2056:5", + "MinTileRow": 0, + "MaxTileRow": 10, + "MinTileCol": 0, + "MaxTileCol": 14 + }, + { + "TileMatrix": "2056:6", + "MinTileRow": 1, + "MaxTileRow": 16, + "MinTileCol": 6, + "MaxTileCol": 24 + }, + { + "TileMatrix": "2056:7", + "MinTileRow": 11, + "MaxTileRow": 32, + "MinTileCol": 24, + "MaxTileCol": 53 + }, + { + "TileMatrix": "2056:8", + "MinTileRow": 27, + "MaxTileRow": 60, + "MinTileCol": 53, + "MaxTileCol": 101 + }, + { + "TileMatrix": "2056:9", + "MinTileRow": 59, + "MaxTileRow": 115, + "MinTileCol": 112, + "MaxTileCol": 197 + }, + { + "TileMatrix": "2056:10", + "MinTileRow": 123, + "MaxTileRow": 226, + "MinTileCol": 229, + "MaxTileCol": 390 + }, + { + "TileMatrix": "2056:11", + "MinTileRow": 315, + "MaxTileRow": 559, + "MinTileCol": 580, + "MaxTileCol": 969 + }, + { + "TileMatrix": "2056:12", + "MinTileRow": 635, + "MaxTileRow": 1114, + "MinTileCol": 1166, + "MaxTileCol": 1934 + }, + { + "TileMatrix": "2056:13", + "MinTileRow": 1276, + "MaxTileRow": 2223, + "MinTileCol": 2338, + "MaxTileCol": 3864 + }, + { + "TileMatrix": "2056:14", + "MinTileRow": 3198, + "MaxTileRow": 5551, + "MinTileCol": 5854, + "MaxTileCol": 9653 + } + ] + } + ], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://geo.so.ch/api/wmts/1.0.0/ch.so.agi.hintergrundkarte_farbig/default/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "ch.so.agi.hintergrundkarte_ortho", + "Identifier": "ch.so.agi.hintergrundkarte_ortho", + "Style": [{ "Identifier": "default", "isDefault": true }], + "Format": ["image/jpeg"], + "TileMatrixSetLink": [ + { + "TileMatrixSet": "2056", + "TileMatrixSetLimits": [ + { + "TileMatrix": "2056:0", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:1", + "MinTileRow": 0, + "MaxTileRow": 0, + "MinTileCol": 0, + "MaxTileCol": 0 + }, + { + "TileMatrix": "2056:2", + "MinTileRow": 0, + "MaxTileRow": 1, + "MinTileCol": 0, + "MaxTileCol": 1 + }, + { + "TileMatrix": "2056:3", + "MinTileRow": 0, + "MaxTileRow": 2, + "MinTileCol": 0, + "MaxTileCol": 3 + }, + { + "TileMatrix": "2056:4", + "MinTileRow": 0, + "MaxTileRow": 4, + "MinTileCol": 0, + "MaxTileCol": 7 + }, + { + "TileMatrix": "2056:5", + "MinTileRow": 0, + "MaxTileRow": 10, + "MinTileCol": 0, + "MaxTileCol": 14 + }, + { + "TileMatrix": "2056:6", + "MinTileRow": 1, + "MaxTileRow": 16, + "MinTileCol": 6, + "MaxTileCol": 24 + }, + { + "TileMatrix": "2056:7", + "MinTileRow": 11, + "MaxTileRow": 32, + "MinTileCol": 24, + "MaxTileCol": 53 + }, + { + "TileMatrix": "2056:8", + "MinTileRow": 27, + "MaxTileRow": 60, + "MinTileCol": 53, + "MaxTileCol": 101 + }, + { + "TileMatrix": "2056:9", + "MinTileRow": 59, + "MaxTileRow": 115, + "MinTileCol": 112, + "MaxTileCol": 197 + }, + { + "TileMatrix": "2056:10", + "MinTileRow": 123, + "MaxTileRow": 226, + "MinTileCol": 229, + "MaxTileCol": 390 + }, + { + "TileMatrix": "2056:11", + "MinTileRow": 315, + "MaxTileRow": 559, + "MinTileCol": 580, + "MaxTileCol": 969 + }, + { + "TileMatrix": "2056:12", + "MinTileRow": 635, + "MaxTileRow": 1114, + "MinTileCol": 1166, + "MaxTileCol": 1934 + }, + { + "TileMatrix": "2056:13", + "MinTileRow": 1276, + "MaxTileRow": 2223, + "MinTileCol": 2338, + "MaxTileCol": 3864 + }, + { + "TileMatrix": "2056:14", + "MinTileRow": 3198, + "MaxTileRow": 5551, + "MinTileCol": 5854, + "MaxTileCol": 9653 + } + ] + } + ], + "ResourceURL": [ + { + "format": "image/jpeg", + "template": "https://geo.so.ch/api/wmts/1.0.0/ch.so.agi.hintergrundkarte_ortho/default/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}.jpg", + "resourceType": "tile" + } + ] + } + ], + "TileMatrixSet": [ + { + "Identifier": "2056", + "BoundingBox": [2420000, 1030000, 2900000, 1350000], + "SupportedCRS": "urn:ogc:def:crs:EPSG:6.3:2056", + "TileMatrix": [ + { + "Identifier": "0", + "ScaleDenominator": 14285714.285714287, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "1", + "ScaleDenominator": 7142857.142857144, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "2", + "ScaleDenominator": 3571428.571428572, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 2 + }, + { + "Identifier": "3", + "ScaleDenominator": 1785714.285714286, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 4, + "MatrixHeight": 3 + }, + { + "Identifier": "4", + "ScaleDenominator": 892857.142857143, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 8, + "MatrixHeight": 5 + }, + { + "Identifier": "5", + "ScaleDenominator": 357142.85714285716, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 19, + "MatrixHeight": 13 + }, + { + "Identifier": "6", + "ScaleDenominator": 178571.42857142858, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 38, + "MatrixHeight": 25 + }, + { + "Identifier": "7", + "ScaleDenominator": 71428.57142857143, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 94, + "MatrixHeight": 63 + }, + { + "Identifier": "8", + "ScaleDenominator": 35714.28571428572, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 188, + "MatrixHeight": 125 + }, + { + "Identifier": "9", + "ScaleDenominator": 17857.14285714286, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 375, + "MatrixHeight": 250 + }, + { + "Identifier": "10", + "ScaleDenominator": 8928.57142857143, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 750, + "MatrixHeight": 500 + }, + { + "Identifier": "11", + "ScaleDenominator": 3571.4285714285716, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1875, + "MatrixHeight": 1250 + }, + { + "Identifier": "12", + "ScaleDenominator": 1785.7142857142858, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 3750, + "MatrixHeight": 2500 + }, + { + "Identifier": "13", + "ScaleDenominator": 892.8571428571429, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 7500, + "MatrixHeight": 5000 + }, + { + "Identifier": "14", + "ScaleDenominator": 357.14285714285717, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 18750, + "MatrixHeight": 12500 + } + ] + } + ] + } +} diff --git a/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS2.json b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS2.json new file mode 100644 index 0000000000..913f373e68 --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS2.json @@ -0,0 +1,264 @@ +{ + "ServiceIdentification": { + "Title": "openstreetmap.org", + "Abstract": "OpenStreetMap is an initiative to create and provide free geographic data, such as street maps, to anyone.", + "ServiceType": "OGC WMTS", + "ServiceTypeVersion": "1.0.0", + "Fees": "none", + "AccessConstraints": "https://operations.osmfoundation.org/policies/tiles/" + }, + "version": "1.0.0", + "Contents": { + "Layer": [ + { + "Title": "OpenStreetMap", + "Abstract": "Standard OSM Layer rendered using openstreetmap-carto and Mapnik", + "Identifier": "mapnik", + "Style": [{ "Identifier": "mapnik", "isDefault": true }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "google3857" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://a.tile.openstreetmap.org/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + }, + { + "format": "image/png", + "template": "https://b.tile.openstreetmap.org/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + }, + { + "format": "image/png", + "template": "https://c.tile.openstreetmap.org/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "Humanitarian", + "Abstract": "Humanitarian OpenStreetMap Team", + "Identifier": "hot", + "Style": [{ "Identifier": "hot", "isDefault": true }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "google3857" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://tile-a.openstreetmap.fr/{Style}{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + }, + { + "format": "image/png", + "template": "https://tile-b.openstreetmap.fr/{Style}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + }, + { + "format": "image/png", + "template": "https://tile-c.openstreetmap.fr/{Style}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + } + ], + "TileMatrixSet": [ + { + "Identifier": "google3857", + "BoundingBox": [977650, 5838030, 1913530, 6281290], + "SupportedCRS": "urn:ogc:def:crs:EPSG:6.18.3:3857", + "WellKnownScaleSet": "urn:ogc:def:wkss:OGC:1.0:GoogleMapsCompatible", + "TileMatrix": [ + { + "Identifier": "0", + "ScaleDenominator": 559082264.029, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "1", + "ScaleDenominator": 279541132.015, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 2 + }, + { + "Identifier": "2", + "ScaleDenominator": 139770566.007, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 4, + "MatrixHeight": 4 + }, + { + "Identifier": "3", + "ScaleDenominator": 69885283.0036, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 8, + "MatrixHeight": 8 + }, + { + "Identifier": "4", + "ScaleDenominator": 34942641.5018, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 16, + "MatrixHeight": 16 + }, + { + "Identifier": "5", + "ScaleDenominator": 17471320.7509, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 32, + "MatrixHeight": 32 + }, + { + "Identifier": "6", + "ScaleDenominator": 8735660.37545, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 64, + "MatrixHeight": 64 + }, + { + "Identifier": "7", + "ScaleDenominator": 4367830.18773, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 128, + "MatrixHeight": 128 + }, + { + "Identifier": "8", + "ScaleDenominator": 2183915.09386, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 256, + "MatrixHeight": 256 + }, + { + "Identifier": "9", + "ScaleDenominator": 1091957.54693, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 512, + "MatrixHeight": 512 + }, + { + "Identifier": "10", + "ScaleDenominator": 545978.773466, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1024, + "MatrixHeight": 1024 + }, + { + "Identifier": "11", + "ScaleDenominator": 272989.386733, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2048, + "MatrixHeight": 2048 + }, + { + "Identifier": "12", + "ScaleDenominator": 136494.693366, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 4096, + "MatrixHeight": 4096 + }, + { + "Identifier": "13", + "ScaleDenominator": 68247.3466832, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 8192, + "MatrixHeight": 8192 + }, + { + "Identifier": "14", + "ScaleDenominator": 34123.6733416, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 16384, + "MatrixHeight": 16384 + }, + { + "Identifier": "15", + "ScaleDenominator": 17061.8366708, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 32768, + "MatrixHeight": 32768 + }, + { + "Identifier": "16", + "ScaleDenominator": 8530.9183354, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 65536, + "MatrixHeight": 65536 + }, + { + "Identifier": "17", + "ScaleDenominator": 4265.4591677, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 131072, + "MatrixHeight": 131072 + }, + { + "Identifier": "18", + "ScaleDenominator": 2132.72958385, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 262144, + "MatrixHeight": 262144 + }, + { + "Identifier": "19", + "ScaleDenominator": 1066.36479193, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 524288, + "MatrixHeight": 524288 + }, + { + "Identifier": "20", + "ScaleDenominator": 533.18239597, + "TopLeftCorner": [-20037508.3428, 20037508.3428], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1048576, + "MatrixHeight": 1048576 + } + ] + } + ] + } +} diff --git a/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS3.json b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS3.json new file mode 100644 index 0000000000..aa65972c5e --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/__test__/fixtures/externalWMTS3.json @@ -0,0 +1,428 @@ +{ + "ServiceIdentification": { + "Title": "Geodati relativi al territorio del Canton Ticino", + "Abstract": "Geodati di base relativi al territorio della Repubblica e Canton Ticino esposti tramite geoservizi WMTS. L'organizzazione dei geodati di base è secondo le geocategorie definite nella norma eCH0166. I geodati di base vengono offerti secondo i servizi, di rappresentazione (WMTS), definiti dal Regolamento della legge cantonale sulla geoinformazione.", + "ServiceType": "OGC WMTS", + "ServiceTypeVersion": "1.0.0", + "Fees": "None", + "AccessConstraints": "Richiesta formale a ccgeo@ti.ch" + }, + "ServiceProvider": { + "ProviderName": "Repubblica e Cantone Ticino", + "ProviderSite": "https://wmts.geo.ti.ch/wmts/1.0.0/WMTSCapabilities.xml", + "ServiceContact": { + "IndividualName": "Ufficio della geomatica", + "PositionName": "Point of contact", + "ContactInfo": { + "Phone": { "Voice": "+41(91)814 26 15", "Facsimile": "+41(91)814 25 29" }, + "Address": { + "DeliveryPoint": "Repubblica e Cantone Ticino", + "City": "Bellinzona", + "PostalCode": "6500", + "Country": "Switzerland", + "ElectronicMailAddress": "ccgeo@ti.ch" + } + } + } + }, + "version": "1.0.0", + "Contents": { + "Layer": [ + { + "Title": "[CH-051.1] Piano Registro Fondiario del Catasto RDPP", + "Abstract": "", + "WGS84BoundingBox": [ + 7.9359243792646, 45.57506921466782, 9.599455051795623, 46.84176195936446 + ], + "Identifier": "ch_051_1_v1_7_piano_registro_fondiario_crdpp", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ch_051_1_v1_7_piano_registro_fondiario_crdpp/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "[CH-051.1] Piano Registro Fondiario (colore)", + "Abstract": "", + "WGS84BoundingBox": [ + 7.9359243792646, 45.57506921466782, 9.599455051795623, 46.84176195936446 + ], + "Identifier": "ch_051_1_v1_7_piano_registro_fondiario_colore", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ch_051_1_v1_7_piano_registro_fondiario_colore/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "[CH-051.1] Piano Registro Fondiario (bianco e nero)", + "Abstract": "", + "WGS84BoundingBox": [ + 7.9359243792646, 45.57506921466782, 9.599455051795623, 46.84176195936446 + ], + "Identifier": "ch_051_1_v1_7_piano_registro_fondiario_bn", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ch_051_1_v1_7_piano_registro_fondiario_bn/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "[CH-052.1] Piano di base–MU–CH (a colori)", + "Abstract": "", + "WGS84BoundingBox": [ + 7.9359243792646, 45.57506921466782, 9.599455051795623, 46.84176195936446 + ], + "Identifier": "ch_052_1_v24_0_piano_base_mu_ch_colore", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ch_052_1_v24_0_piano_base_mu_ch_colore/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "[CH-052.1] Piano di base–MU–CH (bianco e nero)", + "Abstract": "", + "WGS84BoundingBox": [ + 7.9359243792646, 45.57506921466782, 9.599455051795623, 46.84176195936446 + ], + "Identifier": "ch_052_1_v24_0_piano_base_mu_ch_bn", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ch_052_1_v24_0_piano_base_mu_ch_bn/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + }, + { + "Title": "[AC-045.1] Piano corografico", + "Abstract": "", + "WGS84BoundingBox": [ + 8.343252466773158, 45.796442876200864, 9.22819490785335, 46.65791228406806 + ], + "Identifier": "ac_045_1_v1_1_piani_corografici", + "Style": [{ "Identifier": "default", "isDefault": false }], + "Format": ["image/png"], + "TileMatrixSetLink": [{ "TileMatrixSet": "ch95_grid" }], + "ResourceURL": [ + { + "format": "image/png", + "template": "https://wmts.geo.ti.ch/wmts/ac_045_1_v1_1_piani_corografici/{TileMatrixSet}/{TileMatrix}/{TileCol}/{TileRow}.png", + "resourceType": "tile" + } + ] + } + ], + "TileMatrixSet": [ + { + "Identifier": "ch95_grid", + "SupportedCRS": "EPSG:2056", + "TileMatrix": [ + { + "Identifier": "00", + "ScaleDenominator": 14285714.285714284, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "01", + "ScaleDenominator": 13392857.142857142, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "02", + "ScaleDenominator": 12499999.999999998, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "03", + "ScaleDenominator": 11607142.857142856, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "04", + "ScaleDenominator": 10714285.714285713, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "05", + "ScaleDenominator": 9821428.57142857, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "06", + "ScaleDenominator": 8928571.428571427, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "07", + "ScaleDenominator": 8035714.2857142845, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "08", + "ScaleDenominator": 7142857.142857142, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1, + "MatrixHeight": 1 + }, + { + "Identifier": "09", + "ScaleDenominator": 6249999.999999999, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 1 + }, + { + "Identifier": "10", + "ScaleDenominator": 5357142.857142856, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 1 + }, + { + "Identifier": "11", + "ScaleDenominator": 4464285.714285714, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 1 + }, + { + "Identifier": "12", + "ScaleDenominator": 3571428.571428571, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 2, + "MatrixHeight": 2 + }, + { + "Identifier": "13", + "ScaleDenominator": 2678571.428571428, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 3, + "MatrixHeight": 2 + }, + { + "Identifier": "14", + "ScaleDenominator": 2321428.5714285714, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 4, + "MatrixHeight": 2 + }, + { + "Identifier": "15", + "ScaleDenominator": 1785714.2857142854, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 4, + "MatrixHeight": 3 + }, + { + "Identifier": "16", + "ScaleDenominator": 892857.1428571427, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 8, + "MatrixHeight": 5 + }, + { + "Identifier": "17", + "ScaleDenominator": 357142.8571428571, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 20, + "MatrixHeight": 13 + }, + { + "Identifier": "18", + "ScaleDenominator": 178571.42857142855, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 40, + "MatrixHeight": 25 + }, + { + "Identifier": "19", + "ScaleDenominator": 71428.57142857142, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 98, + "MatrixHeight": 63 + }, + { + "Identifier": "20", + "ScaleDenominator": 35714.28571428571, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 196, + "MatrixHeight": 125 + }, + { + "Identifier": "21", + "ScaleDenominator": 17857.142857142855, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 391, + "MatrixHeight": 250 + }, + { + "Identifier": "22", + "ScaleDenominator": 8928.571428571428, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 782, + "MatrixHeight": 500 + }, + { + "Identifier": "23", + "ScaleDenominator": 7142.857142857142, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 977, + "MatrixHeight": 625 + }, + { + "Identifier": "24", + "ScaleDenominator": 5357.142857142857, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1303, + "MatrixHeight": 834 + }, + { + "Identifier": "25", + "ScaleDenominator": 3571.428571428571, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 1954, + "MatrixHeight": 1250 + }, + { + "Identifier": "26", + "ScaleDenominator": 1785.7142857142856, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 3907, + "MatrixHeight": 2500 + }, + { + "Identifier": "27", + "ScaleDenominator": 892.8571428571428, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 7813, + "MatrixHeight": 5000 + }, + { + "Identifier": "28", + "ScaleDenominator": 446.4285714285714, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 15625, + "MatrixHeight": 10000 + }, + { + "Identifier": "29", + "ScaleDenominator": 357.1428571428571, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 19532, + "MatrixHeight": 12500 + }, + { + "Identifier": "30", + "ScaleDenominator": 223.2142857142857, + "TopLeftCorner": [2420000, 1350000], + "TileWidth": 256, + "TileHeight": 256, + "MatrixWidth": 31250, + "MatrixHeight": 20000 + } + ] + } + ] + } +} diff --git a/packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample-sld-enabled.xml b/packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample-sld-enabled.xml similarity index 75% rename from packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample-sld-enabled.xml rename to packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample-sld-enabled.xml index ed72ffbac1..e458c94ab8 100644 --- a/packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample-sld-enabled.xml +++ b/packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample-sld-enabled.xml @@ -1,11 +1,11 @@ @@ -18,8 +18,8 @@ BGDI Geodaten @@ -50,14 +50,14 @@ @@ -74,14 +74,14 @@ @@ -99,14 +99,14 @@ @@ -118,14 +118,14 @@ @@ -138,14 +138,14 @@ @@ -157,14 +157,14 @@ @@ -177,12 +177,12 @@ BLANK wms-bgdi @@ -211,34 +211,34 @@ 48.7511 The federal geoportal image/png ch.swisstopo-vd.official-survey OpenData-AV @@ -266,26 +266,26 @@ 48.7511 text/xml Periodic-Tracking Layer without Name element should use the Title @@ -313,58 +313,58 @@ 47.81 BGDI image/png text/xml ch.swisstopo-vd.ortschaftenverzeichnis_plz PLZ und Ortschaften @@ -394,40 +394,40 @@ 48.7511 text/xml ch.swisstopo-vd.spannungsarme-gebiete Spannungsarme Gebiete @@ -456,32 +456,32 @@ 48.7511 text/xml diff --git a/packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample.xml b/packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample.xml similarity index 77% rename from packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample.xml rename to packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample.xml index 40c127c998..a9920a4471 100644 --- a/packages/mapviewer/src/api/layers/__tests__/wms-geoadmin-sample.xml +++ b/packages/geoadmin-layers/src/parsers/__test__/fixtures/wms-geoadmin-sample.xml @@ -1,10 +1,10 @@ @@ -16,8 +16,8 @@ BGDI Geodaten @@ -48,14 +48,14 @@ @@ -72,14 +72,14 @@ @@ -97,14 +97,14 @@ @@ -116,14 +116,14 @@ @@ -162,34 +162,34 @@ 48.7511 The federal geoportal image/png ch.swisstopo-vd.official-survey OpenData-AV @@ -217,26 +217,26 @@ 48.7511 text/xml Periodic-Tracking Layer without Name element should use the Title @@ -264,58 +264,58 @@ 47.81 BGDI image/png text/xml ch.swisstopo-vd.ortschaftenverzeichnis_plz PLZ und Ortschaften @@ -345,40 +345,40 @@ 48.7511 text/xml ch.swisstopo-vd.spannungsarme-gebiete Spannungsarme Gebiete @@ -407,32 +407,32 @@ 48.7511 text/xml diff --git a/packages/mapviewer/src/api/layers/__tests__/wmts-ogc-sample.xml b/packages/geoadmin-layers/src/parsers/__test__/fixtures/wmts-ogc-sample.xml similarity index 100% rename from packages/mapviewer/src/api/layers/__tests__/wmts-ogc-sample.xml rename to packages/geoadmin-layers/src/parsers/__test__/fixtures/wmts-ogc-sample.xml diff --git a/packages/geoadmin-layers/src/parsers/index.ts b/packages/geoadmin-layers/src/parsers/index.ts new file mode 100644 index 0000000000..67abddb25e --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/index.ts @@ -0,0 +1,10 @@ +import { registerProj4 } from '@geoadmin/coordinates' +import { register } from 'ol/proj/proj4' +import proj4 from 'proj4' + +export * from './ExternalWMSCapabilitiesParser' +export * from './ExternalWMTSCapabilitiesParser' + +registerProj4(proj4) +// register any custom projection in OpenLayers +register(proj4) diff --git a/packages/geoadmin-layers/src/parsers/parser.ts b/packages/geoadmin-layers/src/parsers/parser.ts new file mode 100644 index 0000000000..b58954fcfb --- /dev/null +++ b/packages/geoadmin-layers/src/parsers/parser.ts @@ -0,0 +1,52 @@ +import type { CoordinateSystem } from '@geoadmin/coordinates' + +export interface ExternalLayerParsingOptions { + /** + * Tells which coordinate system / projection should be used to describe all geographical values + * in the parsed external layer (i.e., extent) + */ + outputProjection?: CoordinateSystem + /** If true, won't throw exceptions in case of error but return a default value or undefined */ + ignoreErrors?: boolean + /** + * Sets of values to assign to the resulting external layer(s). This can be used to set a + * layer's visibility or opacity after parsing, without using its default values + */ + initialValues?: Partial +} + +export interface CapabilitiesParser< + CapabilitiesResponseType, + CapabilitiesLayerType, + ExternalLayerType, +> { + /** + * Parse all capabilities (layers, services, etc...) from this server. + * + * @param content XML describing this server (either WMS or WMTS getCapabilities response) + * @param originUrl URL used to gather the getCapabilities XML response, will be used to set the + * attribution for layers, if the server lacks any self-described attribution in its response + */ + parse(content: string, originUrl?: URL): CapabilitiesResponseType + + /** @param capabilities Object parsed previously by the function {@link #parse} from this parser */ + getAllCapabilitiesLayers(capabilities: CapabilitiesResponseType): CapabilitiesLayerType[] + + /** + * @param capabilities Object parsed previously by the function {@link #parse} from this parser + * @param layerId Identifier for the layer we are looking for + */ + getCapabilitiesLayer( + capabilities: CapabilitiesResponseType, + layerId: string + ): CapabilitiesLayerType | undefined + getAllExternalLayers( + capabilities: CapabilitiesResponseType, + options?: ExternalLayerParsingOptions + ): ExternalLayerType[] + getExternalLayer( + capabilities: CapabilitiesResponseType, + layerOrLayerId: CapabilitiesLayerType | string, + options?: ExternalLayerParsingOptions + ): ExternalLayerType | undefined +} diff --git a/packages/geoadmin-layers/src/types/index.ts b/packages/geoadmin-layers/src/types/index.ts new file mode 100644 index 0000000000..f0c05e648d --- /dev/null +++ b/packages/geoadmin-layers/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './layers' +export * from './timeConfig' diff --git a/packages/geoadmin-layers/src/types/layers.ts b/packages/geoadmin-layers/src/types/layers.ts new file mode 100644 index 0000000000..1fa2041614 --- /dev/null +++ b/packages/geoadmin-layers/src/types/layers.ts @@ -0,0 +1,454 @@ +import type { SingleCoordinate, FlatExtent } from '@geoadmin/coordinates' +import type { Options } from 'ol/source/WMTS' + +import { CoordinateSystem } from '@geoadmin/coordinates' +import { ErrorMessage, WarningMessage } from '@geoadmin/log/Message' + +import type { WMSRequestCapabilities } from '@/parsers' +import type { LayerTimeConfig } from '@/types/timeConfig' + +export const DEFAULT_OPACITY = 1.0 +export const WMS_SUPPORTED_VERSIONS = ['1.3.0'] + +export enum LayerType { + WMTS = 'WMTS', + WMS = 'WMS', + GEOJSON = 'GEOJSON', + AGGREGATE = 'AGGREGATE', + KML = 'KML', + GPX = 'GPX', + VECTOR = 'VECTOR', + GROUP = 'GROUP', + COG = 'COG', +} + +export interface LayerAttribution { + name: string + url?: string +} + +export interface LayerLegend { + url: string + format: string + width?: number + height?: number +} + +export interface LayerCustomAttributes { + /** + * Selected year of the time-enabled layer. Can be one of the following values: + * + * - Undefined := either the layer has no timeConfig or we use the default year defined in + * layerConfig.timeBehaviour + * - 'none': = no year is selected, which means that the layer won't be visible. This happens when + * using the TimeSlider where a user can select a year that has no data for this layer. + * - 'all': = load all years for this layer (for WMS this means that no TIME param is added and + * for WMTS we use the geoadmin definition YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA as timestamp) + * - 'current': = load current year, only valid for WMTS layer where 'current' is a valid + * timestamp. + * - YYYY: = any valid year entry for this layer, this will load the data only for this year. + * + * Affects only time-enabled layers (including External WMS/WMTS layer with timestamp) + */ + year?: string | number + /** Automatic refresh time in milliseconds of the layer. Affects GeoAdminGeoJsonLayer only. */ + updateDelay?: number + /** Colon-separated list of feature IDs to select.` */ + features?: string + /** KML style to be applied (in case this layer is a KML layer) */ + style?: KMLStyle + /** Any unlisted param will go here */ + [key: string]: string | number | boolean | undefined +} + +export interface Layer { + /** A unique identifier for each object of this interface * */ + uuid: string + /** Name of this layer in the current lang */ + name: string + /** + * The unique ID of this layer that will be used in the URL to identify it (and also in + * subsequent backend services for GeoAdmin layers) + */ + readonly id: string + /** The layer type */ + readonly type: LayerType + /** + * What's the backend base URL to use when requesting tiles/image for this layer, will be used + * to construct the URL of this layer later on + */ + readonly baseUrl: string + /** + * Flag telling if the base URL must always have a trailing slash. It might be sometime the case + * that this is unwanted (i.e. for an external WMS URL already built past the point of URL + * params, a trailing slash would render this URL invalid). Default is `false` + */ + // readonly ensureTrailingSlashInBaseUrl: boolean + /** Value from 0.0 to 1.0 telling with which opacity this layer should be shown on the map. */ + opacity: number + /** If the layer should be visible on the map or hidden. */ + isVisible: boolean + /** Description of the data owner(s) for this layer. */ + readonly attributions: LayerAttribution[] + /** Define if this layer shows tooltip when clicked on. */ + readonly hasTooltip: boolean + /** Define if this layer has a description that can be shown to users to explain its content. */ + readonly hasDescription: boolean + /** Define if this layer has a legend that can be shown to users to explain its content. */ + readonly hasLegend: boolean + /** Define if this layer comes from our backend, or is from another (external) source. */ + readonly isExternal: boolean + /** Set to true if some parts of the layer (e.g. metadata) are still loading */ + isLoading: boolean + /** Time series config */ + timeConfig?: LayerTimeConfig + /** + * The custom attributes (except the well known updateDelays, adminId, features and year) passed + * with the layer id in url. + */ + customAttributes?: LayerCustomAttributes + + /** List of error linked to this layer (i.e. network error, unparsable data, etc...) */ + errorMessages?: Set + /** List of warning linked to this layer (i.e. malformed (but usable) data, etc...) */ + warningMessages?: Set + hasError: boolean + hasWarning: boolean + + /* The admin id to allow editing. If not set, the user is not allowed to edit the file. */ + adminId?: string +} + +// #region: GeoAdminLayers +/** This interface unifies the shared properties of the layers that speak to an API like WMS and WMTS */ +export interface GeoAdminLayer extends Layer { + /** If this layer should be treated as a background layer. */ + readonly isBackground: boolean + /** + * Tells if this layer possess features that should be highlighted on the map after a click (and + * if the backend will provide valuable information on the + * {@link http://api3.geo.admin.ch/services/sdiservices.html#identify-features} endpoint). + */ + readonly isHighlightable: boolean + /** All the topics in which belongs this layer. */ + readonly topics: string[] + /** Define if this layer's features can be searched through the search bar. */ + readonly searchable: boolean + /** + * The ID/name to use when requesting the WMS backend, this might be different than id, and many + * layers (with different id) can in fact request the same layer, through the same technical + * name, in the end) + */ + readonly technicalName?: string + /** + * The layer ID to be used as substitute for this layer when we are showing the 3D map. Will be + * using the same layer if this is set to null. + */ + readonly idIn3d?: string + readonly isSpecificFor3d: boolean + /* oh OK this is determined by the _3d suffix. Why then isn't it made a 3d layer? */ +} + +/** Represent a WMS Layer from geo.admin.ch */ +export interface GeoAdminWMSLayer extends GeoAdminLayer { + /** + * How much of a gutter (extra pixels around the image) we want. This is specific for tiled WMS, + * if unset this layer will be a considered a single tile WMS. + */ + readonly gutter: number + /** Version of the WMS protocol to use while requesting images on this layer. */ + readonly wmsVersion: string + /** + * The lang ISO code to use when requesting the backend (WMS images can have text that are + * language dependent). + */ + readonly lang: string + /** In which image format the backend must be requested. */ + readonly format: 'png' | 'jpeg' + readonly type: LayerType.WMS +} + +/** Represent a WMTS layer from geo.admin.ch */ +export interface GeoAdminWMTSLayer extends GeoAdminLayer { + /** Define the maximum resolution the layer can reach */ + readonly maxResolution: number + /** In which image format the backend must be requested. */ + readonly format: 'png' | 'jpeg' + readonly type: LayerType.WMTS +} + +export interface GeoAdmin3DLayer extends GeoAdminLayer { + readonly type: LayerType.VECTOR + readonly technicalName: string + /* If the JSON file stored in the /3d-tiles/ sub-folder on the S3 bucket */ + readonly use3dTileSubFolder: boolean + /* If this layers' JSON is stored in a + dedicated timed folder, it can be described with this property. This will be added at the + end of the URL, before the /tileset.json (or /style.json, depending on the layer type) */ + readonly urlTimestampToUse?: string +} + +export interface GeoAdminGeoJSONLayer extends GeoAdminLayer { + readonly type: LayerType.GEOJSON + /* Delay after which the data of this layer + should be re-requested (if null is given, no further data reload will be triggered). A good + example would be layer 'ch.bfe.ladestellen-elektromobilitaet'. Default is `null` */ + updateDelay?: number + /* The URL to use to request the styling to apply to the data */ + readonly styleUrl: string + /* The URL to use when requesting the GeoJSON data (the true GeoJSON per se...) */ + readonly geoJsonUrl: string + geoJsonStyle?: { + type: string + ranges: number[] + } + geoJsonData?: string + readonly technicalName: string + readonly isExternal: false +} + +export interface GeoAdminVectorLayer extends GeoAdminLayer { + readonly type: LayerType.VECTOR + readonly technicalName: string + readonly attributions: LayerAttribution[] + readonly isBackground: boolean +} + +// #endregion + +// #region: File Type Layers +export interface CloudOptimizedGeoTIFFLayer extends Layer { + readonly type: LayerType.COG + readonly isLocalFile: boolean + readonly fileSource?: string + /* Data/content of the COG file, as a string. */ + data?: string | Blob + /* The extent of this COG. */ + extent?: FlatExtent +} + +/** Links to service-kml's entries for this KML */ +export interface KMLMetadataLinks { + /** URL link to the KML's metadata (which is used to set the KMLMetadata values up) */ + readonly metadata: string + /** URL link to the file itself */ + readonly kml: string +} + +/** + * All info from the metadata endpoint of service-kml + * + * @see https://github.com/geoadmin/service-kml/blob/56286ea029b1b01054d0d7e1288279acd0aa9b4b/app/routes.py#L80-L83 + */ +export interface KMLMetadata { + /** The file ID to use to access the resource and metadata */ + readonly id: string + /** + * The file admin ID to use if the user wants to modify this file later (without changing his + * previously generated share links) + */ + readonly adminId?: string + readonly created: Date + updated: Date + /** + * Author of the KML. + * + * Is used to detect if a KML is from the "legacy" viewer (a.k.a. mf-geoadmin3) or was generated + * with the current version of the code. + * + * To be flagged as "current", this value must be exactly "web-mapviewer". Any other value will + * be considered a legacy KML. + * + * This is especially important for icon URLs, as we've changed service-icons' URL scheme while + * going live with web-mapviewer's project. + */ + readonly author: string + /** + * Version of the KML drawing + * + * This is set by the viewer's code to 1.0.0 (backend will default to 0.0.0) here: + * https://github.com/geoadmin/web-mapviewer/blob/8fa2cf2ad273779265d2dfad91c8c4b96f47b90f/packages/mapviewer/src/api/files.api.js#L125 + */ + readonly authorVersion: string + /** URL links to this KML's resource */ + readonly links: KMLMetadataLinks +} + +export enum KMLStyle { + DEFAULT = 'DEFAULT', + GEOADMIN = 'GEOADMIN', +} + +export interface KMLLayer extends Layer { + /* The URL to access the KML data. */ + kmlFileUrl: string + fileId?: string + /* Data/content of the KML file, as a string. */ + kmlData?: string + /* Metadata of the KML drawing. This object contains all the metadata returned by the backend. */ + kmlMetadata?: KMLMetadata + + extent?: FlatExtent + /* Flag defining if the KML should be clamped to + the 3D terrain (only for 3D viewer). If not set, the clamp to ground flag will be set to + true if the KML is coming from geoadmin (drawing). Some users wanted to have 3D KMLs (fly + tracks) that were not clamped to the ground (they are providing height values), and others + wanted to have their flat surface visible on the ground, so that is the way to please both + crowds. */ + clampToGround: boolean + style?: KMLStyle + isExternal: boolean + isLocalFile: boolean + attributions: LayerAttribution[] + /** + * Map of KMZ icons subfiles. Those files are usually sent with the KML inside a KMZ archive and + * can be referenced inside the KML (e.g., icon, image, ...), so that they are available + * "offline" + */ + internalFiles: Map +} + +export interface GPXLink { + text?: string + type?: string +} + +export interface GPXAuthor { + name?: string + email?: string + link?: GPXLink +} + +export interface GPXMetadata { + name?: string + desc?: string + author?: GPXAuthor + link?: GPXLink + time?: number + keywords?: string + bounds?: FlatExtent + extensions?: any +} + +export interface GPXLayer extends Layer { + /* URL to the GPX file (can also be a local file URI) */ + readonly gpxFileUrl?: string + /* Data/content of the GPX file, as a string. */ + gpxData?: string + /* Metadata of the GPX file. This object contains all the metadata found in the file itself within the tag. */ + gpxMetadata?: GPXMetadata + extent?: FlatExtent +} +// #endregion + +// #region: external layers +export interface ExternalLayerTimeDimension { + /* Dimension identifier */ + readonly id: string + /* Dimension default value */ + readonly defaultValue: string + /* All dimension values */ + readonly values: string[] + /* Boolean flag if the dimension support current (see WMTS/WMS OGC spec) */ + current?: boolean +} + +export interface TileMatrixSet { + /* Identifier of the tile matrix set (see WMTS OGC spec) */ + readonly id: string + /* Coordinate system supported by the Tile Matrix Set */ + projection: CoordinateSystem + /* TileMatrix from GetCapabilities (see WMTS OGC spec) */ + tileMatrix: any // TODO type this properly +} + +export interface BoundingBox { + readonly lowerCorner?: SingleCoordinate + readonly upperCorner?: SingleCoordinate + readonly extent?: FlatExtent + readonly crs?: string + readonly dimensions?: number +} + +export enum WMTSEncodingType { + KVP = 'KVP', + REST = 'REST', +} + +/* Configuration describing how to request this layer's server to get feature information. */ +export interface ExternalLayerGetFeatureInfoCapability { + readonly baseUrl: string + readonly method: 'GET' | 'POST' + readonly formats: string[] +} + +export interface ExternalLayer extends Layer { + readonly abstract?: string + readonly availableProjections?: CoordinateSystem[] + readonly getFeatureInfoCapability?: ExternalLayerGetFeatureInfoCapability + readonly dimensions: ExternalLayerTimeDimension[] + /* Current year of the time series config to use. This parameter is needed as it is set in the + URL while the timeConfig parameter is not yet available and parse later on from the + GetCapabilities. */ + currentYear?: number + /* Layer legends. */ + readonly legends?: LayerLegend[] + readonly extent?: FlatExtent +} + +export interface ExternalWMTSLayer extends ExternalLayer { + /* WMTS Get Capabilities options */ + readonly options?: Partial + /* WMTS Get Tile encoding (KVP or REST). */ + readonly getTileEncoding: WMTSEncodingType + /* WMTS Get Tile url template for REST encoding. */ + readonly urlTemplate: string + /* WMTS layer style. If no style is given here, and no style is found in the options, the 'default' style will be used. */ + readonly style?: string + /* WMTS tile matrix sets */ + readonly tileMatrixSets?: TileMatrixSet[] + readonly type: LayerType.WMTS +} + +export interface ExternalWMSLayer extends ExternalLayer { + /* Description of the layers being part of this WMS layer (they will all be displayed at the + same time, in contrast to an aggregate layer) */ + readonly layers?: ExternalWMSLayer[] + /* WMS protocol version to be used when querying this server. */ + readonly wmsVersion: string + readonly wmsOperations: WMSRequestCapabilities + readonly format: 'png' | 'jpeg' + /** URL parameters to pass to each WMS requests to the server */ + readonly params?: Record +} + +// #endregion + +// #region Combined layers + +export interface AggregateSubLayer { + /* The ID used in the GeoAdmin's backend to describe this sub-layer */ + readonly subLayerId?: string + /* The sub-layer config (can be a {@link GeoAdminGeoJsonLayer}, a {@link GeoAdminWMTSLayer} or a {@link GeoAdminWMTSLayer}) */ + readonly layer: GeoAdminLayer + /* In meter/px, at which resolution this sub-layer should start to be visible */ + readonly minResolution: number + /* In meter/px, from which resolution the layer should be hidden */ + readonly maxResolution: number +} + +export interface GeoAdminAggregateLayer extends GeoAdminLayer { + readonly type: LayerType.AGGREGATE + readonly baseUrl: '' + readonly subLayers: AggregateSubLayer[] +} + +export interface GeoAdminGroupOfLayers extends Layer { + /* Description of the layers being part of this group */ + readonly layers: GeoAdminLayer[] + readonly type: LayerType.GROUP +} + +// #endregion + +export type FileLayer = KMLLayer | GPXLayer | CloudOptimizedGeoTIFFLayer diff --git a/packages/geoadmin-layers/src/types/timeConfig.ts b/packages/geoadmin-layers/src/types/timeConfig.ts new file mode 100644 index 0000000000..caaf9bc2a8 --- /dev/null +++ b/packages/geoadmin-layers/src/types/timeConfig.ts @@ -0,0 +1,60 @@ +import { Interval } from 'luxon' + +/** + * Year we are using to describe the timestamp "all data" for WMS (and also for WMTS as there is no + * equivalent for that in the norm) + * + * For WMTS : as we want this year to be passed along year by year, we can't use the actual year + * (today would be 2025) as otherwise this link opened in 2030 won't show "current" data but 2025 + * data, so we store it the same way WMS does, with 9999 as a year + */ +export const YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA: number = 9999 + +/** Timestamp to describe "all data" for time enabled WMS layer */ +export const ALL_YEARS_TIMESTAMP: string = 'all' +/** + * Timestamp to describe "current" or latest available data for a time enabled WMTS layer (and also + * is the default value to give any WMTS layer that is not time enabled, as this timestamp is + * required in the URL scheme) + */ +export const CURRENT_YEAR_TIMESTAMP: string = 'current' + +/** + * Duration of time for when to show data on this layer. Can be a set of instant (expressed as an + * Interval) or two preset values : + * + * - "all": show all available data, across all time entries + * - "current": show "current" data, which stays "current" when new data is added (loads the new data) + */ +export type LayerTimeInterval = Interval | 'all' | 'current' + +export interface LayerTimeConfig { + timeEntries: LayerTimeConfigEntry[] + /** + * Describe which time entry should be used as default value if no value is previously defined + * when loading the layer. + * + * - 'last': will use the top entry from the entry list (the last => the most recent, not the last + * of the list) + * - 'all': will stay as "all", as this describes some kind of hack on the backend to show a fake + * year that includes all data (there's not yet support for a real "give me all data" time + * entry there) + * - "current": stays as "current", alias value to tell the backend to show the latest available + * value, without having to change the URL each year for our users. ("current" will always + * display the latest available data when asked on the backend) + * - Any number value: specific value, that will be set as is. + */ + // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents + behaviour?: 'last' | 'all' | 'current' | number | string + currentTimeEntry?: LayerTimeConfigEntry +} + +export interface LayerTimeConfigEntry { + /** + * The timestamp used by our backend service to describe this entry (should be used in the URL + * when requesting tiles/images) + */ + timestamp: string + nonTimeBasedValue?: string + interval?: Interval +} diff --git a/packages/geoadmin-layers/src/utils/__test__/layerUtils.spec.ts b/packages/geoadmin-layers/src/utils/__test__/layerUtils.spec.ts new file mode 100644 index 0000000000..41b104f198 --- /dev/null +++ b/packages/geoadmin-layers/src/utils/__test__/layerUtils.spec.ts @@ -0,0 +1,7 @@ +// TODO create a test that ensures the precedence of the values + +import { describe, it } from 'vitest' + +describe('Test layer utils functions', () => { + it('placeholder', () => {}) +}) diff --git a/packages/geoadmin-layers/src/utils/__test__/timeConfigUtils.spec.ts b/packages/geoadmin-layers/src/utils/__test__/timeConfigUtils.spec.ts new file mode 100644 index 0000000000..826976f170 --- /dev/null +++ b/packages/geoadmin-layers/src/utils/__test__/timeConfigUtils.spec.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai' +import { describe, it } from 'vitest' + +import { type Layer, LayerType } from '@/types/layers' +import { hasMultipleTimestamps, makeTimeConfigEntry } from '@/utils/timeConfigUtils' + +describe('Test utility functions', () => { + it('Determines correctly that a layer has multiple timestamps', () => { + const simpleLayer: Layer = { + uuid: 'one-two-three-four', + name: 'simple', + id: 'ch.bgdi.simple', + opacity: 0, + isVisible: true, + type: LayerType.WMTS, + attributions: [], + hasTooltip: false, + hasDescription: false, + hasError: false, + hasLegend: false, + isExternal: false, + isLoading: false, + baseUrl: 'http://bgdi.ch', + hasWarning: false, + } + + expect(hasMultipleTimestamps(simpleLayer)).to.be.false + + simpleLayer.timeConfig = { + timeEntries: [], + behaviour: 'last', + currentTimeEntry: undefined + } + + expect(hasMultipleTimestamps(simpleLayer)).to.be.false + + simpleLayer.timeConfig.timeEntries.push(makeTimeConfigEntry('200223123')) + + expect(hasMultipleTimestamps(simpleLayer)).to.be.false + + simpleLayer.timeConfig.timeEntries.push(makeTimeConfigEntry('200523123')) + + expect(hasMultipleTimestamps(simpleLayer)).to.be.true + }) +}) diff --git a/packages/geoadmin-layers/src/utils/externalLayerUtils.ts b/packages/geoadmin-layers/src/utils/externalLayerUtils.ts new file mode 100644 index 0000000000..4126d059ce --- /dev/null +++ b/packages/geoadmin-layers/src/utils/externalLayerUtils.ts @@ -0,0 +1,39 @@ +import { setWmsGetCapabilitiesParams, setWmtsGetCapParams } from '@/api/external' + +/** Checks if file has WMS Capabilities XML content */ +export function isWmsGetCap(fileContent: string): boolean { + return /<(WMT_MS_Capabilities|WMS_Capabilities)/.test(fileContent) +} + +/** Checks if file has WMTS Capabilities XML content */ +export function isWmtsGetCap(fileContent: string): boolean { + return /): void => { + if (!values.name) { + throw new InvalidLayerDataError('Missing layer name', values) + } + if (!values.id) { + throw new InvalidLayerDataError('Missing layer ID', values) + } +} + +/** + * Construct a basic GeoAdmin WMS Layer + * + * This is a helper that can work with a subset of the GeoAdminWMSLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdminWMSLayer(values: Partial): GeoAdminWMSLayer { + const defaults = { + uuid: uuidv4(), + isExternal: false, + type: LayerType.WMS, + opacity: DEFAULT_OPACITY, + isVisible: true, + isLoading: false, + gutter: 0, + wmsVersion: '1.3.0', + lang: 'en', + isHighlightable: false, + hasTooltip: false, + topics: [], + hasLegend: false, + searchable: false, + format: 'png' as 'png' | 'jpeg', + technicalName: '', + isSpecificFor3d: false, + attributions: [], + hasDescription: true, + hasError: false, + hasWarning: false, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer as GeoAdminWMSLayer +} + +/** + * Construct a basic GeoAdmin WMTS Layer + * + * This is a helper that can work with a subset of the GeoAdminWMTSLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdminWMTSLayer(values: Partial): GeoAdminWMTSLayer { + const defaults = { + uuid: uuidv4(), + type: LayerType.WMTS, + idIn3d: undefined, + technicalName: undefined, + opacity: 1.0, + isVisible: true, + format: 'png' as 'png' | 'jpeg', + isBackground: false, + isHighlightable: false, + hasTooltip: false, + topics: [], + hasLegend: false, + searchable: false, + maxResolution: DEFAULT_GEOADMIN_MAX_WMTS_RESOLUTION, + isSpecificFor3d: false, + attributions: [], + hasDescription: true, + isExternal: false, + isLoading: false, + hasError: false, + hasWarning: false, + timeConfig: null, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a basic WMTS layer with all the necessary defaults + * + * This is a helper that can work with a subset of the GeoAdminWMSLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeExternalWMTSLayer(values: Partial): ExternalWMTSLayer { + const hasDescription = (values?.abstract?.length ?? 0) > 0 || (values?.legends?.length ?? 0) > 0 + const attributions = [] + const hasLegend = (values?.legends ?? []).length > 0 + + if (values?.baseUrl) { + attributions.push({ name: new URL(values.baseUrl).hostname }) + } + + const defaults = { + uuid: uuidv4(), + isExternal: true, + type: LayerType.WMTS, + opacity: DEFAULT_OPACITY, + isVisible: true, + abstract: '', + legends: [], + availableProjections: [], + getTileEncoding: WMTSEncodingType.REST, + urlTemplate: '', + style: '', + tileMatrixSets: [], + dimensions: [], + hasTooltip: false, + hasDescription, + searchable: false, + hasLegend, + isLoading: true, + hasError: false, + currentYear: undefined, + attributions, + hasWarning: false, + timeConfig: null, + } + + if (values.currentYear && values.timeConfig) { + const timeEntry = timeConfigUtils.getTimeEntryForYear(values.timeConfig, values.currentYear) + if (timeEntry) { + timeConfigUtils.updateCurrentTimeEntry(values.timeConfig, timeEntry) + } + } + + // if hasDescription or attributions were provided in `values`, then these would + // override the ones we inferred above + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct an external WMSLayer + * + * This is a helper that can work with a subset of the WMSLayer properties. The missing values from + * the function parameter will be used from defaults + */ +function makeExternalWMSLayer(values: Partial): ExternalWMSLayer { + const hasDescription = (values?.abstract?.length ?? 0) > 0 || (values?.legends?.length ?? 0) > 0 + const attributions = [{ name: new URL(values.baseUrl!).hostname }] + const hasLegend = (values?.legends ?? []).length > 0 + + const defaults = { + uuid: uuidv4(), + opacity: 1.0, + isVisible: true, + layers: [], + attributions, + hasDescription, + wmsVersion: '1.3.0', + format: 'png' as 'png' | 'jpeg', + hasLegend, + abstract: '', + extent: undefined, + legends: [], + isLoading: true, + availableProjections: [], + hasTooltip: false, + getFeatureInfoCapability: null, + dimensions: [], + currentYear: undefined, + customAttributes: undefined, + type: LayerType.WMS, + isExternal: true, + hasError: false, + hasWarning: false, + timeConfig: null, + } + + if (values.currentYear && values.timeConfig) { + const timeEntry = timeConfigUtils.getTimeEntryForYear(values.timeConfig, values.currentYear) + if (timeEntry) { + timeConfigUtils.updateCurrentTimeEntry(values.timeConfig, timeEntry) + } + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a KML Layer + * + * This is a helper that can work with a subset of the KMLLayer properties. The missing values from + * the function parameter will be used from defaults + */ +function makeKMLLayer(values: Partial): KMLLayer { + const defaults = { + uuid: uuidv4(), + opacity: 1.0, + isVisible: true, + layers: [], + extent: null, + clampToGround: false, + isExternal: false, + kmlFileUrl: '', + fileId: '', + kmlData: null, + kmlMetadata: null, + isLocalFile: false, + attributions: [], + style: KMLStyle.DEFAULT, + type: LayerType.KML, + hasTooltip: false, + hasError: false, + hasWarning: false, + hasDescription: false, + hasLegend: false, + isLoading: true, + adminId: null, + linkFiles: new Map(), + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a GPX Layer + * + * This is a helper that can work with a subset of the GPXLayer properties. The missing values from + * the function parameter will be used from defaults + */ +function makeGPXLayer(values: Partial): GPXLayer { + const isLocalFile = !values.gpxFileUrl?.startsWith('http') + if (!values.gpxFileUrl) { + throw new InvalidLayerDataError('Missing GPX file URL', values) + } + const attributionName = isLocalFile ? values.gpxFileUrl : new URL(values.gpxFileUrl).hostname + const attributions = [{ name: attributionName }] + const name = values.gpxMetadata?.name ?? 'GPX' + + const defaults = { + uuid: uuidv4(), + baseUrl: values.gpxFileUrl, + gpxFileUrl: null, + gpxData: null, + gpxMetadata: null, + extent: null, + name: name, + id: `GPX|${encodeExternalLayerParam(values.gpxFileUrl)}`, + type: LayerType.GPX, + opacity: 0, + isVisible: false, + attributions, + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isExternal: true, + hasError: false, + hasWarning: false, + isLoading: !values.gpxData, + timeConfig: null, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a GeoAdminVectorLayer Layer + * + * This is a helper that can work with a subset of the GeoAdminVectorLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdminVectorLayer(values: Partial): GeoAdminVectorLayer { + const attributions = [ + ...(values.attributions ? values.attributions : []), + { name: 'swisstopo', url: 'https://www.swisstopo.admin.ch/en/home.html' }, + ] + + const defaults = { + uuid: uuidv4(), + type: LayerType.VECTOR, + technicalName: '', + attributions, + opacity: 0, + isVisible: false, + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isBackground: true, + isExternal: false, + isLoading: false, + hasError: false, + hasWarning: false, + isHighlightable: false, + timeConfig: null, + topics: [], + searchable: false, + isSpecificFor3d: false, + } + + const layer = merge(defaults, omit(values, 'attributions')) + validateBaseData(layer) + return layer +} + +/** + * Construct a GeoAdmin3DLayer Layer + * + * This is a helper that can work with a subset of the GeoAdmin3DLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdmin3DLayer(values: Partial): GeoAdmin3DLayer { + const attributions = [{ name: 'swisstopo', url: 'https://www.swisstopo.admin.ch/en/home.html' }] + + const defaults = { + uuid: uuidv4(), + technicalName: '', + use3dTileSubFolder: false, + urlTimestampToUse: false, + name: values.name ?? values.id, + type: LayerType.VECTOR, + opacity: 1, + isVisible: true, + attributions, + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isExternal: false, + isLoading: false, + hasError: false, + hasWarning: false, + isHighlightable: false, + topics: [], + searchable: false, + isSpecificFor3d: false, + timeConfig: null, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a CloudOptimizedGeoTIFFLayer Layer + * + * This is a helper that can work with a subset of the CloudOptimizedGeoTIFFLayer properties. The + * missing values from the function parameter will be used from defaults + */ +function makeCloudOptimizedGeoTIFFLayer( + values: Partial +): CloudOptimizedGeoTIFFLayer { + if (values.fileSource === null || values.fileSource === undefined) { + throw new InvalidLayerDataError('Missing COG file source', values) + } + + const fileSource = values.fileSource + const isLocalFile = !fileSource?.startsWith('http') + const attributionName = isLocalFile ? fileSource : new URL(fileSource).hostname + const attributions = [{ name: attributionName }] + const fileName = isLocalFile + ? fileSource + : fileSource?.substring(fileSource.lastIndexOf('/') + 1) + + const defaults = { + uuid: uuidv4(), + baseUrl: fileSource, + type: LayerType.COG, + isLocalFile, + fileSource: null, + data: null, + extent: null, + name: fileName, + id: fileSource, + opacity: 1, + isVisible: false, + attributions, + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isExternal: false, + isLoading: false, + hasError: false, + hasWarning: false, + timeConfig: null, + } + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a GeoAdminAggregateLayer Layer + * + * This is a helper that can work with a subset of the GeoAdminAggregateLayer properties. The + * missing values from the function parameter will be used from defaults + */ +function makeGeoAdminAggregateLayer( + values: Partial +): GeoAdminAggregateLayer { + const defaults = { + uuid: uuidv4(), + type: LayerType.AGGREGATE, + subLayers: [], + opacity: 1, + isVisible: true, + attributions: [], + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isExternal: false, + isLoading: false, + hasError: false, + hasWarning: false, + timeConfig: null, + isHighlightable: false, + topics: [], + searchable: false, + isSpecificFor3d: false, + } + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a GeoAdminGeoJSONLayer Layer + * + * This is a helper that can work with a subset of the GeoAdminGeoJSONLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdminGeoJSONLayer(values: Partial): GeoAdminGeoJSONLayer { + const defaults = { + uuid: uuidv4(), + type: LayerType.GEOJSON, + updateDelay: 0, + styleUrl: '', + geoJsonUrl: '', + technicalName: '', + isExternal: false, + opacity: 1, + isVisible: true, + attributions: [], + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isLoading: false, + hasError: false, + hasWarning: false, + geoJsonStyle: null, + geoJsonData: null, + timeConfig: null, + isHighlightable: false, + searchable: false, + topics: [], + isSpecificFor3d: false, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** + * Construct a GeoAdminGroupOfLayers + * + * This is a helper that can work with a subset of the GeoAdminGeoJSONLayer properties. The missing + * values from the function parameter will be used from defaults + */ +function makeGeoAdminGroupOfLayers(values: Partial): GeoAdminGroupOfLayers { + const defaults = { + uuid: uuidv4(), + layers: [], + type: LayerType.GROUP, + opacity: 1, + isVisible: true, + attributions: [], + hasTooltip: false, + hasDescription: false, + hasLegend: false, + isExternal: false, + isLoading: false, + timeConfig: null, + hasError: false, + hasWarning: false, + } + + const layer = merge(defaults, values) + validateBaseData(layer) + return layer +} + +/** Construct an aggregate sub layer */ +function makeAggregateSubLayer(values: Partial): AggregateSubLayer { + if (values.layer === undefined || values.subLayerId === undefined) { + throw new InvalidLayerDataError('Must provide a layer for the aggregate sublayer', values) + } + + const defaults = { + minResolution: 0, + maxResolution: 0, + } + + return merge(defaults, values as AggregateSubLayer) +} + +function isKmlLayerLegacy(layer: KMLLayer): boolean { + return layer.kmlMetadata?.author !== 'web-mapviewer' +} + +function isKmlLayerEmpty(layer: KMLLayer): boolean { + return !layer.kmlData || layer.kmlData === EMPTY_KML_DATA +} + +/** + * Returns which topic should be used in URL that needs one topic to be defined (identify or + * htmlPopup for instance). By default and whenever possible, the viewer should use `ech`. If `ech` + * is not present in the topics, the first of them should be used to request the backend. + * + * @returns The topic to use in request to the backend for this layer + */ +function getTopicForIdentifyAndTooltipRequests(layer: GeoAdminLayer): string { + // by default, the frontend should always request `ech`, so if there's no topic that's what we do + // if there are some topics, we look if `ech` is one of them, if so we return it + if (layer.topics.length === 0 || layer.topics.indexOf('ech') !== -1) { + return 'ech' + } + // otherwise we return the first topic to make our backend requests for identify and htmlPopup + return layer.topics[0] +} + +/** Clone a layer but give it a new uuid */ +function cloneLayer(layer: T): T { + validateBaseData(layer) + const clone = cloneDeep(layer) + clone.uuid = uuidv4() + return clone +} + +export interface GeoadminLayerUtils { + transformToLayerTypeEnum: typeof transformToLayerTypeEnum + makeGPXLayer: typeof makeGPXLayer + makeKMLLayer: typeof makeKMLLayer + makeGeoAdminWMSLayer: typeof makeGeoAdminWMSLayer + makeGeoAdminWMTSLayer: typeof makeGeoAdminWMTSLayer + makeExternalWMTSLayer: typeof makeExternalWMTSLayer + makeExternalWMSLayer: typeof makeExternalWMSLayer + makeGeoAdminVectorLayer: typeof makeGeoAdminVectorLayer + makeGeoAdmin3DLayer: typeof makeGeoAdmin3DLayer + makeCloudOptimizedGeoTIFFLayer: typeof makeCloudOptimizedGeoTIFFLayer + makeGeoAdminAggregateLayer: typeof makeGeoAdminAggregateLayer + makeGeoAdminGeoJSONLayer: typeof makeGeoAdminGeoJSONLayer + makeGeoAdminGroupOfLayers: typeof makeGeoAdminGroupOfLayers + makeAggregateSubLayer: typeof makeAggregateSubLayer + isKmlLayerLegacy: typeof isKmlLayerLegacy + isKmlLayerEmpty: typeof isKmlLayerEmpty + getTopicForIdentifyAndTooltipRequests: typeof getTopicForIdentifyAndTooltipRequests + cloneLayer: typeof cloneLayer +} + +export const layerUtils: GeoadminLayerUtils = { + transformToLayerTypeEnum, + makeGPXLayer, + makeKMLLayer, + makeGeoAdminWMSLayer, + makeGeoAdminWMTSLayer, + makeExternalWMTSLayer, + makeExternalWMSLayer, + makeGeoAdminVectorLayer, + makeGeoAdmin3DLayer, + makeCloudOptimizedGeoTIFFLayer, + makeGeoAdminAggregateLayer, + makeGeoAdminGeoJSONLayer, + makeGeoAdminGroupOfLayers, + makeAggregateSubLayer, + isKmlLayerLegacy, + isKmlLayerEmpty, + getTopicForIdentifyAndTooltipRequests, + cloneLayer, +} + +export default layerUtils diff --git a/packages/geoadmin-layers/src/utils/timeConfigUtils.ts b/packages/geoadmin-layers/src/utils/timeConfigUtils.ts new file mode 100644 index 0000000000..0926c2572b --- /dev/null +++ b/packages/geoadmin-layers/src/utils/timeConfigUtils.ts @@ -0,0 +1,194 @@ +import log from '@geoadmin/log' +import { isTimestampYYYYMMDD } from '@geoadmin/numbers' +import { Interval } from 'luxon' + +import { + type Layer, + type LayerTimeConfig, + type LayerTimeConfigEntry, + YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA, +} from '@/types' + +export const hasTimestamp = (timeConfig: LayerTimeConfig, timestamp: string): boolean => + timeConfig.timeEntries.some((entry: LayerTimeConfigEntry) => entry.timestamp === timestamp) + +export const getTimeEntryForYear = ( + timeConfig: LayerTimeConfig, + year: number +): LayerTimeConfigEntry | undefined => { + const yearAsInterval = Interval.fromISO(`${year}-01-01/P1Y`) + return timeConfig.timeEntries.find((entry: LayerTimeConfigEntry) => { + if (entry.nonTimeBasedValue && ['all', 'current'].includes(entry.nonTimeBasedValue)) { + return year === YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA + } + if (yearAsInterval.isValid && entry.interval) { + return yearAsInterval.intersection(entry.interval) !== null + } + return false + }) +} + +export const updateCurrentTimeEntry = ( + timeConfig: LayerTimeConfig, + entryOrTimestamp: LayerTimeConfigEntry | string | number | undefined +) => { + let currentTimeEntry: LayerTimeConfigEntry | undefined + + if (typeof entryOrTimestamp === 'string' || typeof entryOrTimestamp === 'number') { + currentTimeEntry = timeConfig.timeEntries.find((e) => e.timestamp === entryOrTimestamp) + } else { + currentTimeEntry = entryOrTimestamp + } + + timeConfig.currentTimeEntry = currentTimeEntry +} + +export const makeTimeConfigEntry = (timestamp: string): LayerTimeConfigEntry => { + let interval: Interval | undefined + let nonTimeBasedValue: string | undefined + if (timestamp.startsWith('9999')) { + // TODO PB-680 clean up "all" hack + // Currently the backends (mf-chsdi3 for layerConfig and WMTS) are using a hack to describe "all" + // by using a timestamp with the year 9999 (we have in my knowledge three type of "all" WMTS timestmaps + // 1. 9999 (e.g. ch.swisstopo.lubis-terrestrische_aufnahmen) + // 2. 99990101 (e.g. ch.astra.unfaelle-personenschaeden_alle) + // 3. 99991231 (e.g. ch.swisstopo.lubis-luftbilder-dritte-firmen) + nonTimeBasedValue = 'all' + } else { + let year: string | undefined + let month: string | undefined + let day: string | undefined + if (isTimestampYYYYMMDD(timestamp)) { + year = timestamp.substring(0, 4) + month = timestamp.substring(4, 6) + day = timestamp.substring(6, 8) + } else { + const date = new Date(timestamp) + if (!isNaN(date.getFullYear())) { + year = date.getFullYear().toString().padStart(4, '0') + } + if (!isNaN(date.getMonth())) { + // getMonth returns value between 0 and 11 + month = (date.getMonth() + 1).toString().padStart(2, '0') + } + if (!isNaN(date.getDate())) { + day = date.getDate().toString().padStart(2, '0') + } + } + if (year !== undefined && month !== undefined && day !== undefined) { + interval = Interval.fromISO(`${year}-${month}-${day}/P1D`) + } else if (year !== undefined && month !== undefined) { + interval = Interval.fromISO(`${year}-${month}-01/P1M`) + } else if (year !== undefined) { + interval = Interval.fromISO(`${year}-01-01/P1Y`) + } + } + + // Could not parse any time interval with the input, passing the timestamp as is + if (interval === undefined || !interval.isValid) { + nonTimeBasedValue = timestamp + } + if (interval && !interval.isValid) { + log.debug('[@geoadmin/layers] invalid interval for timestamp', timestamp) + interval = undefined + } + + return { + timestamp, + interval, + nonTimeBasedValue, + } +} + +export const makeTimeConfig = ( + behaviour?: string | number, + timeEntries?: LayerTimeConfigEntry[] +): LayerTimeConfig | undefined => { + if (!timeEntries || timeEntries.length === 0) { + return + } + const timeConfig: LayerTimeConfig = { + timeEntries: timeEntries, + behaviour, + } + /* + * Here we will define what is the first "currentTimeEntry" for this configuration. + * We will simplify the two approaches that exists for WMS and WMTS. + * The first value will depend on what is in 'behaviour' + * + * With WMS the behaviour can be : + * - 'last' : the most recent year has to be picked + * - 'all' : all years must be picked (so the year 9999 or no year should be specified in the URL) + * - any valid year that has an equivalent in 'timeEntries' + * + * With WMTS the behaviour can be : + * - 'current' : 'current' is a valid timestamp in regard to WMTS URL norm so we need to do about the same as + * with WMS and keep this information for later use + * - 'last' : same as WMS, we pick the most recent timestamp from 'timestamps' + * - any valid year that is in 'timestamps' + * - nothing : we then have to pick the first timestamp of the timestamps as default (same as if it was 'last') + * + * First let's tackle layers that have "last" as a timestamp (can be both WMS and WMTS layers). + * We will return, well, the last timestamp (the most recent) of the timestamps (if there are some) + * or if nothing has been defined in the behaviour, but there are some timestamps defined, we take the first. + */ + if ((behaviour === 'last' || !behaviour) && timeEntries.length > 0) { + updateCurrentTimeEntry(timeConfig, timeEntries[0]) + } else if (behaviour) { + updateCurrentTimeEntry(timeConfig, behaviour) + } + + return timeConfig +} + +export const hasMultipleTimestamps = (layer: Layer): boolean => { + return (layer.timeConfig?.timeEntries?.length || 0) > 1 +} + +export const getYearFromLayerTimeEntry = (timeEntry: LayerTimeConfigEntry): number | undefined => { + if (timeEntry.nonTimeBasedValue && ['all', 'current'].includes(timeEntry.nonTimeBasedValue)) { + return YEAR_TO_DESCRIBE_ALL_OR_CURRENT_DATA + } + if (timeEntry.interval && timeEntry.interval.start?.year !== undefined) { + return timeEntry.interval.start.year + } + return undefined +} + +export const getTimeEntryForInterval = ( + layer: Layer, + interval: Interval +): LayerTimeConfigEntry | undefined => { + if (!interval?.isValid || !layer.timeConfig?.timeEntries?.length) { + return + } + return layer.timeConfig.timeEntries.find((entry) => { + if (entry.interval) { + return entry.interval.overlaps(interval) + } + return false + }) +} + +export interface GeoadminTimeConfigUtils { + hasTimestamp: typeof hasTimestamp + getTimeEntryForYear: typeof getTimeEntryForYear + updateCurrentTimeEntry: typeof updateCurrentTimeEntry + makeTimeConfigEntry: typeof makeTimeConfigEntry + makeTimeConfig: typeof makeTimeConfig + hasMultipleTimestamps: typeof hasMultipleTimestamps + getYearFromLayerTimeEntry: typeof getYearFromLayerTimeEntry + getTimeEntryForInterval: typeof getTimeEntryForInterval +} +export const timeConfigUtils: GeoadminTimeConfigUtils = { + hasTimestamp, + getTimeEntryForYear, + updateCurrentTimeEntry, + makeTimeConfigEntry, + makeTimeConfig, + hasMultipleTimestamps, + getYearFromLayerTimeEntry, + getTimeEntryForInterval, +} + +export default timeConfigUtils diff --git a/packages/geoadmin-layers/src/validation/index.ts b/packages/geoadmin-layers/src/validation/index.ts new file mode 100644 index 0000000000..72b410acb2 --- /dev/null +++ b/packages/geoadmin-layers/src/validation/index.ts @@ -0,0 +1,145 @@ +import { ErrorMessage, WarningMessage } from '@geoadmin/log/Message' + +import type { Layer } from '@/types' + +export class InvalidLayerDataError extends Error { + data: any + constructor(message: string, data: any) { + super(message) + this.data = data + this.name = 'InvalidLayerDataError' + } +} + +export const layerContainsErrorMessage = (layer: Layer, errorMessage: ErrorMessage): boolean => { + if (layer.errorMessages) { + return layer.errorMessages.has(errorMessage) + } + return false +} + +export const getFirstErrorMessage = (layer: Layer): ErrorMessage | undefined => { + if (layer.errorMessages) { + return layer.errorMessages.values().next()?.value as ErrorMessage + } + return +} + +export const addErrorMessageToLayer = (layer: Layer, errorMessage: ErrorMessage): void => { + if (!layer.errorMessages) { + layer.errorMessages = new Set() + } + layer.errorMessages.add(errorMessage) + layer.hasError = true +} + +export const removeErrorMessageFromLayer = (layer: Layer, errorMessage: ErrorMessage): void => { + if (!layer.errorMessages) { + return + } + + // We need to find the error message that equals to remove it + for (const msg of layer.errorMessages) { + if (msg.isEquals(errorMessage)) { + layer.errorMessages.delete(msg) + break + } + } + layer.hasError = !!layer.errorMessages.size +} + +export const clearErrorMessages = (layer: Layer): void => { + if (layer.errorMessages) { + layer.errorMessages.clear() + } + layer.hasError = false +} + +export const layerContainsWarningMessage = ( + layer: Layer, + warningMessage: WarningMessage +): boolean => { + if (layer.warningMessages) { + return layer.warningMessages.has(warningMessage) + } + return false +} + +export const getFirstWarningMessage = (layer: Layer): WarningMessage | undefined => { + if (layer.warningMessages) { + return layer.warningMessages.values().next().value! + } + return +} + +export const addWarningMessageToLayer = (layer: Layer, warningMessage: WarningMessage): void => { + if (!layer.warningMessages) { + layer.warningMessages = new Set() + } + layer.warningMessages.add(warningMessage) + layer.hasWarning = true +} + +export const removeWarningMessageFromLayer = ( + layer: Layer, + warningMessage: WarningMessage +): void => { + if (!layer.warningMessages) { + return + } + + // We need to find the error message that equals to remove it + for (const msg of layer.warningMessages) { + if (msg.isEquals(warningMessage)) { + layer.warningMessages.delete(msg) + break + } + } + layer.hasWarning = !!layer.warningMessages.size +} + +export const clearWarningMessages = (layer: Layer): void => { + layer.warningMessages?.clear() + layer.hasWarning = false +} + +/** + * WMS or WMTS Capabilities Error + * + * 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 user message + */ +export class CapabilitiesError extends Error { + key?: string + + constructor(message: string, key?: string) { + super(message) + this.name = 'CapabilitiesError' + this.key = key + } +} + +/** + * Validate a component prop for basic layer type + * + * In cases where we don't yet use TS in vue components, we can't check the props against the + * interfaces. It used to be done with a instanceof AbstractLayer check. This function helps solving + * that issue by checking for the very basic and absolutely necessary properties of a Layer object. + * This should be good enough in the transition to TS to ensure that the provided property is indeed + * an implementation of Layer + * + * @param value Any Object + * @returns Boolean + */ +export const validateLayerProp = (value: Record): boolean => { + const requiredProps = ['id', 'type', 'baseUrl', 'name'] + for (const prop of requiredProps) { + if (!(prop in value)) { + return false + } + } + return true +} diff --git a/packages/geoadmin-layers/src/vue/index.ts b/packages/geoadmin-layers/src/vue/index.ts new file mode 100644 index 0000000000..423a46cd23 --- /dev/null +++ b/packages/geoadmin-layers/src/vue/index.ts @@ -0,0 +1 @@ +export * from './useCapabilities' diff --git a/packages/geoadmin-layers/src/vue/useCapabilities.ts b/packages/geoadmin-layers/src/vue/useCapabilities.ts new file mode 100644 index 0000000000..8431f2c722 --- /dev/null +++ b/packages/geoadmin-layers/src/vue/useCapabilities.ts @@ -0,0 +1,127 @@ +import type { CoordinateSystem } from '@geoadmin/coordinates' + +import log from '@geoadmin/log' +import axios, { AxiosError } from 'axios' +import { type MaybeRefOrGetter, toValue } from 'vue' + +import type { ExternalWMSLayer, ExternalWMTSLayer } from '@/types' + +import { EXTERNAL_SERVER_TIMEOUT, parseWmtsCapabilities } from '@/api/external' +import externalWMSParser from '@/parsers/ExternalWMSCapabilitiesParser' +import externalWMTSParser from '@/parsers/ExternalWMTSCapabilitiesParser' +import { guessExternalLayerUrl, isWmsGetCap, isWmtsGetCap } from '@/utils/externalLayerUtils' +import { CapabilitiesError } from '@/validation' + +export interface ParsedExternalWMS { + layers: ExternalWMSLayer[] + wmsMaxSize?: { + width: number + height: number + } +} + +export interface ParsedExternalWMTS { + layers: ExternalWMTSLayer[] +} + +function handleFileContent( + content: string, + fullUrl: URL, + projection: CoordinateSystem, + contentType?: string +): ParsedExternalWMTS | ParsedExternalWMS { + if (isWmsGetCap(content)) { + return handleWms(content, fullUrl, projection) + } else if (isWmtsGetCap(content)) { + return handleWmts(content, fullUrl, projection) + } else { + throw new CapabilitiesError( + `Unsupported url ${fullUrl} response content; Content-Type=${contentType}`, + 'unsupported_content_type' + ) + } +} + +function handleWms(content: string, fullUrl: URL, projection: CoordinateSystem): ParsedExternalWMS { + let wmsMaxSize + const capabilities = externalWMSParser.parse(content, fullUrl) + if (capabilities.Service.MaxWidth && capabilities.Service.MaxHeight) { + wmsMaxSize = { + width: capabilities.Service.MaxWidth, + height: capabilities.Service.MaxHeight, + } + } + return { + layers: externalWMSParser.getAllExternalLayers(capabilities, { + outputProjection: projection, + initialValues: { + opacity: 1, + isVisible: true, + }, + }), + wmsMaxSize, + } +} + +function handleWmts( + content: string, + fullUrl: URL, + projection: CoordinateSystem +): ParsedExternalWMTS { + return { + layers: externalWMTSParser.getAllExternalLayers(parseWmtsCapabilities(content, fullUrl), { + outputProjection: projection, + initialValues: { + isVisible: true, + opacity: 1, + }, + }), + } +} + +/** + * @param {String} url + * @param {CoordinateSystem} projection + * @param {String} lang + */ +export function useCapabilities( + url: MaybeRefOrGetter, + projection: MaybeRefOrGetter, + lang: MaybeRefOrGetter +) { + async function loadCapabilities(): Promise { + const fullUrl = guessExternalLayerUrl(toValue(url), toValue(lang)) + try { + const response = await axios.get(fullUrl.toString(), { + timeout: EXTERNAL_SERVER_TIMEOUT, + }) + if (!response || response.status !== 200 || !response.headers) { + throw new CapabilitiesError( + `Failed to fetch ${fullUrl.toString()}; status_code=${response.status}`, + 'network_error' + ) + } + const props = handleFileContent( + response.data, + fullUrl, + toValue(projection), + response.headers['Content-Type'] as string + ) + if (props?.layers?.length === 0) { + throw new CapabilitiesError( + `No valid layer found in ${fullUrl.toString()}`, + 'no_layer_found' + ) + } + return props + } catch (error: any) { + log.error(`Failed to fetch url ${fullUrl}`, error) + if (error instanceof AxiosError) { + throw new CapabilitiesError(error.message, 'network_error') + } + throw error + } + } + + return { loadCapabilities } +} diff --git a/packages/geoadmin-layers/tsconfig.json b/packages/geoadmin-layers/tsconfig.json new file mode 100644 index 0000000000..7b8ecd91fc --- /dev/null +++ b/packages/geoadmin-layers/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.shared.json" +} diff --git a/packages/geoadmin-layers/vite.config.js b/packages/geoadmin-layers/vite.config.js new file mode 100644 index 0000000000..49f41a5f75 --- /dev/null +++ b/packages/geoadmin-layers/vite.config.js @@ -0,0 +1,38 @@ +import { resolve } from 'path' +import dts from 'unplugin-dts/vite' +import { fileURLToPath, URL } from 'url' +import { defineConfig } from 'vite' + +export default defineConfig({ + build: { + lib: { + entry: { + index: resolve(__dirname, 'src/index.ts'), + api: resolve(__dirname, 'src/api/index.ts'), + parsers: resolve(__dirname, 'src/parsers/index.ts'), + utils: resolve(__dirname, 'src/utils/index.ts'), + validation: resolve(__dirname, 'src/validation/index.ts'), + vue: resolve(__dirname, 'src/vue/index.ts'), + }, + name: '@geoadmin/utils', + }, + rollupOptions: { + output: { + exports: 'named', + }, + }, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + plugins: [ + dts({ + bundleTypes: true, + }), + ], + test: { + environment: 'jsdom', + }, +}) diff --git a/packages/geoadmin-log/package.json b/packages/geoadmin-log/package.json index 31a06b843d..732f3b756e 100644 --- a/packages/geoadmin-log/package.json +++ b/packages/geoadmin-log/package.json @@ -28,6 +28,11 @@ "generate-types": "vue-tsc --declaration", "type-check": "vue-tsc -p tsconfig.json" }, + "devDependencies": { + "@microsoft/api-extractor": "catalog:", + "unplugin-dts": "catalog:", + "vite": "catalog:" + }, "peerDependencies": { "proj4": "catalog:" } diff --git a/packages/geoadmin-log/src/Message.ts b/packages/geoadmin-log/src/Message.ts index ad6e767c51..67c5a8829a 100644 --- a/packages/geoadmin-log/src/Message.ts +++ b/packages/geoadmin-log/src/Message.ts @@ -13,7 +13,7 @@ export class Message { this.params = params ?? {} } - isEquals(object: Message) { + isEquals(object: Message): boolean { return ( object instanceof Message && object.msg === this.msg && diff --git a/packages/geoadmin-log/vite.config.ts b/packages/geoadmin-log/vite.config.ts index 01e64f025a..6ee13ea860 100644 --- a/packages/geoadmin-log/vite.config.ts +++ b/packages/geoadmin-log/vite.config.ts @@ -1,28 +1,24 @@ import { resolve } from 'path' -import { defineConfig } from 'vite' -import dts from 'vite-plugin-dts' +import dts from 'unplugin-dts/vite' -export default defineConfig(({ mode }) => { - return { - build: { - lib: { - entry: { - index: resolve(__dirname, 'src/index.ts'), - Message: resolve(__dirname, 'src/Message.ts'), - }, - name: '@geoadmin/log', +export default { + build: { + lib: { + entry: { + index: resolve(__dirname, 'src/index.ts'), + Message: resolve(__dirname, 'src/Message.ts'), }, - rollupOptions: { - output: { - exports: 'named', - }, + name: '@geoadmin/log', + }, + rollupOptions: { + output: { + exports: 'named', }, - minify: mode !== 'development', }, - plugins: [ - dts({ - outDir: 'dist', - }), - ], - } -}) + }, + plugins: [ + dts({ + bundleTypes: true, + }), + ], +} diff --git a/packages/geoadmin-numbers/package.json b/packages/geoadmin-numbers/package.json index 8f9eaf7d41..fe9f8f35f7 100644 --- a/packages/geoadmin-numbers/package.json +++ b/packages/geoadmin-numbers/package.json @@ -15,10 +15,10 @@ ], "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", + "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", "dev": "vite", "generate-types": "vue-tsc --declaration", "test:unit": "vitest --run --mode development --environment jsdom", @@ -29,9 +29,10 @@ "@geoadmin/log": "workspace:*" }, "devDependencies": { + "@microsoft/api-extractor": "catalog:", "chai": "catalog:", + "unplugin-dts": "catalog:", "vite": "catalog:", - "vite-plugin-dts": "catalog:", "vitest": "catalog:", "vue-tsc": "catalog:" } diff --git a/packages/geoadmin-numbers/src/index.ts b/packages/geoadmin-numbers/src/index.ts index fd65f5dfe4..4fb4881604 100644 --- a/packages/geoadmin-numbers/src/index.ts +++ b/packages/geoadmin-numbers/src/index.ts @@ -130,7 +130,7 @@ export function isTimestampYYYYMMDD(timestamp: string): boolean { */ export function circularMean(values: number[]): number | undefined { if (!Array.isArray(values) || values.some((value) => !isNumber(value))) { - return undefined + return } const sumCos = values.reduce((acc, curr) => acc + Math.cos(curr), 0) const sumSin = values.reduce((acc, curr) => acc + Math.sin(curr), 0) @@ -146,7 +146,19 @@ export function circularMean(values: number[]): number | undefined { return mean } -const numbers = { +export interface GeoadminNumberUtils { + round: typeof round + closest: typeof closest + isNumber: typeof isNumber + randomIntBetween: typeof randomIntBetween + format: typeof format + formatThousand: typeof formatThousand + wrapDegrees: typeof wrapDegrees + isTimestampYYYYMMDD: typeof isTimestampYYYYMMDD + circularMean: typeof circularMean +} + +const numbers: GeoadminNumberUtils = { round, closest, isNumber, diff --git a/packages/geoadmin-numbers/vite.config.js b/packages/geoadmin-numbers/vite.config.js index 09c36d2306..d1113bc94c 100644 --- a/packages/geoadmin-numbers/vite.config.js +++ b/packages/geoadmin-numbers/vite.config.js @@ -1,12 +1,14 @@ import { resolve } from 'path' +import dts from 'unplugin-dts/vite' import { fileURLToPath, URL } from 'url' -import dts from 'vite-plugin-dts' export default { build: { lib: { entry: [resolve(__dirname, 'src/index.ts')], - name: '@geoadmin/utils', + name: '@geoadmin/numbers', + formats: ['es'], + filename: 'geoadmin-numbers', }, rollupOptions: { output: { @@ -21,7 +23,7 @@ export default { }, plugins: [ dts({ - outDir: 'dist', + bundleTypes: true, }), ], } diff --git a/packages/geoadmin-tooltip/package.json b/packages/geoadmin-tooltip/package.json index 7608836cd0..9d156f5be2 100644 --- a/packages/geoadmin-tooltip/package.json +++ b/packages/geoadmin-tooltip/package.json @@ -20,10 +20,10 @@ ], "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", + "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", "dev": "vite --host", "generate-types": "vue-tsc --declaration", "preview": "vite preview", @@ -33,12 +33,14 @@ "@floating-ui/vue": "catalog:" }, "devDependencies": { + "@microsoft/api-extractor": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/jsdom": "catalog:", "@vitejs/plugin-vue": "catalog:", "@vue/tsconfig": "catalog:", "tailwindcss": "catalog:", + "unplugin-dts": "catalog:", "vite": "catalog:", "vite-plugin-vue-devtools": "catalog:", "vitest": "catalog:", diff --git a/packages/geoadmin-tooltip/src/DevApp.vue b/packages/geoadmin-tooltip/src/DevApp.vue index ba121d5db1..403a87a1fe 100644 --- a/packages/geoadmin-tooltip/src/DevApp.vue +++ b/packages/geoadmin-tooltip/src/DevApp.vue @@ -3,13 +3,13 @@ import { useTemplateRef } from 'vue' import GeoadminTooltip from '@/GeoadminTooltip.vue' -const manualTooltip = useTemplateRef('manualTooltip') +const manualTooltipRef = useTemplateRef>('manualTooltip') function toggleManualTooltip() { - if (manualTooltip.value?.isOpen) { - manualTooltip.value?.closeTooltip() + if (manualTooltipRef.value?.isOpen) { + manualTooltipRef.value?.closeTooltip() } else { - manualTooltip.value?.openTooltip() + manualTooltipRef.value?.openTooltip() } } diff --git a/packages/geoadmin-tooltip/vite.config.js b/packages/geoadmin-tooltip/vite.config.js index 7e887f4f8a..008bc4961c 100644 --- a/packages/geoadmin-tooltip/vite.config.js +++ b/packages/geoadmin-tooltip/vite.config.js @@ -1,11 +1,10 @@ import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' +import dts from 'unplugin-dts/vite' import { fileURLToPath, URL } from 'url' -import dts from 'vite-plugin-dts' import vueDevTools from 'vite-plugin-vue-devtools' - export default { build: { lib: { @@ -18,7 +17,7 @@ export default { exports: 'named', globals: { vue: 'Vue', - } + }, }, }, }, @@ -31,6 +30,9 @@ export default { tailwindcss(), vue(), vueDevTools(), - dts(), + dts({ + bundleTypes: true, + processor: 'vue', + }), ], } diff --git a/packages/mapviewer/package.json b/packages/mapviewer/package.json index a4b62e9e7b..72d14d92bc 100644 --- a/packages/mapviewer/package.json +++ b/packages/mapviewer/package.json @@ -6,8 +6,8 @@ "build": "pnpm run type-check && vite build", "build:dev": "pnpm run build --mode development", "build:int": "pnpm run build --mode integration", - "build:test": "pnpm run build --mode test", "build:prod": "pnpm run build --mode production", + "build:test": "pnpm run build --mode test", "check:external": "npx vite-node scripts/check-external-layers-providers.js", "delete:reports": "rimraf tests/results/ || true", "delete:reports:unit": "rimraf tests/results/unit/ || true", @@ -22,11 +22,11 @@ "preview:int:https": "USE_HTTPS=1 pnpm run preview:int", "preview:prod": "pnpm run build:prod && vite preview --mode production --port 8080 --host --outDir ./dist/prod", "preview:prod:https": "USE_HTTPS=1 pnpm run preview:prod", + "preview:test": "pnpm run build:test && vite preview --mode test --port 8080 --host --outDir ./dist/dev", "prod": "vite --port 8080 --host --cors --mode production", "prod:https": "USE_HTTPS=1 pnpm run prod", "start": "pnpm run dev", "test": "vite --port 8080 --host --cors --mode test", - "preview:test": "pnpm run build:test && vite preview --mode test --port 8080 --host --outDir ./dist/dev", "test:component": "cypress open --component", "test:component:ci": "cypress run --component --record --tag ${CYPRESS_TAGS} --group component/chrome/mobile --ci-build-id ${CODEBUILD_INITIATOR}", "test:component:headless": "cypress run --component", @@ -49,14 +49,15 @@ "@fortawesome/vue-fontawesome": "catalog:", "@geoadmin/coordinates": "workspace:*", "@geoadmin/elevation-profile": "workspace:*", + "@geoadmin/layers": "workspace:*", "@geoadmin/log": "workspace:*", "@geoadmin/numbers": "workspace:*", "@geoadmin/tooltip": "workspace:*", "@geoblocks/cesium-compass": "catalog:", "@geoblocks/mapfishprint": "catalog:", "@geoblocks/ol-maplibre-layer": "catalog:", - "@mapbox/togeojson": "catalog:", "@popperjs/core": "catalog:", + "@tmcw/togeojson": "catalog:", "@turf/turf": "catalog:", "@vueuse/core": "catalog:", "animate.css": "catalog:", @@ -73,9 +74,11 @@ "jquery": "catalog:", "jszip": "catalog:", "lodash": "catalog:", + "luxon": "catalog:", "maplibre-gl": "catalog:", "ol": "catalog:", "pako": "catalog:", + "pinia": "catalog:", "print-js": "catalog:", "proj4": "catalog:", "reproject": "catalog:", @@ -85,8 +88,7 @@ "vue-chartjs": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", - "vue3-social-sharing": "catalog:", - "vuex": "catalog:" + "vue3-social-sharing": "catalog:" }, "devDependencies": { "@4tw/cypress-drag-drop": "catalog:", @@ -98,10 +100,14 @@ "@rushstack/eslint-patch": "catalog:", "@tailwindcss/vite": "catalog:", "@types/bootstrap": "catalog:", + "@types/lodash": "catalog:", + "@types/luxon": "catalog:", + "@types/pako": "catalog:", "@vite-pwa/assets-generator": "catalog:", "@vitejs/plugin-basic-ssl": "catalog:", "@vitejs/plugin-vue": "catalog:", "@vue/tsconfig": "catalog:", + "@xmldom/xmldom": "catalog:", "axios-retry": "catalog:", "chai": "catalog:", "cypress": "catalog:", @@ -117,6 +123,7 @@ "jsdom": "catalog:", "mime-types": "catalog:", "mocha-junit-reporter": "catalog:", + "pinia-logger": "catalog:", "rimraf": "catalog:", "sass": "catalog:", "sharp": "catalog:", @@ -128,6 +135,7 @@ "vite-plugin-pwa": "catalog:", "vite-plugin-static-copy": "catalog:", "vite-plugin-vue-devtools": "catalog:", + "vite-tsconfig-paths": "catalog:", "vitest": "catalog:", "workbox-cacheable-response": "catalog:", "workbox-core": "catalog:", diff --git a/packages/mapviewer/src/App.vue b/packages/mapviewer/src/App.vue index b98c0792d9..278f12c98d 100644 --- a/packages/mapviewer/src/App.vue +++ b/packages/mapviewer/src/App.vue @@ -1,31 +1,34 @@ - @@ -41,7 +45,7 @@ function onSizeSelect(dropdownItem) { -import { ref } from 'vue' + @@ -31,12 +31,9 @@ function onColorChange(color) { > {{ t('modify_text_color_label') }} -
+
- - diff --git a/packages/mapviewer/src/modules/infobox/components/styling/FeatureStyleEdit.vue b/packages/mapviewer/src/modules/infobox/components/styling/FeatureStyleEdit.vue index 4ec98ea59a..fa71594c85 100644 --- a/packages/mapviewer/src/modules/infobox/components/styling/FeatureStyleEdit.vue +++ b/packages/mapviewer/src/modules/infobox/components/styling/FeatureStyleEdit.vue @@ -7,7 +7,7 @@ import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from import { useI18n } from 'vue-i18n' import { useStore } from 'vuex' -import EditableFeature, { EditableFeatureTypes } from '@/api/features/EditableFeature.class' +import EditableFeature, { EditableFeatureTypes } from '@/api/features/EditableFeature.class.js' import FeatureAreaInfo from '@/modules/infobox/components/FeatureAreaInfo.vue' import ShowGeometryProfileButton from '@/modules/infobox/components/ShowGeometryProfileButton.vue' import DrawingStyleColorSelector from '@/modules/infobox/components/styling/DrawingStyleColorSelector.vue' @@ -21,7 +21,7 @@ import MediaTypes from '@/modules/infobox/DrawingStyleMediaTypes.enum.js' import CoordinateCopySlot from '@/utils/components/CoordinateCopySlot.vue' import allFormats from '@/utils/coordinates/coordinateFormat' import debounce from '@/utils/debounce' -import { calculateTextOffset } from '@/utils/featureStyleUtils' +import { calculateTextOffset } from '@/utils/featureStyleUtils.js' const dispatcher = { dispatcher: 'FeatureStyleEdit.vue' } diff --git a/packages/mapviewer/src/modules/map/components/CompareSlider.vue b/packages/mapviewer/src/modules/map/components/CompareSlider.vue index 2b324c6958..3c31855e3a 100644 --- a/packages/mapviewer/src/modules/map/components/CompareSlider.vue +++ b/packages/mapviewer/src/modules/map/components/CompareSlider.vue @@ -1,12 +1,11 @@ @@ -196,7 +209,7 @@ function clearClick() { >
diff --git a/packages/mapviewer/src/modules/map/components/LocationPopupPosition.vue b/packages/mapviewer/src/modules/map/components/LocationPopupPosition.vue index 31d683ba84..34570e9d22 100644 --- a/packages/mapviewer/src/modules/map/components/LocationPopupPosition.vue +++ b/packages/mapviewer/src/modules/map/components/LocationPopupPosition.vue @@ -7,9 +7,9 @@ import log from '@geoadmin/log' import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import { requestHeight } from '@/api/height.api' -import reframe from '@/api/lv03Reframe.api' -import { registerWhat3WordsLocation } from '@/api/what3words.api' +import { requestHeight } from '@/api/height.api.js' +import reframe from '@/api/lv03Reframe.api.js' +import { registerWhat3WordsLocation } from '@/api/what3words.api.js' import CoordinateCopySlot from '@/utils/components/CoordinateCopySlot.vue' import { LV03Format, @@ -127,7 +127,7 @@ async function updateHeight() { role="tabpanel" aria-labelledby="nav-local-tab" > -
+
+import { LayerType } from '@geoadmin/layers' import log from '@geoadmin/log' import { BillboardGraphics, @@ -11,13 +12,12 @@ import { } from 'cesium' import { computed, inject, toRef, watch } from 'vue' -import GPXLayer from '@/api/layers/GPXLayer.class' -import { GPX_BILLBOARD_RADIUS } from '@/config/cesium.config' +import { GPX_BILLBOARD_RADIUS } from '@/config/cesium.config.js' import useAddDataSourceLayer from '@/modules/map/components/cesium/utils/useAddDataSourceLayer.composable' const { gpxLayerConfig } = defineProps({ gpxLayerConfig: { - type: GPXLayer, + validator: (value) => value.type === LayerType.GPX, required: true, }, }) diff --git a/packages/mapviewer/src/modules/map/components/cesium/CesiumGeoJSONLayer.vue b/packages/mapviewer/src/modules/map/components/cesium/CesiumGeoJSONLayer.vue index f0efe85dc5..50647165ed 100644 --- a/packages/mapviewer/src/modules/map/components/cesium/CesiumGeoJSONLayer.vue +++ b/packages/mapviewer/src/modules/map/components/cesium/CesiumGeoJSONLayer.vue @@ -1,18 +1,18 @@