openskidata-format 14.0.0 → 16.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/dist/ElevationProfile.d.ts +1 -0
- package/dist/ElevationProfile.d.ts.map +1 -0
- package/dist/ElevationProfile.js +15 -24
- package/dist/ElevationProfile.js.map +1 -1
- package/dist/FeatureType.d.ts +1 -0
- package/dist/FeatureType.d.ts.map +1 -0
- package/dist/FeatureType.js +2 -5
- package/dist/FeatureType.js.map +1 -1
- package/dist/Lift.d.ts +10 -8
- package/dist/Lift.d.ts.map +1 -0
- package/dist/Lift.js +22 -29
- package/dist/Lift.js.map +1 -1
- package/dist/LiftNameFormatter.d.ts +2 -1
- package/dist/LiftNameFormatter.d.ts.map +1 -0
- package/dist/LiftNameFormatter.js +11 -14
- package/dist/LiftNameFormatter.js.map +1 -1
- package/dist/Place.d.ts +1 -0
- package/dist/Place.d.ts.map +1 -0
- package/dist/Place.js +1 -2
- package/dist/Run.d.ts +10 -9
- package/dist/Run.d.ts.map +1 -0
- package/dist/Run.js +23 -30
- package/dist/Run.js.map +1 -1
- package/dist/RunDifficultyConvention.d.ts +2 -1
- package/dist/RunDifficultyConvention.d.ts.map +1 -0
- package/dist/RunDifficultyConvention.js +10 -15
- package/dist/RunDifficultyConvention.js.map +1 -1
- package/dist/SkiArea.d.ts +11 -12
- package/dist/SkiArea.d.ts.map +1 -0
- package/dist/SkiArea.js +2 -5
- package/dist/SkiArea.js.map +1 -1
- package/dist/SlopeGradingScale.d.ts +3 -2
- package/dist/SlopeGradingScale.d.ts.map +1 -0
- package/dist/SlopeGradingScale.js +21 -26
- package/dist/SlopeGradingScale.js.map +1 -1
- package/dist/SnowCoverHistory.d.ts +1 -0
- package/dist/SnowCoverHistory.d.ts.map +1 -0
- package/dist/SnowCoverHistory.js +9 -8
- package/dist/SnowCoverHistory.js.map +1 -1
- package/dist/Source.d.ts +2 -14
- package/dist/Source.d.ts.map +1 -0
- package/dist/Source.js +7 -43
- package/dist/Source.js.map +1 -1
- package/dist/Spot.d.ts +6 -5
- package/dist/Spot.d.ts.map +1 -0
- package/dist/Spot.js +6 -9
- package/dist/Spot.js.map +1 -1
- package/dist/Status.d.ts +1 -0
- package/dist/Status.d.ts.map +1 -0
- package/dist/Status.js +2 -5
- package/dist/Status.js.map +1 -1
- package/dist/ViewportHint.d.ts +1 -0
- package/dist/ViewportHint.d.ts.map +1 -0
- package/dist/ViewportHint.js +4 -10
- package/dist/ViewportHint.js.map +1 -1
- package/dist/index.d.ts +15 -15
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -18
- package/dist/index.js.map +1 -1
- package/dist/util/exhaustiveMatchingGuard.d.ts +1 -0
- package/dist/util/exhaustiveMatchingGuard.d.ts.map +1 -0
- package/dist/util/exhaustiveMatchingGuard.js +1 -5
- package/dist/util/exhaustiveMatchingGuard.js.map +1 -1
- package/package.json +35 -23
- package/dist/SkiPass.d.ts +0 -82
- package/dist/SkiPass.js +0 -3
- package/dist/SkiPass.js.map +0 -1
- package/dist/testUtils.d.ts +0 -2
- package/dist/testUtils.js +0 -7
- package/dist/testUtils.js.map +0 -1
- package/src/ElevationProfile.test.ts +0 -738
- package/src/ElevationProfile.ts +0 -452
- package/src/FeatureType.ts +0 -6
- package/src/Lift.test.ts +0 -195
- package/src/Lift.ts +0 -219
- package/src/LiftNameFormatter.ts +0 -116
- package/src/Place.ts +0 -11
- package/src/Run.ts +0 -242
- package/src/RunDifficultyConvention.test.ts +0 -79
- package/src/RunDifficultyConvention.ts +0 -335
- package/src/SkiArea.ts +0 -128
- package/src/SkiPass.ts +0 -89
- package/src/SlopeGradingScale.test.ts +0 -151
- package/src/SlopeGradingScale.ts +0 -96
- package/src/SnowCoverHistory.test.ts +0 -103
- package/src/SnowCoverHistory.ts +0 -107
- package/src/Source.test.ts +0 -50
- package/src/Source.ts +0 -72
- package/src/Spot.ts +0 -140
- package/src/Status.ts +0 -20
- package/src/ViewportHint.ts +0 -208
- package/src/index.ts +0 -15
- package/src/testUtils.ts +0 -5
- package/src/util/exhaustiveMatchingGuard.ts +0 -3
package/src/ElevationProfile.ts
DELETED
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
import distance from '@turf/distance'
|
|
2
|
-
import length from '@turf/length'
|
|
3
|
-
import { lineChunk } from '@turf/line-chunk'
|
|
4
|
-
import { LineString } from 'geojson'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Represents an elevation profile with height measurements along a LineString.
|
|
8
|
-
*
|
|
9
|
-
* @property {number[]} heights - Array of height measurements in meters. These heights are sampled at regular intervals along the LineString,
|
|
10
|
-
* except for the final height which corresponds to the LineString endpoint and may have a different spacing.
|
|
11
|
-
* Height values can be mapped to geographical coordinates using Turf.js: `turf.lineChunk(geometry, resolution, {units: 'meters'})`.
|
|
12
|
-
* @property {number} resolution - The horizontal sampling distance in meters between consecutive height measurements.
|
|
13
|
-
* @property {number} targetResolution - The horizontal sampling distance in meters that was requested for the elevation profile. The actual resolution may be less to ensure an integer number of segments along the line. This is used for reconstructing the profile geometry from the feature geometry.
|
|
14
|
-
*/
|
|
15
|
-
export type ElevationProfile = {
|
|
16
|
-
heights: number[]
|
|
17
|
-
resolution: number
|
|
18
|
-
targetResolution: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Elevation data that can be computed on demand fom a Run or Lift feature.
|
|
23
|
-
*/
|
|
24
|
-
export type ElevationData = AscentDescentData &
|
|
25
|
-
PitchData & {
|
|
26
|
-
profileGeometry: GeoJSON.LineString
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type AscentDescentData = {
|
|
30
|
-
ascentInMeters: number
|
|
31
|
-
descentInMeters: number
|
|
32
|
-
minElevationInMeters: number
|
|
33
|
-
maxElevationInMeters: number
|
|
34
|
-
verticalInMeters: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
type PitchData = {
|
|
38
|
-
averagePitchInPercent: number | null
|
|
39
|
-
maxPitchInPercent: number | null
|
|
40
|
-
inclinedLengthInMeters: number
|
|
41
|
-
overallPitchInPercent: number | null
|
|
42
|
-
pitchCalculationResolutionInMeters: number
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function getElevationData(
|
|
46
|
-
profileGeometry: GeoJSON.LineString,
|
|
47
|
-
): ElevationData {
|
|
48
|
-
return {
|
|
49
|
-
...getAscentAndDescent(profileGeometry),
|
|
50
|
-
...getPitchData(profileGeometry),
|
|
51
|
-
profileGeometry,
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function getPitchData(
|
|
56
|
-
profileGeometry: GeoJSON.LineString,
|
|
57
|
-
minResolutionInMeters: number = 25, // Default resolution of 25 meters for pitch calculation
|
|
58
|
-
): PitchData {
|
|
59
|
-
const coordinates = profileGeometry.coordinates
|
|
60
|
-
if (coordinates[0].length < 3) {
|
|
61
|
-
throw 'Elevation data is required for slope analysis'
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// For overall calculations (average pitch, total length, etc.)
|
|
65
|
-
let totalLength = 0
|
|
66
|
-
let totalInclinedLength = 0
|
|
67
|
-
let totalElevationChange = 0
|
|
68
|
-
|
|
69
|
-
// First calculate the values needed for overall statistics
|
|
70
|
-
for (let i = 0; i < coordinates.length - 1; i++) {
|
|
71
|
-
const before = coordinates[i]
|
|
72
|
-
const after = coordinates[i + 1]
|
|
73
|
-
const elevation = after[2] - before[2]
|
|
74
|
-
const geometry: GeoJSON.LineString = {
|
|
75
|
-
type: 'LineString',
|
|
76
|
-
coordinates: [before, after],
|
|
77
|
-
}
|
|
78
|
-
const feature: GeoJSON.Feature = {
|
|
79
|
-
type: 'Feature',
|
|
80
|
-
geometry,
|
|
81
|
-
properties: {},
|
|
82
|
-
}
|
|
83
|
-
const lengthInMeters = length(feature, { units: 'meters' })
|
|
84
|
-
|
|
85
|
-
totalLength += lengthInMeters
|
|
86
|
-
totalInclinedLength += Math.sqrt(
|
|
87
|
-
Math.pow(lengthInMeters, 2) + Math.pow(elevation, 2),
|
|
88
|
-
)
|
|
89
|
-
totalElevationChange += Math.abs(elevation)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// If the line is too short relative to the resolution, pitch calculations are unreliable
|
|
93
|
-
// due to elevation data resolution. A 1m elevation change over 1m distance would be a 45 degree slope,
|
|
94
|
-
// which can easily happen due to elevation data precision issues.
|
|
95
|
-
if (totalLength < minResolutionInMeters / 2) {
|
|
96
|
-
return {
|
|
97
|
-
averagePitchInPercent: null,
|
|
98
|
-
maxPitchInPercent: null,
|
|
99
|
-
inclinedLengthInMeters: totalInclinedLength,
|
|
100
|
-
overallPitchInPercent: null,
|
|
101
|
-
pitchCalculationResolutionInMeters: totalLength,
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Chunk the line into segments using the calculated resolution for max pitch calculation
|
|
106
|
-
const {
|
|
107
|
-
geometry: chunkedGeometries,
|
|
108
|
-
resolutionInMeters: actualResolutionInMeters,
|
|
109
|
-
} = lineChunkPatched(profileGeometry, minResolutionInMeters)
|
|
110
|
-
|
|
111
|
-
// Mutates the geometries in place to add elevation data
|
|
112
|
-
interpolateElevation(chunkedGeometries)
|
|
113
|
-
|
|
114
|
-
// Calculate max pitch over the evenly-divided chunks
|
|
115
|
-
let maxPitchValue = 0
|
|
116
|
-
for (const chunk of chunkedGeometries) {
|
|
117
|
-
const chunkCoords = chunk.coordinates
|
|
118
|
-
|
|
119
|
-
const startPoint = chunkCoords[0]
|
|
120
|
-
const endPoint = chunkCoords[chunkCoords.length - 1]
|
|
121
|
-
|
|
122
|
-
const elevationChange = endPoint[2] - startPoint[2]
|
|
123
|
-
const chunkLengthInMeters = length(
|
|
124
|
-
{ type: 'Feature', geometry: chunk, properties: {} },
|
|
125
|
-
{ units: 'meters' },
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
const chunkPitch = Math.abs(elevationChange / chunkLengthInMeters)
|
|
129
|
-
if (chunkPitch > maxPitchValue) {
|
|
130
|
-
maxPitchValue = chunkPitch
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const averagePitch = totalElevationChange / totalLength
|
|
135
|
-
const overallElevationChange = Math.abs(
|
|
136
|
-
coordinates[coordinates.length - 1][2] - coordinates[0][2],
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
return {
|
|
140
|
-
averagePitchInPercent: averagePitch,
|
|
141
|
-
maxPitchInPercent: maxPitchValue,
|
|
142
|
-
inclinedLengthInMeters: totalInclinedLength,
|
|
143
|
-
overallPitchInPercent: overallElevationChange / totalLength,
|
|
144
|
-
pitchCalculationResolutionInMeters: actualResolutionInMeters,
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function getProfileGeometry(
|
|
149
|
-
geometry: GeoJSON.LineString,
|
|
150
|
-
elevationProfile: ElevationProfile,
|
|
151
|
-
): GeoJSON.LineString {
|
|
152
|
-
const { geometry: profileLine, resolutionInMeters: actualResolution } =
|
|
153
|
-
extractPointsForElevationProfile(
|
|
154
|
-
geometry,
|
|
155
|
-
elevationProfile.targetResolution,
|
|
156
|
-
)
|
|
157
|
-
if (profileLine.coordinates.length !== elevationProfile.heights.length) {
|
|
158
|
-
throw `Mismatch of points & elevation profile`
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
Math.abs(actualResolution - elevationProfile.resolution) >
|
|
163
|
-
elevationProfile.resolution * 0.001
|
|
164
|
-
) {
|
|
165
|
-
throw `Resolution mismatch between profile geometry (${actualResolution}) and elevation profile (${elevationProfile.resolution})`
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
for (let i = 0; i < profileLine.coordinates.length; i++) {
|
|
169
|
-
const point = profileLine.coordinates[i]
|
|
170
|
-
const height = elevationProfile.heights[i]
|
|
171
|
-
point.push(height)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return profileLine
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Determines the points to use for an elevation profile, given a minimum horizontal resolution. The resolution is selected to have evenly spaced points along the line.
|
|
179
|
-
*/
|
|
180
|
-
export function extractPointsForElevationProfile(
|
|
181
|
-
geometry: LineString,
|
|
182
|
-
minResolutionInMeters: number,
|
|
183
|
-
): { resolutionInMeters: number; geometry: GeoJSON.LineString } {
|
|
184
|
-
const { geometry: lineChunks, resolutionInMeters } = lineChunkPatched(
|
|
185
|
-
geometry,
|
|
186
|
-
minResolutionInMeters,
|
|
187
|
-
)
|
|
188
|
-
|
|
189
|
-
const points: GeoJSON.Position[] = []
|
|
190
|
-
for (let subline of lineChunks) {
|
|
191
|
-
const point = subline.coordinates[0]
|
|
192
|
-
points.push([point[0], point[1]])
|
|
193
|
-
}
|
|
194
|
-
if (lineChunks.length > 0) {
|
|
195
|
-
const geometry = lineChunks[lineChunks.length - 1]
|
|
196
|
-
const coords = geometry.coordinates
|
|
197
|
-
if (coords.length > 1) {
|
|
198
|
-
const point = coords[coords.length - 1]
|
|
199
|
-
points.push([point[0], point[1]])
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
resolutionInMeters,
|
|
205
|
-
geometry: {
|
|
206
|
-
type: 'LineString',
|
|
207
|
-
coordinates: points,
|
|
208
|
-
},
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function getAscentAndDescent(
|
|
213
|
-
profileGeometry: GeoJSON.LineString,
|
|
214
|
-
): AscentDescentData {
|
|
215
|
-
const coordinates = profileGeometry.coordinates
|
|
216
|
-
if (coordinates.length === 0) {
|
|
217
|
-
throw 'Empty coordinates are not supported for elevation data analysis'
|
|
218
|
-
}
|
|
219
|
-
if (coordinates[0].length < 3) {
|
|
220
|
-
throw 'Elevation data is required for elevation data analysis'
|
|
221
|
-
}
|
|
222
|
-
const initialElevation = coordinates[0][2]
|
|
223
|
-
const result = coordinates
|
|
224
|
-
.map((point) => point[2])
|
|
225
|
-
.reduce(
|
|
226
|
-
(accumulated, currentElevation) => {
|
|
227
|
-
const ascent = currentElevation - accumulated.lastElevation
|
|
228
|
-
if (ascent > 0) {
|
|
229
|
-
accumulated.ascent += ascent
|
|
230
|
-
} else {
|
|
231
|
-
accumulated.descent -= ascent
|
|
232
|
-
}
|
|
233
|
-
accumulated.lastElevation = currentElevation
|
|
234
|
-
accumulated.minElevation = Math.min(
|
|
235
|
-
currentElevation,
|
|
236
|
-
accumulated.minElevation,
|
|
237
|
-
)
|
|
238
|
-
accumulated.maxElevation = Math.max(
|
|
239
|
-
currentElevation,
|
|
240
|
-
accumulated.maxElevation,
|
|
241
|
-
)
|
|
242
|
-
|
|
243
|
-
return accumulated
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
ascent: 0,
|
|
247
|
-
descent: 0,
|
|
248
|
-
minElevation: initialElevation,
|
|
249
|
-
maxElevation: initialElevation,
|
|
250
|
-
lastElevation: initialElevation,
|
|
251
|
-
},
|
|
252
|
-
)
|
|
253
|
-
|
|
254
|
-
return {
|
|
255
|
-
ascentInMeters: result.ascent,
|
|
256
|
-
descentInMeters: result.descent,
|
|
257
|
-
minElevationInMeters: result.minElevation,
|
|
258
|
-
maxElevationInMeters: result.maxElevation,
|
|
259
|
-
verticalInMeters: result.maxElevation - result.minElevation,
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Turf's lineChunk has several issues:
|
|
265
|
-
* - it doesn't allow for a custom resolution that divides the line evenly, which is important for consistent pitch calculations
|
|
266
|
-
* change: calculate a resolution that divides the line into an integer number of segments, with a maximum of the provided resolution
|
|
267
|
-
* - it includes elevations from the original geometry on newly created points which are wrong (https://github.com/Turfjs/turf/issues/3007)
|
|
268
|
-
* fix: strip Z coordinates from non-original points
|
|
269
|
-
* - it can produce a last point that is very close but not exactly the same as the original endpoint
|
|
270
|
-
* fix: set the last point to be exactly the original endpoint
|
|
271
|
-
* - it can produce two points very close to the end of line due to floating point precision issues
|
|
272
|
-
* fix: drop the second to last point if it's very close to the end
|
|
273
|
-
*
|
|
274
|
-
* @param geometry
|
|
275
|
-
* @param minResolutionInMeters
|
|
276
|
-
* @returns
|
|
277
|
-
*/
|
|
278
|
-
export function lineChunkPatched(
|
|
279
|
-
geometry: LineString,
|
|
280
|
-
minResolutionInMeters: number,
|
|
281
|
-
): { resolutionInMeters: number; geometry: GeoJSON.LineString[] } {
|
|
282
|
-
const resolutionInMeters = horizontalResolutionFitting(
|
|
283
|
-
geometry,
|
|
284
|
-
minResolutionInMeters,
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
const lineChunks = lineChunk(geometry, resolutionInMeters, {
|
|
288
|
-
units: 'meters',
|
|
289
|
-
}).features.map((feature) => feature.geometry)
|
|
290
|
-
|
|
291
|
-
// Check the last segment has the expected length
|
|
292
|
-
const lastSegment = lineChunks[lineChunks.length - 1]
|
|
293
|
-
const lastSegmentLength = length(
|
|
294
|
-
{ type: 'Feature', geometry: lastSegment, properties: {} },
|
|
295
|
-
{ units: 'meters' },
|
|
296
|
-
)
|
|
297
|
-
if (lastSegmentLength < resolutionInMeters * 0.01) {
|
|
298
|
-
// If the last segment is very short, which can happen due to floating point precision issues, drop it to avoid issues with elevation interpolation
|
|
299
|
-
lineChunks.pop()
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Set the last point from the original coordinates, turf line chunk can sometimes produce a last point that
|
|
303
|
-
// is very close but not exactly the same as the original endpoint, which can cause issues with elevation interpolation
|
|
304
|
-
const lastOriginalPoint =
|
|
305
|
-
geometry.coordinates[geometry.coordinates.length - 1]
|
|
306
|
-
const lastChunkCoords = lineChunks[lineChunks.length - 1].coordinates
|
|
307
|
-
lastChunkCoords[lastChunkCoords.length - 1] = lastOriginalPoint
|
|
308
|
-
|
|
309
|
-
// turf 7.3+ adds Z coordinates to newly created points using segment-end elevation,
|
|
310
|
-
// but we need distance-based interpolation. Strip Z from non-original vertices so
|
|
311
|
-
// interpolateElevation can add them correctly.
|
|
312
|
-
// https://github.com/Turfjs/turf/issues/3007
|
|
313
|
-
stripNonOriginalElevations(lineChunks, geometry.coordinates)
|
|
314
|
-
|
|
315
|
-
return { resolutionInMeters, geometry: lineChunks }
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function horizontalResolutionFitting(
|
|
319
|
-
geometry: LineString,
|
|
320
|
-
minResolution: number,
|
|
321
|
-
): number {
|
|
322
|
-
const totalLength = length(
|
|
323
|
-
{ type: 'Feature', geometry, properties: {} },
|
|
324
|
-
{ units: 'meters' },
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
if (totalLength === 0) {
|
|
328
|
-
return 0
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const numSegments = Math.ceil(totalLength / minResolution)
|
|
332
|
-
return totalLength / numSegments
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Strip Z coordinates from points that were created by lineChunk (not original vertices).
|
|
337
|
-
* Original vertices are identified by matching lon/lat coordinates.
|
|
338
|
-
* Mutates the geometries in place.
|
|
339
|
-
*/
|
|
340
|
-
function stripNonOriginalElevations(
|
|
341
|
-
geometries: GeoJSON.LineString[],
|
|
342
|
-
originalCoordinates: GeoJSON.Position[],
|
|
343
|
-
) {
|
|
344
|
-
const originalSet = new Set(originalCoordinates.map((c) => `${c[0]},${c[1]}`))
|
|
345
|
-
|
|
346
|
-
for (const geometry of geometries) {
|
|
347
|
-
for (const point of geometry.coordinates) {
|
|
348
|
-
const key = `${point[0]},${point[1]}`
|
|
349
|
-
if (!originalSet.has(key) && point.length >= 3) {
|
|
350
|
-
point.length = 2
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Interpolate elevation for points along a list of linestrings that don't already have elevation.
|
|
358
|
-
* Uses geographic distances for proper interpolation.
|
|
359
|
-
* Mutates the geometries in place.
|
|
360
|
-
*
|
|
361
|
-
* @throws Error if no points have elevation data or if segments can't be properly formed
|
|
362
|
-
*/
|
|
363
|
-
function interpolateElevation(geometries: GeoJSON.LineString[]) {
|
|
364
|
-
if (geometries.length === 0) {
|
|
365
|
-
return
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Flatten all coordinates into a single array for easier processing
|
|
369
|
-
const allPoints: {
|
|
370
|
-
point: GeoJSON.Position
|
|
371
|
-
hasElevation: boolean
|
|
372
|
-
index: number
|
|
373
|
-
distanceFromStart: number // Distance from the beginning of the line
|
|
374
|
-
}[] = []
|
|
375
|
-
|
|
376
|
-
// Collect all points while tracking which ones have elevation
|
|
377
|
-
let pointIndex = 0
|
|
378
|
-
let totalDistance = 0
|
|
379
|
-
let previousPoint: GeoJSON.Position | null = null
|
|
380
|
-
|
|
381
|
-
for (const geometry of geometries) {
|
|
382
|
-
for (const point of geometry.coordinates) {
|
|
383
|
-
// Calculate distance from previous point
|
|
384
|
-
if (previousPoint) {
|
|
385
|
-
const segmentLength = distance(previousPoint, point, {
|
|
386
|
-
units: 'meters',
|
|
387
|
-
})
|
|
388
|
-
totalDistance += segmentLength
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
const hasElevation = point.length >= 3
|
|
392
|
-
allPoints.push({
|
|
393
|
-
point,
|
|
394
|
-
hasElevation,
|
|
395
|
-
index: pointIndex++,
|
|
396
|
-
distanceFromStart: totalDistance,
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
previousPoint = point
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// Find elevation reference points
|
|
404
|
-
const referencePoints = allPoints.filter((p) => p.hasElevation)
|
|
405
|
-
|
|
406
|
-
if (referencePoints.length <= 1) {
|
|
407
|
-
throw `At least two points with elevation data are required ${JSON.stringify(allPoints)}`
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Process segments between each pair of reference points
|
|
411
|
-
for (let i = 0; i < referencePoints.length - 1; i++) {
|
|
412
|
-
const startPoint = referencePoints[i]
|
|
413
|
-
const endPoint = referencePoints[i + 1]
|
|
414
|
-
|
|
415
|
-
// Points must be in sequence
|
|
416
|
-
if (endPoint.index <= startPoint.index) {
|
|
417
|
-
throw 'Reference points must be in sequence'
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const startElevation = startPoint.point[2]
|
|
421
|
-
const endElevation = endPoint.point[2]
|
|
422
|
-
const elevationDelta = endElevation - startElevation
|
|
423
|
-
|
|
424
|
-
// Calculate the geographic distance between reference points
|
|
425
|
-
const distanceBetweenRefs =
|
|
426
|
-
endPoint.distanceFromStart - startPoint.distanceFromStart
|
|
427
|
-
|
|
428
|
-
// Interpolate elevations for points between references
|
|
429
|
-
for (let j = startPoint.index + 1; j < endPoint.index; j++) {
|
|
430
|
-
const pointData = allPoints[j]
|
|
431
|
-
|
|
432
|
-
// Only interpolate if point doesn't already have elevation
|
|
433
|
-
if (pointData.hasElevation) {
|
|
434
|
-
throw "Can't interpolate elevation for points that already have it"
|
|
435
|
-
}
|
|
436
|
-
// Calculate how far along this point is between the reference points
|
|
437
|
-
const distanceFromStart =
|
|
438
|
-
pointData.distanceFromStart - startPoint.distanceFromStart
|
|
439
|
-
|
|
440
|
-
// Its possible both references are the same point, in which case we use the ref elevation
|
|
441
|
-
let interpolatedElevation = startElevation
|
|
442
|
-
if (distanceBetweenRefs > 0) {
|
|
443
|
-
const interpolationFactor = distanceFromStart / distanceBetweenRefs
|
|
444
|
-
interpolatedElevation =
|
|
445
|
-
startElevation + elevationDelta * interpolationFactor
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Add elevation to the existing point array (using reference semantics)
|
|
449
|
-
pointData.point.push(interpolatedElevation)
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
package/src/FeatureType.ts
DELETED
package/src/Lift.test.ts
DELETED
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
import { FeatureType } from './FeatureType';
|
|
2
|
-
import { getLiftElevationData, LiftFeature, LiftType, getFormattedLiftType, getLiftColor } from './Lift';
|
|
3
|
-
import { mockViewportHint } from './testUtils';
|
|
4
|
-
import { LiftStationPosition, LiftStationSpotFeature, SpotType } from './Spot';
|
|
5
|
-
import { Status } from './Status';
|
|
6
|
-
|
|
7
|
-
function makeLiftFeature(overrides: {
|
|
8
|
-
coordinates?: GeoJSON.Position[];
|
|
9
|
-
duration?: number | null;
|
|
10
|
-
stations?: LiftStationSpotFeature[];
|
|
11
|
-
}): LiftFeature {
|
|
12
|
-
return {
|
|
13
|
-
type: 'Feature',
|
|
14
|
-
geometry: {
|
|
15
|
-
type: 'LineString',
|
|
16
|
-
coordinates: overrides.coordinates ?? [
|
|
17
|
-
[8.0, 46.0, 1000],
|
|
18
|
-
[8.01, 46.05, 1500],
|
|
19
|
-
],
|
|
20
|
-
},
|
|
21
|
-
properties: {
|
|
22
|
-
type: FeatureType.Lift,
|
|
23
|
-
id: 'test',
|
|
24
|
-
liftType: LiftType.ChairLift,
|
|
25
|
-
status: Status.Operating,
|
|
26
|
-
access: null,
|
|
27
|
-
name: null,
|
|
28
|
-
ref: null,
|
|
29
|
-
refFRCAIRN: null,
|
|
30
|
-
description: null,
|
|
31
|
-
oneway: null,
|
|
32
|
-
occupancy: null,
|
|
33
|
-
capacity: null,
|
|
34
|
-
duration: overrides.duration !== undefined ? overrides.duration : 600,
|
|
35
|
-
detachable: null,
|
|
36
|
-
bubble: null,
|
|
37
|
-
heating: null,
|
|
38
|
-
tunnel: null,
|
|
39
|
-
stations: overrides.stations ?? [],
|
|
40
|
-
skiAreas: [],
|
|
41
|
-
sources: [],
|
|
42
|
-
websites: [],
|
|
43
|
-
wikidataID: null,
|
|
44
|
-
places: [],
|
|
45
|
-
viewportHint: mockViewportHint(),
|
|
46
|
-
},
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function makeStation(
|
|
51
|
-
position: LiftStationPosition,
|
|
52
|
-
coordinates: GeoJSON.Position,
|
|
53
|
-
): LiftStationSpotFeature {
|
|
54
|
-
return {
|
|
55
|
-
type: 'Feature',
|
|
56
|
-
geometry: { type: 'Point', coordinates },
|
|
57
|
-
properties: {
|
|
58
|
-
type: FeatureType.Spot,
|
|
59
|
-
id: `station-${position}`,
|
|
60
|
-
spotType: SpotType.LiftStation,
|
|
61
|
-
name: null,
|
|
62
|
-
position,
|
|
63
|
-
entry: null,
|
|
64
|
-
exit: null,
|
|
65
|
-
liftId: 'test',
|
|
66
|
-
skiAreas: [],
|
|
67
|
-
sources: [],
|
|
68
|
-
places: [],
|
|
69
|
-
viewportHint: mockViewportHint(),
|
|
70
|
-
},
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
describe('Lift', () => {
|
|
75
|
-
describe('getLiftElevationData', () => {
|
|
76
|
-
it('returns null for non-LineString geometry', () => {
|
|
77
|
-
const feature: LiftFeature = {
|
|
78
|
-
...makeLiftFeature({}),
|
|
79
|
-
geometry: { type: 'MultiLineString', coordinates: [] },
|
|
80
|
-
}
|
|
81
|
-
expect(getLiftElevationData(feature)).toBeNull()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('returns null when coordinates have no elevation', () => {
|
|
85
|
-
const feature = makeLiftFeature({
|
|
86
|
-
coordinates: [[8.0, 46.0], [8.01, 46.05]],
|
|
87
|
-
})
|
|
88
|
-
expect(getLiftElevationData(feature)).toBeNull()
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('returns null speed when duration is null', () => {
|
|
92
|
-
const result = getLiftElevationData(makeLiftFeature({ duration: null }))
|
|
93
|
-
expect(result?.speedInMetersPerSecond).toBeNull()
|
|
94
|
-
expect(result?.verticalSpeedInMetersPerSecond).toBeNull()
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('uses full geometry length when no stations', () => {
|
|
98
|
-
const feature = makeLiftFeature({ stations: [] })
|
|
99
|
-
const result = getLiftElevationData(feature)!
|
|
100
|
-
expect(result.speedInMetersPerSecond).toBeGreaterThan(0)
|
|
101
|
-
// Speed should be inclinedLength / duration from geometry
|
|
102
|
-
const expectedSpeed = result.inclinedLengthInMeters / 600
|
|
103
|
-
expect(result.speedInMetersPerSecond).toBeCloseTo(expectedSpeed)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('uses full geometry length when only one terminal station is present', () => {
|
|
107
|
-
const noStations = makeLiftFeature({ stations: [] })
|
|
108
|
-
const oneStation = makeLiftFeature({
|
|
109
|
-
stations: [makeStation(LiftStationPosition.Bottom, [8.0, 46.0, 1000])],
|
|
110
|
-
})
|
|
111
|
-
const resultNoStations = getLiftElevationData(noStations)!
|
|
112
|
-
const resultOneStation = getLiftElevationData(oneStation)!
|
|
113
|
-
expect(resultOneStation.speedInMetersPerSecond).toBeCloseTo(
|
|
114
|
-
resultNoStations.speedInMetersPerSecond!,
|
|
115
|
-
)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('uses station-based distance when top and bottom stations have elevation', () => {
|
|
119
|
-
// Geometry extends beyond the stations (extra approach at each end)
|
|
120
|
-
const feature = makeLiftFeature({
|
|
121
|
-
coordinates: [
|
|
122
|
-
[8.0, 46.0, 900], // approach before bottom station
|
|
123
|
-
[8.005, 46.01, 1000], // bottom station location
|
|
124
|
-
[8.015, 46.04, 1500], // top station location
|
|
125
|
-
[8.02, 46.05, 1600], // approach after top station
|
|
126
|
-
],
|
|
127
|
-
duration: 600,
|
|
128
|
-
stations: [
|
|
129
|
-
makeStation(LiftStationPosition.Bottom, [8.005, 46.01, 1000]),
|
|
130
|
-
makeStation(LiftStationPosition.Top, [8.015, 46.04, 1500]),
|
|
131
|
-
],
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
const resultWithStations = getLiftElevationData(feature)!
|
|
135
|
-
|
|
136
|
-
// Without stations, use full geometry (which is longer)
|
|
137
|
-
const featureNoStations = makeLiftFeature({
|
|
138
|
-
coordinates: [
|
|
139
|
-
[8.0, 46.0, 900],
|
|
140
|
-
[8.005, 46.01, 1000],
|
|
141
|
-
[8.015, 46.04, 1500],
|
|
142
|
-
[8.02, 46.05, 1600],
|
|
143
|
-
],
|
|
144
|
-
duration: 600,
|
|
145
|
-
stations: [],
|
|
146
|
-
})
|
|
147
|
-
const resultNoStations = getLiftElevationData(featureNoStations)!
|
|
148
|
-
|
|
149
|
-
// Station-based distance is shorter → lower speed
|
|
150
|
-
expect(resultWithStations.speedInMetersPerSecond).toBeLessThan(
|
|
151
|
-
resultNoStations.speedInMetersPerSecond!,
|
|
152
|
-
)
|
|
153
|
-
expect(resultWithStations.verticalSpeedInMetersPerSecond).toBeLessThan(
|
|
154
|
-
resultNoStations.verticalSpeedInMetersPerSecond!,
|
|
155
|
-
)
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it('falls back to geometry when stations have no elevation', () => {
|
|
159
|
-
const featureWithStations = makeLiftFeature({
|
|
160
|
-
stations: [
|
|
161
|
-
makeStation(LiftStationPosition.Bottom, [8.0, 46.0]), // no Z
|
|
162
|
-
makeStation(LiftStationPosition.Top, [8.01, 46.05]), // no Z
|
|
163
|
-
],
|
|
164
|
-
})
|
|
165
|
-
const featureNoStations = makeLiftFeature({ stations: [] })
|
|
166
|
-
|
|
167
|
-
const resultWithStations = getLiftElevationData(featureWithStations)!
|
|
168
|
-
const resultNoStations = getLiftElevationData(featureNoStations)!
|
|
169
|
-
|
|
170
|
-
expect(resultWithStations.speedInMetersPerSecond).toBeCloseTo(
|
|
171
|
-
resultNoStations.speedInMetersPerSecond!,
|
|
172
|
-
)
|
|
173
|
-
})
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
describe('getFormattedLiftType', () => {
|
|
177
|
-
it('should format lift types correctly', () => {
|
|
178
|
-
expect(getFormattedLiftType(LiftType.ChairLift)).toBe('Chairlift');
|
|
179
|
-
expect(getFormattedLiftType(LiftType.Gondola)).toBe('Gondola');
|
|
180
|
-
expect(getFormattedLiftType(LiftType.MagicCarpet)).toBe('Magic Carpet');
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('getLiftColor', () => {
|
|
185
|
-
it('should return dim red for disused lifts', () => {
|
|
186
|
-
expect(getLiftColor(Status.Disused)).toBe('hsl(0, 53%, 42%)');
|
|
187
|
-
expect(getLiftColor(Status.Abandoned)).toBe('hsl(0, 53%, 42%)');
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('should return bright red for operating lifts', () => {
|
|
191
|
-
expect(getLiftColor(Status.Operating)).toBe('hsl(0, 82%, 42%)');
|
|
192
|
-
expect(getLiftColor(Status.Construction)).toBe('hsl(0, 82%, 42%)');
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
});
|