@swissgeo/coordinates 1.0.0-rc.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +118 -0
  2. package/dist/index.cjs +7 -0
  3. package/dist/index.d.ts +1 -12
  4. package/dist/index.js +8012 -18103
  5. package/dist/ol.cjs +1 -0
  6. package/dist/ol.d.ts +10 -0
  7. package/dist/ol.js +4467 -0
  8. package/dist/registerProj4-BuUOcPpF.cjs +23 -0
  9. package/dist/registerProj4-CwR_kPOz.js +10172 -0
  10. package/eslint.config.mts +12 -0
  11. package/index.html +14 -0
  12. package/package.json +30 -23
  13. package/setup-vitest.ts +8 -0
  14. package/src/DevApp.vue +65 -0
  15. package/src/__test__/coordinatesUtils.spec.ts +178 -0
  16. package/src/__test__/extentUtils.spec.ts +92 -0
  17. package/src/coordinatesUtils.ts +188 -0
  18. package/src/dev.ts +6 -0
  19. package/src/extentUtils.ts +196 -0
  20. package/src/index.ts +29 -0
  21. package/src/ol.ts +52 -0
  22. package/src/proj/CoordinateSystem.ts +315 -0
  23. package/src/proj/CoordinateSystemBounds.ts +170 -0
  24. package/src/proj/CustomCoordinateSystem.ts +58 -0
  25. package/src/proj/LV03CoordinateSystem.ts +23 -0
  26. package/src/proj/LV95CoordinateSystem.ts +35 -0
  27. package/src/proj/StandardCoordinateSystem.ts +22 -0
  28. package/src/proj/SwissCoordinateSystem.ts +233 -0
  29. package/src/proj/WGS84CoordinateSystem.ts +97 -0
  30. package/src/proj/WebMercatorCoordinateSystem.ts +89 -0
  31. package/src/proj/__test__/CoordinateSystem.spec.ts +63 -0
  32. package/src/proj/__test__/CoordinateSystemBounds.spec.ts +252 -0
  33. package/src/proj/__test__/SwissCoordinateSystem.spec.ts +136 -0
  34. package/src/proj/index.ts +65 -0
  35. package/src/proj/types.ts +22 -0
  36. package/src/registerProj4.ts +38 -0
  37. package/tsconfig.json +4 -0
  38. package/vite.config.ts +46 -0
  39. package/dist/index.umd.cjs +0 -29
@@ -0,0 +1,170 @@
1
+ import type { Coord } from '@turf/turf'
2
+ import type { Feature, FeatureCollection, GeoJsonProperties, LineString } from 'geojson'
3
+
4
+ import {
5
+ bboxPolygon,
6
+ booleanPointInPolygon,
7
+ distance,
8
+ lineSplit,
9
+ lineString,
10
+ points,
11
+ } from '@turf/turf'
12
+ import { sortBy } from 'lodash'
13
+
14
+ import type { SingleCoordinate } from '@/coordinatesUtils'
15
+ import type { CoordinatesChunk } from '@/proj/types'
16
+
17
+ interface CoordinateSystemBoundsProps {
18
+ lowerX: number
19
+ upperX: number
20
+ lowerY: number
21
+ upperY: number
22
+ customCenter?: SingleCoordinate
23
+ }
24
+
25
+ /**
26
+ * Turf's lineSplit function doesn't ensure split path will be ordered in its output.
27
+ *
28
+ * This code aims to reorder the result of this function in the correct order. Comes from a GitHub
29
+ * issue (see link below)
30
+ *
31
+ * @see https://github.com/Turfjs/turf/issues/1989#issuecomment-753147919
32
+ */
33
+ function reassembleLineSegments(
34
+ origin: Coord,
35
+ path: FeatureCollection<LineString, GeoJsonProperties>
36
+ ): Feature<LineString>[] {
37
+ let candidateFeatures = path.features
38
+ const orderedFeatures: Feature<LineString, GeoJsonProperties>[] = []
39
+ while (candidateFeatures.length > 0) {
40
+ candidateFeatures = sortBy(candidateFeatures, (f) => {
41
+ if (f.geometry && f.geometry.coordinates && f.geometry.coordinates[0]) {
42
+ return distance(origin, f.geometry.coordinates[0])
43
+ } else {
44
+ throw new Error('Feature missing geometry')
45
+ }
46
+ })
47
+ const closest = candidateFeatures.shift()!
48
+ const closestOrigin = closest.geometry.coordinates[closest.geometry.coordinates.length - 1]
49
+ if (closestOrigin) {
50
+ origin = closestOrigin
51
+ }
52
+ orderedFeatures.push(closest)
53
+ }
54
+ return orderedFeatures
55
+ }
56
+
57
+ /**
58
+ * Representation of boundaries of a coordinate system (also sometime called extent)
59
+ *
60
+ * It is expressed by the most bottom left points possible / top right point possible, meaning that
61
+ * a combination of these two gives us the area in which the coordinate system can produce valid
62
+ * coordinates
63
+ */
64
+ export default class CoordinateSystemBounds {
65
+ public readonly lowerX: number
66
+ public readonly upperX: number
67
+ public readonly lowerY: number
68
+ public readonly upperY: number
69
+ public readonly customCenter?: SingleCoordinate
70
+
71
+ public readonly bottomLeft: SingleCoordinate
72
+ public readonly bottomRight: SingleCoordinate
73
+ public readonly topLeft: SingleCoordinate
74
+ public readonly topRight: SingleCoordinate
75
+
76
+ public readonly center: SingleCoordinate
77
+ /** A flattened version of the bounds such as [lowerX, lowerY, upperX, upperY] */
78
+ public readonly flatten: [number, number, number, number]
79
+
80
+ /**
81
+ * @param args.lowerX
82
+ * @param args.upperX
83
+ * @param args.lowerY
84
+ * @param args.upperY
85
+ * @param args.customCenter If this bounds must have a different center (if we want to offset
86
+ * the natural center of those bounds). If no custom center is given, the center will be
87
+ * calculated relative to the bounds.
88
+ */
89
+ constructor(args: CoordinateSystemBoundsProps) {
90
+ const { lowerX, upperX, lowerY, upperY, customCenter } = args
91
+ this.lowerX = lowerX
92
+ this.upperX = upperX
93
+ this.lowerY = lowerY
94
+ this.upperY = upperY
95
+ this.customCenter = customCenter
96
+
97
+ this.bottomLeft = [this.lowerX, this.lowerY]
98
+ this.bottomRight = [this.upperX, this.lowerY]
99
+ this.topLeft = [this.lowerX, this.upperY]
100
+ this.topRight = [this.upperX, this.upperY]
101
+
102
+ this.center = this.customCenter ?? [
103
+ (this.lowerX + this.upperX) / 2,
104
+ (this.lowerY + this.upperY) / 2,
105
+ ]
106
+ this.flatten = [this.lowerX, this.lowerY, this.upperX, this.upperY]
107
+ }
108
+
109
+ isInBounds(x: number, y: number): boolean
110
+ isInBounds(coordinate: SingleCoordinate): boolean
111
+
112
+ isInBounds(xOrCoordinate: number | SingleCoordinate, y?: number): boolean {
113
+ if (typeof xOrCoordinate === 'number') {
114
+ return (
115
+ xOrCoordinate >= this.lowerX &&
116
+ xOrCoordinate <= this.upperX &&
117
+ y! >= this.lowerY &&
118
+ y! <= this.upperY
119
+ )
120
+ }
121
+ return this.isInBounds(xOrCoordinate[0], xOrCoordinate[1])
122
+ }
123
+
124
+ /**
125
+ * Will split the coordinates in chunks if some portion of the coordinates are outside bounds
126
+ * (one chunk for the portion inside, one for the portion outside, rinse and repeat if
127
+ * necessary)
128
+ *
129
+ * Can be helpful when requesting information from our backends, but said backend doesn't
130
+ * support world-wide coverage. Typical example is service-profile, if we give it coordinates
131
+ * outside LV95 bounds it will fill what it doesn't know with coordinates following LV95 extent
132
+ * instead of returning undefined
133
+ *
134
+ * @param {[Number, Number][]} coordinates Coordinates `[[x1,y1],[x2,y2],...]` expressed in the
135
+ * same coordinate system (projection) as the bounds
136
+ * @returns {CoordinatesChunk[] | undefined}
137
+ */
138
+ splitIfOutOfBounds(coordinates: SingleCoordinate[]): CoordinatesChunk[] | undefined {
139
+ if (!Array.isArray(coordinates) || coordinates.length <= 1) {
140
+ return
141
+ }
142
+ // checking that all coordinates are well-formed
143
+ if (coordinates.find((coordinate) => coordinate.length !== 2)) {
144
+ return
145
+ }
146
+ // checking if we require splitting
147
+ if (coordinates.find((coordinate) => !this.isInBounds(coordinate))) {
148
+ const boundsAsPolygon = bboxPolygon(this.flatten)
149
+ const paths = lineSplit(lineString(coordinates), boundsAsPolygon)
150
+ if (coordinates[0]) {
151
+ paths.features = reassembleLineSegments(coordinates[0], paths)
152
+ }
153
+ return paths.features.map((chunk) => {
154
+ return {
155
+ coordinates: chunk.geometry.coordinates,
156
+ isWithinBounds: points(chunk.geometry.coordinates).features.every((point) =>
157
+ booleanPointInPolygon(point, boundsAsPolygon)
158
+ ),
159
+ } as CoordinatesChunk
160
+ })
161
+ }
162
+ // no splitting needed, we return the coordinates as they were given
163
+ return [
164
+ {
165
+ coordinates: coordinates,
166
+ isWithinBounds: true,
167
+ },
168
+ ]
169
+ }
170
+ }
@@ -0,0 +1,58 @@
1
+ import type { SingleCoordinate } from '@/coordinatesUtils'
2
+ import type { CoordinateSystemProps } from '@/proj/CoordinateSystem'
3
+ import type CoordinateSystemBounds from '@/proj/CoordinateSystemBounds'
4
+
5
+ import CoordinateSystem from '@/proj/CoordinateSystem'
6
+
7
+ export interface CustomCoordinateSystemProps extends CoordinateSystemProps {
8
+ /** With a custom coordinate system, bounds are mandatory. */
9
+ bounds: CoordinateSystemBounds
10
+ }
11
+
12
+ /**
13
+ * Description of a coordinate system that will not use the standard resolution and zoom level
14
+ * (a.k.a. mercator pyramid).
15
+ *
16
+ * This can be used to describe national coordinate systems that are built to represent a subset of
17
+ * the World, with a custom zoom pyramid to match their map resolutions.
18
+ *
19
+ * You can see examples by following {@link SwissCoordinateSystem} and its children.
20
+ *
21
+ * @abstract
22
+ * @see https://wiki.openstreetmap.org/wiki/Zoom_levels
23
+ */
24
+ export default abstract class CustomCoordinateSystem extends CoordinateSystem {
25
+ declare public readonly bounds: CoordinateSystemBounds
26
+
27
+ protected constructor(args: CustomCoordinateSystemProps) {
28
+ super(args)
29
+ }
30
+
31
+ /**
32
+ * The origin to use as anchor for tile coordinate calculations. It will return the bound's
33
+ * [lowerX, upperY] as default value (meaning the top-left corner of bounds). If this is not the
34
+ * behavior you want, you have to override this function.
35
+ */
36
+ getTileOrigin(): SingleCoordinate {
37
+ return this.bounds.topLeft
38
+ }
39
+
40
+ /**
41
+ * Transforms a zoom level from this custom coordinate system, back to a zoom level such as
42
+ * described in https://wiki.openstreetmap.org/wiki/Zoom_levels
43
+ *
44
+ * @abstract
45
+ * @param customZoomLevel A zoom level in this custom coordinate system
46
+ * @returns A standard (or OpenStreetMap) zoom level
47
+ */
48
+ abstract transformCustomZoomLevelToStandard(customZoomLevel: number): number
49
+
50
+ /**
51
+ * Transforms a standard (or OpenStreetMap) zoom level into a zoom level in this coordinate
52
+ * system
53
+ *
54
+ * @param standardZoomLevel A standard zoom level
55
+ * @returns A zoom level in this custom coordinate system
56
+ */
57
+ abstract transformStandardZoomLevelToCustom(standardZoomLevel: number): number
58
+ }
@@ -0,0 +1,23 @@
1
+ import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds'
2
+ import SwissCoordinateSystem from '@/proj/SwissCoordinateSystem'
3
+
4
+ export default class LV03CoordinateSystem extends SwissCoordinateSystem {
5
+ constructor() {
6
+ super({
7
+ epsgNumber: 21781,
8
+ label: 'CH1903 / LV03',
9
+ technicalName: 'LV03',
10
+ // matrix is coming fom https://epsg.io/21781.proj4
11
+ proj4transformationMatrix:
12
+ '+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 +x_0=600000 +y_0=200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs',
13
+ // bound is coming from https://epsg.io/21781
14
+ bounds: new CoordinateSystemBounds({
15
+ lowerX: 485071.58,
16
+ upperX: 837119.8,
17
+ lowerY: 74261.72,
18
+ upperY: 299941.79,
19
+ }),
20
+ usesMercatorPyramid: false,
21
+ })
22
+ }
23
+ }
@@ -0,0 +1,35 @@
1
+ import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds'
2
+ import SwissCoordinateSystem from '@/proj/SwissCoordinateSystem'
3
+
4
+ export default class LV95CoordinateSystem extends SwissCoordinateSystem {
5
+ constructor() {
6
+ super({
7
+ epsgNumber: 2056,
8
+ label: 'CH1903+ / LV95',
9
+ technicalName: 'LV95',
10
+ // matrix is coming fom https://epsg.io/2056.proj4
11
+ proj4transformationMatrix:
12
+ '+proj=somerc +lat_0=46.9524055555556 +lon_0=7.43958333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs +type=crs',
13
+ /**
14
+ * This can be used to constrain OpenLayers (or another mapping framework) to only ask
15
+ * for tiles that are within the extent. It should remove, for instance, the big white
16
+ * zone that is around the pixelkarte-farbe.
17
+ *
18
+ * Values are a ripoff of mf-geoadmin3 (see link below) and are not technically the
19
+ * mathematical bounds of the system, but the limit at which we do not serve data
20
+ * anymore.
21
+ *
22
+ * Those are coordinates expressed in EPSG:2056 (or LV95)
23
+ *
24
+ * @see https://github.com/geoadmin/mf-geoadmin3/blob/0ec560069e93fdceb54ce126a3c2d0ef23a50f45/mk/config.mk#L140
25
+ */
26
+ bounds: new CoordinateSystemBounds({
27
+ lowerX: 2420000,
28
+ upperX: 2900000,
29
+ lowerY: 1030000,
30
+ upperY: 1350000,
31
+ }),
32
+ usesMercatorPyramid: false,
33
+ })
34
+ }
35
+ }
@@ -0,0 +1,22 @@
1
+ import CoordinateSystem, { STANDARD_ZOOM_LEVEL_1_25000_MAP } from '@/proj/CoordinateSystem'
2
+
3
+ /**
4
+ * Coordinate system with a zoom level/resolution calculation based on the size of the Earth at the
5
+ * equator.
6
+ *
7
+ * These will be used to represent WebMercator and WGS84 among others.
8
+ *
9
+ * @abstract
10
+ * @see https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale
11
+ * @see https://wiki.openstreetmap.org/wiki/Zoom_levels
12
+ */
13
+ export default abstract class StandardCoordinateSystem extends CoordinateSystem {
14
+ /** The index in the resolution list where the 1:25000 zoom level is */
15
+ get1_25000ZoomLevel(): number {
16
+ return STANDARD_ZOOM_LEVEL_1_25000_MAP
17
+ }
18
+
19
+ getDefaultZoom(): number {
20
+ return STANDARD_ZOOM_LEVEL_1_25000_MAP
21
+ }
22
+ }
@@ -0,0 +1,233 @@
1
+ import { closest, round } from '@swissgeo/numbers'
2
+
3
+ import type { ResolutionStep } from '@/proj/types'
4
+
5
+ import {
6
+ STANDARD_ZOOM_LEVEL_1_25000_MAP,
7
+ SWISS_ZOOM_LEVEL_1_25000_MAP,
8
+ } from '@/proj/CoordinateSystem'
9
+ import CustomCoordinateSystem from '@/proj/CustomCoordinateSystem'
10
+
11
+ /**
12
+ * Resolutions for each LV95 zoom level, from 0 to 14
13
+ *
14
+ * @see https://api3.geo.admin.ch/services/sdiservices.html#gettile
15
+ */
16
+ export const LV95_RESOLUTIONS: number[] = [
17
+ 650.0, 500.0, 250.0, 100.0, 50.0, 20.0, 10.0, 5.0, 2.5, 2.0, 1.0, 0.5, 0.25, 0.1,
18
+ ]
19
+
20
+ /**
21
+ * Resolutions steps (one per zoom level) for our own WMTS pyramid (see
22
+ * {@link http://api3.geo.admin.ch/services/sdiservices.html#wmts}) expressed in meters/pixel
23
+ *
24
+ * Be mindful that zoom levels described on our doc are expressed for LV95 and need conversion to
25
+ * World Wide zoom level (see {@link SwissCoordinateSystem})
26
+ *
27
+ * It is essentially, at low resolution, the same as {@link LV95_RESOLUTIONS}, but with added steps
28
+ * at higher zoom level (further from the ground)
29
+ */
30
+ export const SWISSTOPO_TILEGRID_RESOLUTIONS: number[] = [
31
+ 4000.0,
32
+ 3750.0,
33
+ 3500.0,
34
+ 3250.0,
35
+ 3000.0,
36
+ 2750.0,
37
+ 2500.0,
38
+ 2250.0,
39
+ 2000.0,
40
+ 1750.0,
41
+ 1500.0,
42
+ 1250.0,
43
+ 1000.0,
44
+ 750.0,
45
+ ...LV95_RESOLUTIONS.slice(0, 10),
46
+ // see table https://api3.geo.admin.ch/services/sdiservices.html#gettile
47
+ // LV95 doesn't support zoom level 10 at 1.5 resolution, so we need to split
48
+ // the resolution and add it here
49
+ 1.5,
50
+ ...LV95_RESOLUTIONS.slice(10),
51
+ ]
52
+
53
+ /**
54
+ * Conversion matrix from swisstopo LV95 zoom level to Web Mercator zoom level
55
+ *
56
+ * Indexes of the array are LV95 zoom levels
57
+ *
58
+ * Values are mercator equivalents
59
+ *
60
+ * @type {Number[]}
61
+ */
62
+ export const SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX: number[] = [
63
+ 7.35, // min: 0
64
+ 7.75, // 1
65
+ 8.75, // 2
66
+ 10, // 3
67
+ 11, // 4
68
+ 12.5, // 5
69
+ 13.5, // 6
70
+ 14.5, // 7
71
+ STANDARD_ZOOM_LEVEL_1_25000_MAP, // 8
72
+ 15.75, // 9
73
+ 16.7, // 10
74
+ 17.75, // 11
75
+ 18.75, // 12
76
+ 20, // 13
77
+ 21, // max: 14
78
+ ]
79
+
80
+ const SWISSTOPO_ZOOM_TO_PRODUCT_SCALE: string[] = [
81
+ "1:2'500'000", // zoom 0
82
+ "1:2'500'000", // 1
83
+ "1:1'000'000", // 2
84
+ "1:1'000'000", // 3
85
+ "1:500'000", // 4
86
+ "1:200'000", // 5
87
+ "1:100'000", // 6
88
+ "1:50'000", // 7
89
+ "1:25'000", // 8
90
+ "1:25'000", // 9
91
+ "1:10'000", // 10
92
+ "1:10'000", // 11
93
+ "1:10'000", // 12
94
+ "1:10'000", // 13
95
+ "1:10'000", // max zoom: 14
96
+ ]
97
+
98
+ const swisstopoZoomLevels: number[] = SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX.map(
99
+ (_, index) => index
100
+ )
101
+
102
+ /**
103
+ * This specialization will be used to represent LV95 and LV03, that use a custom zoom/resolution
104
+ * pyramid to match all our printable products (in contrast to {@link StandardCoordinateSystem} which
105
+ * bases its zoom/resolution on the radius of the Earth at the equator and latitude positioning of
106
+ * the map).
107
+ *
108
+ * @abstract
109
+ * @see https://api3.geo.admin.ch/services/sdiservices.html#wmts
110
+ * @see https://wiki.openstreetmap.org/wiki/Zoom_levels
111
+ */
112
+ export default class SwissCoordinateSystem extends CustomCoordinateSystem {
113
+ getResolutionSteps(): ResolutionStep[] {
114
+ return SWISSTOPO_TILEGRID_RESOLUTIONS.map((resolution) => {
115
+ const zoom: number | undefined = LV95_RESOLUTIONS.indexOf(resolution) ?? undefined
116
+ let label: string | undefined
117
+ if (zoom) {
118
+ label = SWISSTOPO_ZOOM_TO_PRODUCT_SCALE[zoom]
119
+ }
120
+ return {
121
+ zoom,
122
+ label,
123
+ resolution: resolution,
124
+ }
125
+ })
126
+ }
127
+
128
+ get1_25000ZoomLevel(): number {
129
+ return SWISS_ZOOM_LEVEL_1_25000_MAP
130
+ }
131
+
132
+ getDefaultZoom(): number {
133
+ return 1
134
+ }
135
+
136
+ transformStandardZoomLevelToCustom(standardZoomLevel: number): number {
137
+ // checking first if the standard zoom level is within range of swiss zooms we have available
138
+ if (
139
+ typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] === 'number' &&
140
+ typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] === 'number' &&
141
+ standardZoomLevel >= SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] &&
142
+ standardZoomLevel <= SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14]
143
+ ) {
144
+ return SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX.filter(
145
+ (zoom) => zoom < standardZoomLevel
146
+ ).length
147
+ }
148
+ if (
149
+ typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0] === 'number' &&
150
+ standardZoomLevel < SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[0]
151
+ ) {
152
+ return 0
153
+ }
154
+ if (
155
+ typeof SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14] === 'number' &&
156
+ standardZoomLevel > SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[14]
157
+ ) {
158
+ return 14
159
+ }
160
+ // if no matching zoom level was found, we return the one for the 1:25'000 map
161
+ return this.get1_25000ZoomLevel()
162
+ }
163
+
164
+ /**
165
+ * Mapping between Swiss map zooms and standard zooms. Heavily inspired by
166
+ * {@link https://github.com/geoadmin/mf-geoadmin3/blob/ce885985e4af5e3e20c87321e67a650388af3602/src/components/map/MapUtilsService.js#L603-L631 MapUtilsService.js on mf-geoadmin3}
167
+ *
168
+ * @param customZoomLevel A zoom level as desribed in
169
+ * {@link http://api3.geo.admin.ch/services/sdiservices.html#wmts our backend's doc}
170
+ * @returns A web-mercator zoom level (as described on
171
+ * {@link https://wiki.openstreetmap.org/wiki/Zoom_levels | OpenStreetMap's wiki}) or the zoom
172
+ * level to show the 1:25'000 map if the input is invalid
173
+ */
174
+ transformCustomZoomLevelToStandard(customZoomLevel: number): number {
175
+ const key = Math.floor(customZoomLevel)
176
+ if (SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX.length - 1 >= key) {
177
+ return (
178
+ SWISSTOPO_TILEGRID_ZOOM_TO_STANDARD_ZOOM_MATRIX[key] ??
179
+ STANDARD_ZOOM_LEVEL_1_25000_MAP
180
+ )
181
+ }
182
+ // if no matching zoom level was found, we return the one for the 1:25'000 map
183
+ return STANDARD_ZOOM_LEVEL_1_25000_MAP
184
+ }
185
+
186
+ getResolutionForZoomAndCenter(zoom: number): number {
187
+ const roundedZoom = Math.round(zoom)
188
+ if (typeof LV95_RESOLUTIONS[roundedZoom] !== 'number') {
189
+ return 0
190
+ }
191
+ // ignoring the center, as it won't have any effect on the chosen zoom level
192
+ return LV95_RESOLUTIONS[roundedZoom]
193
+ }
194
+
195
+ getZoomForResolutionAndCenter(resolution: number): number {
196
+ // ignoring the center, as it won't have any effect on the resolution
197
+ const matchingResolution = LV95_RESOLUTIONS.find(
198
+ (lv95Resolution) => lv95Resolution <= resolution
199
+ )
200
+ if (matchingResolution) {
201
+ return LV95_RESOLUTIONS.indexOf(matchingResolution)
202
+ }
203
+ // if no match was found, we have to decide if the resolution is too great,
204
+ // or too small to be matched and return the zoom accordingly
205
+ const smallestResolution = LV95_RESOLUTIONS.slice(-1)[0]
206
+ if (smallestResolution && smallestResolution > resolution) {
207
+ // if the resolution was smaller than the smallest available, we return the zoom level corresponding
208
+ // to the smallest available resolution
209
+ return LV95_RESOLUTIONS.indexOf(smallestResolution)
210
+ }
211
+ // otherwise, we return the zoom level corresponding to the greatest resolution available
212
+ return 0
213
+ }
214
+
215
+ roundCoordinateValue(value: number): number {
216
+ return round(value, 2)
217
+ }
218
+
219
+ /**
220
+ * Rounding to the zoom level
221
+ *
222
+ * @param customZoomLevel A zoom level, that could be a floating number
223
+ * @param normalize Normalize the zoom level to the closest swisstopo zoom level, by default it
224
+ * only round the zoom level to 3 decimal
225
+ * @returns A zoom level matching one of our national maps
226
+ */
227
+ roundZoomLevel(customZoomLevel: number, normalize: boolean = false): number {
228
+ if (normalize) {
229
+ return closest(customZoomLevel, swisstopoZoomLevels)
230
+ }
231
+ return super.roundZoomLevel(customZoomLevel)
232
+ }
233
+ }
@@ -0,0 +1,97 @@
1
+ import { round } from '@swissgeo/numbers'
2
+
3
+ import type { SingleCoordinate } from '@/coordinatesUtils'
4
+
5
+ import { PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES } from '@/proj/CoordinateSystem'
6
+ import CoordinateSystemBounds from '@/proj/CoordinateSystemBounds'
7
+ import StandardCoordinateSystem from '@/proj/StandardCoordinateSystem'
8
+
9
+ export default class WGS84CoordinateSystem extends StandardCoordinateSystem {
10
+ constructor() {
11
+ super({
12
+ epsgNumber: 4326,
13
+ label: 'WGS 84 (lat/lon)',
14
+ // matrix comes from https://epsg.io/4326.proj4
15
+ proj4transformationMatrix: '+proj=longlat +datum=WGS84 +no_defs +type=proj',
16
+ bounds: new CoordinateSystemBounds({
17
+ lowerX: -180.0,
18
+ upperX: 180.0,
19
+ lowerY: -90.0,
20
+ upperY: 90.0,
21
+ // center of LV95's extent transformed with epsg.io website
22
+ customCenter: [8.239436, 46.832259],
23
+ }),
24
+ usesMercatorPyramid: true,
25
+ })
26
+ }
27
+
28
+ roundCoordinateValue(value: number): number {
29
+ // a precision of 6 digits means we can track position with 0.111m accuracy
30
+ // see http://wiki.gis.com/wiki/index.php/Decimal_degrees
31
+ return round(value, 6)
32
+ }
33
+
34
+ /**
35
+ * Formula comes from
36
+ * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale
37
+ *
38
+ * resolution = 156543.03 meters / pixel * cos(latitude) / (2 ^ zoom level)
39
+ */
40
+ getResolutionForZoomAndCenter(zoom: number, center: SingleCoordinate): number {
41
+ return round(
42
+ Math.abs(
43
+ (PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES *
44
+ Math.cos((center[1] * Math.PI) / 180.0)) /
45
+ Math.pow(2, zoom)
46
+ ),
47
+ 2
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Ensures an extent is in X,Y order (longitude, latitude). If coordinates are in Y,X order
53
+ * (latitude, longitude), swaps them. WGS84 traditionally uses latitude-first (Y,X) axis order
54
+ * [minY, minX, maxY, maxX] Some WGS84 implementations may use X,Y order therefore we need to
55
+ * check and swap if needed.
56
+ *
57
+ * TODO: This method works for the common coordinates in and around switzerland but will not
58
+ * work for the whole world. Therefore a better solution should be implemented if we want to
59
+ * support coordinates and extents of the whole world.
60
+ *
61
+ * @param extent - Input extent [minX, minY, maxX, maxY] or [minY, minX, maxY, maxX]
62
+ * @returns Extent guaranteed to be in [minX, minY, maxX, maxY] order
63
+ * @link Problem description https://docs.geotools.org/latest/userguide/library/referencing/order.html
64
+ */
65
+ getExtentInOrderXY(extent: [number, number, number, number]): [number, number, number, number] {
66
+ if (extent[0] > extent[1]) {
67
+ return [extent[1], extent[0], extent[3], extent[2]]
68
+ }
69
+ return extent
70
+ }
71
+
72
+ /**
73
+ * Calculating zoom level by reversing formula from
74
+ * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Resolution_and_Scale :
75
+ *
76
+ * resolution = 156543.03 * cos(latitude) / (2 ^ zoom level)
77
+ *
78
+ * So that
79
+ *
80
+ * zoom level = log2( resolution / 156543.03 / cos(latitude) )
81
+ *
82
+ * @param resolution Resolution in meter/pixel
83
+ * @param center As the use an equatorial constant to calculate the zoom level, we need to know
84
+ * the latitude of the position the resolution must be calculated at, as we need to take into
85
+ * account the deformation of the WebMercator projection (that is greater the further north we
86
+ * are)
87
+ */
88
+ getZoomForResolutionAndCenter(resolution: number, center: SingleCoordinate): number {
89
+ return Math.abs(
90
+ Math.log2(
91
+ resolution /
92
+ PIXEL_LENGTH_IN_KM_AT_ZOOM_ZERO_WITH_256PX_TILES /
93
+ Math.cos((center[1] * Math.PI) / 180.0)
94
+ )
95
+ )
96
+ }
97
+ }