@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.
- package/README.md +118 -0
- package/dist/index.cjs +7 -0
- package/dist/index.d.ts +1 -12
- package/dist/index.js +8012 -18103
- package/dist/ol.cjs +1 -0
- package/dist/ol.d.ts +10 -0
- package/dist/ol.js +4467 -0
- package/dist/registerProj4-BuUOcPpF.cjs +23 -0
- package/dist/registerProj4-CwR_kPOz.js +10172 -0
- package/eslint.config.mts +12 -0
- package/index.html +14 -0
- package/package.json +30 -23
- package/setup-vitest.ts +8 -0
- package/src/DevApp.vue +65 -0
- package/src/__test__/coordinatesUtils.spec.ts +178 -0
- package/src/__test__/extentUtils.spec.ts +92 -0
- package/src/coordinatesUtils.ts +188 -0
- package/src/dev.ts +6 -0
- package/src/extentUtils.ts +196 -0
- package/src/index.ts +29 -0
- package/src/ol.ts +52 -0
- package/src/proj/CoordinateSystem.ts +315 -0
- package/src/proj/CoordinateSystemBounds.ts +170 -0
- package/src/proj/CustomCoordinateSystem.ts +58 -0
- package/src/proj/LV03CoordinateSystem.ts +23 -0
- package/src/proj/LV95CoordinateSystem.ts +35 -0
- package/src/proj/StandardCoordinateSystem.ts +22 -0
- package/src/proj/SwissCoordinateSystem.ts +233 -0
- package/src/proj/WGS84CoordinateSystem.ts +97 -0
- package/src/proj/WebMercatorCoordinateSystem.ts +89 -0
- package/src/proj/__test__/CoordinateSystem.spec.ts +63 -0
- package/src/proj/__test__/CoordinateSystemBounds.spec.ts +252 -0
- package/src/proj/__test__/SwissCoordinateSystem.spec.ts +136 -0
- package/src/proj/index.ts +65 -0
- package/src/proj/types.ts +22 -0
- package/src/registerProj4.ts +38 -0
- package/tsconfig.json +4 -0
- package/vite.config.ts +46 -0
- 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
|
+
}
|