openskidata-format 3.2.0 → 5.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 +4 -4
- package/dist/ElevationProfile.js +156 -34
- package/dist/ElevationProfile.js.map +1 -1
- package/dist/Lift.d.ts +1 -1
- package/dist/Lift.js.map +1 -1
- package/dist/SkiArea.d.ts +2 -2
- package/package.json +2 -1
- package/src/ElevationProfile.test.ts +143 -9
- package/src/ElevationProfile.ts +208 -40
- package/src/Lift.ts +1 -7
- package/src/SkiArea.ts +2 -2
|
@@ -25,13 +25,13 @@ type AscentDescentData = {
|
|
|
25
25
|
verticalInMeters: number;
|
|
26
26
|
};
|
|
27
27
|
type PitchData = {
|
|
28
|
-
averagePitchInPercent: number;
|
|
29
|
-
maxPitchInPercent: number;
|
|
28
|
+
averagePitchInPercent: number | null;
|
|
29
|
+
maxPitchInPercent: number | null;
|
|
30
30
|
inclinedLengthInMeters: number;
|
|
31
|
-
overallPitchInPercent: number;
|
|
31
|
+
overallPitchInPercent: number | null;
|
|
32
32
|
};
|
|
33
33
|
export declare function getElevationData(profileGeometry: GeoJSON.LineString): ElevationData;
|
|
34
|
-
export declare function getPitchData(profileGeometry: GeoJSON.LineString): PitchData;
|
|
34
|
+
export declare function getPitchData(profileGeometry: GeoJSON.LineString, resolutionInMeters?: number): PitchData;
|
|
35
35
|
export declare function getProfileGeometry(geometry: GeoJSON.LineString, elevationProfile: ElevationProfile): GeoJSON.LineString;
|
|
36
36
|
/**
|
|
37
37
|
* Determines the points to use for an elevation profile.
|
package/dist/ElevationProfile.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.getProfileGeometry = getProfileGeometry;
|
|
|
6
6
|
exports.extractPointsForElevationProfile = extractPointsForElevationProfile;
|
|
7
7
|
exports.getAscentAndDescent = getAscentAndDescent;
|
|
8
8
|
const tslib_1 = require("tslib");
|
|
9
|
+
const distance_1 = tslib_1.__importDefault(require("@turf/distance"));
|
|
9
10
|
const length_1 = tslib_1.__importDefault(require("@turf/length"));
|
|
10
11
|
const line_chunk_1 = require("@turf/line-chunk");
|
|
11
12
|
function getElevationData(profileGeometry) {
|
|
@@ -15,16 +16,16 @@ function getElevationData(profileGeometry) {
|
|
|
15
16
|
profileGeometry,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
|
-
function getPitchData(profileGeometry) {
|
|
19
|
+
function getPitchData(profileGeometry, resolutionInMeters = 25) {
|
|
19
20
|
const coordinates = profileGeometry.coordinates;
|
|
20
21
|
if (coordinates[0].length < 3) {
|
|
21
22
|
throw 'Elevation data is required for slope analysis';
|
|
22
23
|
}
|
|
24
|
+
// For overall calculations (average pitch, total length, etc.)
|
|
23
25
|
let totalLength = 0;
|
|
24
26
|
let totalInclinedLength = 0;
|
|
25
27
|
let totalElevationChange = 0;
|
|
26
|
-
|
|
27
|
-
let maxDownhillPitch = Number.MAX_VALUE;
|
|
28
|
+
// First calculate the values needed for overall statistics
|
|
28
29
|
for (let i = 0; i < coordinates.length - 1; i++) {
|
|
29
30
|
const before = coordinates[i];
|
|
30
31
|
const after = coordinates[i + 1];
|
|
@@ -39,61 +40,102 @@ function getPitchData(profileGeometry) {
|
|
|
39
40
|
properties: {},
|
|
40
41
|
};
|
|
41
42
|
const lengthInMeters = (0, length_1.default)(feature, { units: 'meters' });
|
|
42
|
-
const percentSlope = elevation / lengthInMeters;
|
|
43
|
-
maxUphillPitch = Math.max(maxUphillPitch, percentSlope);
|
|
44
|
-
maxDownhillPitch = Math.min(maxDownhillPitch, percentSlope);
|
|
45
43
|
totalLength += lengthInMeters;
|
|
46
44
|
totalInclinedLength += Math.sqrt(Math.pow(lengthInMeters, 2) + Math.pow(elevation, 2));
|
|
47
45
|
totalElevationChange += Math.abs(elevation);
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
:
|
|
47
|
+
// Chunk the line into fixed-length segments for max pitch calculation
|
|
48
|
+
const chunkedGeometries = (0, line_chunk_1.lineChunk)(profileGeometry, resolutionInMeters, {
|
|
49
|
+
units: 'meters',
|
|
50
|
+
}).features.map((feature) => feature.geometry);
|
|
51
|
+
// Mutates the geometries in place to add elevation data
|
|
52
|
+
interpolateElevation(chunkedGeometries);
|
|
53
|
+
// Initialize max pitch values
|
|
54
|
+
let maxPitchValue = null;
|
|
55
|
+
// Calculate max pitch over the fixed-length chunks
|
|
56
|
+
for (const chunk of chunkedGeometries) {
|
|
57
|
+
const chunkCoords = chunk.coordinates;
|
|
58
|
+
const startPoint = chunkCoords[0];
|
|
59
|
+
const endPoint = chunkCoords[chunkCoords.length - 1];
|
|
60
|
+
const elevationChange = endPoint[2] - startPoint[2];
|
|
61
|
+
const chunkLengthInMeters = (0, length_1.default)({ type: 'Feature', geometry: chunk, properties: {} }, { units: 'meters' });
|
|
62
|
+
// Skip chunks that are too close together to calculate pitch reliably (can happen for the last chunk)
|
|
63
|
+
if (chunkLengthInMeters < resolutionInMeters / 2)
|
|
64
|
+
continue;
|
|
65
|
+
const chunkPitch = Math.abs(elevationChange / chunkLengthInMeters);
|
|
66
|
+
if (maxPitchValue === null || chunkPitch > maxPitchValue) {
|
|
67
|
+
maxPitchValue = chunkPitch;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
52
70
|
const averagePitch = totalElevationChange / totalLength;
|
|
53
71
|
const overallElevationChange = Math.abs(coordinates[coordinates.length - 1][2] - coordinates[0][2]);
|
|
72
|
+
// null maxPitchValue indicates the line is shorter than half the resolution, in which case the accuracy of pitch calculations
|
|
73
|
+
// is questionable as a 1m change in elevation over
|
|
74
|
+
// 1m of distance would be a 45 degree slope, this can happen due to resolution of the elevation data.
|
|
75
|
+
if (maxPitchValue === null) {
|
|
76
|
+
return {
|
|
77
|
+
averagePitchInPercent: null,
|
|
78
|
+
maxPitchInPercent: null,
|
|
79
|
+
inclinedLengthInMeters: totalInclinedLength,
|
|
80
|
+
overallPitchInPercent: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
54
83
|
return {
|
|
55
84
|
averagePitchInPercent: averagePitch,
|
|
56
|
-
maxPitchInPercent:
|
|
85
|
+
maxPitchInPercent: maxPitchValue,
|
|
57
86
|
inclinedLengthInMeters: totalInclinedLength,
|
|
58
87
|
overallPitchInPercent: overallElevationChange / totalLength,
|
|
59
88
|
};
|
|
60
89
|
}
|
|
61
90
|
function getProfileGeometry(geometry, elevationProfile) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
const geometries = lineChunksForElevationProfile(geometry, elevationProfile.resolution);
|
|
92
|
+
var index = 0;
|
|
93
|
+
for (let subline of geometries) {
|
|
94
|
+
const firstPoint = subline.coordinates[0];
|
|
95
|
+
if (firstPoint.length === 2) {
|
|
96
|
+
firstPoint.push(elevationProfile.heights[index]);
|
|
97
|
+
}
|
|
98
|
+
index++;
|
|
99
|
+
if (index === elevationProfile.heights.length) {
|
|
100
|
+
throw 'Mismatch of points & elevation profile.';
|
|
101
|
+
}
|
|
102
|
+
const lastPoint = subline.coordinates[subline.coordinates.length - 1];
|
|
103
|
+
if (lastPoint.length === 2) {
|
|
104
|
+
lastPoint.push(elevationProfile.heights[index]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (index !== elevationProfile.heights.length - 1) {
|
|
108
|
+
throw `Mismatch of points & elevation profile`;
|
|
109
|
+
}
|
|
110
|
+
// Check all coordinates have a height
|
|
111
|
+
for (let subline of geometries) {
|
|
112
|
+
for (let point of subline.coordinates) {
|
|
113
|
+
if (point.length < 3) {
|
|
114
|
+
throw 'All points should have an elevation at this point.';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
66
117
|
}
|
|
67
118
|
return {
|
|
68
119
|
type: 'LineString',
|
|
69
|
-
coordinates:
|
|
70
|
-
return [point[0], point[1], heights[index]];
|
|
71
|
-
}),
|
|
120
|
+
coordinates: geometries.flatMap((geometry) => geometry.coordinates),
|
|
72
121
|
};
|
|
73
122
|
}
|
|
74
123
|
/**
|
|
75
124
|
* Determines the points to use for an elevation profile.
|
|
76
125
|
*/
|
|
77
126
|
function extractPointsForElevationProfile(geometry, resolution) {
|
|
78
|
-
const
|
|
79
|
-
units: 'meters',
|
|
80
|
-
}).features;
|
|
127
|
+
const geometries = lineChunksForElevationProfile(geometry, resolution);
|
|
81
128
|
const points = [];
|
|
82
|
-
for (let subline of
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const point = geometry.coordinates[0];
|
|
86
|
-
points.push([point[0], point[1]]);
|
|
87
|
-
}
|
|
129
|
+
for (let subline of geometries) {
|
|
130
|
+
const point = subline.coordinates[0];
|
|
131
|
+
points.push([point[0], point[1]]);
|
|
88
132
|
}
|
|
89
|
-
if (
|
|
90
|
-
const geometry =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
points.push([point[0], point[1]]);
|
|
96
|
-
}
|
|
133
|
+
if (geometries.length > 0) {
|
|
134
|
+
const geometry = geometries[geometries.length - 1];
|
|
135
|
+
const coords = geometry.coordinates;
|
|
136
|
+
if (coords.length > 1) {
|
|
137
|
+
const point = coords[coords.length - 1];
|
|
138
|
+
points.push([point[0], point[1]]);
|
|
97
139
|
}
|
|
98
140
|
}
|
|
99
141
|
return {
|
|
@@ -101,6 +143,11 @@ function extractPointsForElevationProfile(geometry, resolution) {
|
|
|
101
143
|
coordinates: points,
|
|
102
144
|
};
|
|
103
145
|
}
|
|
146
|
+
function lineChunksForElevationProfile(geometry, resolution) {
|
|
147
|
+
return (0, line_chunk_1.lineChunk)(geometry, resolution, {
|
|
148
|
+
units: 'meters',
|
|
149
|
+
}).features.map((feature) => feature.geometry);
|
|
150
|
+
}
|
|
104
151
|
function getAscentAndDescent(profileGeometry) {
|
|
105
152
|
const coordinates = profileGeometry.coordinates;
|
|
106
153
|
if (coordinates.length === 0) {
|
|
@@ -139,4 +186,79 @@ function getAscentAndDescent(profileGeometry) {
|
|
|
139
186
|
verticalInMeters: result.maxElevation - result.minElevation,
|
|
140
187
|
};
|
|
141
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Interpolate elevation for points along a list of linestrings that don't already have elevation.
|
|
191
|
+
* Uses geographic distances for proper interpolation.
|
|
192
|
+
* Mutates the geometries in place.
|
|
193
|
+
*
|
|
194
|
+
* @throws Error if no points have elevation data or if segments can't be properly formed
|
|
195
|
+
*/
|
|
196
|
+
function interpolateElevation(geometries) {
|
|
197
|
+
if (geometries.length === 0) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// Flatten all coordinates into a single array for easier processing
|
|
201
|
+
const allPoints = [];
|
|
202
|
+
// Collect all points while tracking which ones have elevation
|
|
203
|
+
let pointIndex = 0;
|
|
204
|
+
let totalDistance = 0;
|
|
205
|
+
let previousPoint = null;
|
|
206
|
+
for (const geometry of geometries) {
|
|
207
|
+
for (const point of geometry.coordinates) {
|
|
208
|
+
// Calculate distance from previous point
|
|
209
|
+
if (previousPoint) {
|
|
210
|
+
const segmentLength = (0, distance_1.default)(previousPoint, point, {
|
|
211
|
+
units: 'meters',
|
|
212
|
+
});
|
|
213
|
+
totalDistance += segmentLength;
|
|
214
|
+
}
|
|
215
|
+
const hasElevation = point.length >= 3;
|
|
216
|
+
allPoints.push({
|
|
217
|
+
point,
|
|
218
|
+
hasElevation,
|
|
219
|
+
index: pointIndex++,
|
|
220
|
+
distanceFromStart: totalDistance,
|
|
221
|
+
});
|
|
222
|
+
previousPoint = point;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Find elevation reference points
|
|
226
|
+
const referencePoints = allPoints.filter((p) => p.hasElevation);
|
|
227
|
+
if (referencePoints.length <= 1) {
|
|
228
|
+
throw 'At least two points with elevation data are required';
|
|
229
|
+
}
|
|
230
|
+
// Process segments between each pair of reference points
|
|
231
|
+
for (let i = 0; i < referencePoints.length - 1; i++) {
|
|
232
|
+
const startPoint = referencePoints[i];
|
|
233
|
+
const endPoint = referencePoints[i + 1];
|
|
234
|
+
// Points must be in sequence
|
|
235
|
+
if (endPoint.index <= startPoint.index) {
|
|
236
|
+
throw 'Reference points must be in sequence';
|
|
237
|
+
}
|
|
238
|
+
const startElevation = startPoint.point[2];
|
|
239
|
+
const endElevation = endPoint.point[2];
|
|
240
|
+
const elevationDelta = endElevation - startElevation;
|
|
241
|
+
// Calculate the geographic distance between reference points
|
|
242
|
+
const distanceBetweenRefs = endPoint.distanceFromStart - startPoint.distanceFromStart;
|
|
243
|
+
// Interpolate elevations for points between references
|
|
244
|
+
for (let j = startPoint.index + 1; j < endPoint.index; j++) {
|
|
245
|
+
const pointData = allPoints[j];
|
|
246
|
+
// Only interpolate if point doesn't already have elevation
|
|
247
|
+
if (pointData.hasElevation) {
|
|
248
|
+
throw "Can't interpolate elevation for points that already have it";
|
|
249
|
+
}
|
|
250
|
+
// Calculate how far along this point is between the reference points
|
|
251
|
+
const distanceFromStart = pointData.distanceFromStart - startPoint.distanceFromStart;
|
|
252
|
+
// Its possible both references are the same point, in which case we use the ref elevation
|
|
253
|
+
let interpolatedElevation = startElevation;
|
|
254
|
+
if (distanceBetweenRefs > 0) {
|
|
255
|
+
const interpolationFactor = distanceFromStart / distanceBetweenRefs;
|
|
256
|
+
interpolatedElevation =
|
|
257
|
+
startElevation + elevationDelta * interpolationFactor;
|
|
258
|
+
}
|
|
259
|
+
// Add elevation to the existing point array (using reference semantics)
|
|
260
|
+
pointData.point.push(interpolatedElevation);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
142
264
|
//# sourceMappingURL=ElevationProfile.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ElevationProfile.js","sourceRoot":"","sources":["../src/ElevationProfile.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"ElevationProfile.js","sourceRoot":"","sources":["../src/ElevationProfile.ts"],"names":[],"mappings":"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"}
|
package/dist/Lift.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { SkiAreaSummaryFeature } from './SkiArea';
|
|
|
4
4
|
import { Source } from './Source';
|
|
5
5
|
import { Status } from './Status';
|
|
6
6
|
export type LiftFeature = GeoJSON.Feature<LiftGeometry, LiftProperties>;
|
|
7
|
-
export type LiftGeometry = GeoJSON.
|
|
7
|
+
export type LiftGeometry = GeoJSON.LineString | GeoJSON.MultiLineString;
|
|
8
8
|
/**
|
|
9
9
|
* A feature representing a ski lift.
|
|
10
10
|
*
|
package/dist/Lift.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Lift.js","sourceRoot":"","sources":["../src/Lift.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"Lift.js","sourceRoot":"","sources":["../src/Lift.ts"],"names":[],"mappings":"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"}
|
package/dist/SkiArea.d.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { Source } from './Source';
|
|
|
6
6
|
import { Status } from './Status';
|
|
7
7
|
/**
|
|
8
8
|
* A ski area feature is derived from several sources:
|
|
9
|
-
* - OpenStreetMap site=piste relations
|
|
10
|
-
* - OpenStreetMap landuse=winter_sports areas
|
|
9
|
+
* - OpenStreetMap site=piste relations (must contain at least one lift or run of any kind)
|
|
10
|
+
* - OpenStreetMap landuse=winter_sports areas (must contain at least one downhill/nordic (non-backcountry) run or operational lift)
|
|
11
11
|
* - Skimap.org ski areas
|
|
12
12
|
*
|
|
13
13
|
* The processor attempts to merge the data of all sources to create a single ski area feature.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openskidata-format",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Data format for OpenSkiMap.org",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"typescript": "^5"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
+
"@turf/distance": "^7.2.0",
|
|
36
37
|
"@turf/length": "^7.2.0",
|
|
37
38
|
"@turf/line-chunk": "^7.2.0",
|
|
38
39
|
"@turf/meta": "^7.2.0",
|
|
@@ -74,23 +74,28 @@ describe('ElevationProfile', () => {
|
|
|
74
74
|
|
|
75
75
|
const result = getPitchData(profileGeometry)
|
|
76
76
|
|
|
77
|
-
expect(result.maxPitchInPercent).toBeCloseTo(0.
|
|
77
|
+
expect(result.maxPitchInPercent).toBeCloseTo(0.377)
|
|
78
78
|
expect(result.averagePitchInPercent).toBeCloseTo(0.2482)
|
|
79
79
|
expect(result.inclinedLengthInMeters).toBeCloseTo(63.0597)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
it('
|
|
82
|
+
it('calculates pitch correctly on a profile with problematic short segments', () => {
|
|
83
|
+
// Create a geometry with artificially short segments and unrealistic elevation changes
|
|
83
84
|
const profileGeometry: GeoJSON.LineString = {
|
|
84
85
|
type: 'LineString',
|
|
85
86
|
coordinates: [
|
|
86
|
-
[0, 0],
|
|
87
|
-
[
|
|
87
|
+
[0, 0, 100],
|
|
88
|
+
[0, 0.00000001, 101], // Very short distance with 1m elevation gain (unrealistically steep)
|
|
89
|
+
[0, 0.001, 102], // Normal segment
|
|
90
|
+
[0, 0.002, 103], // Normal segment
|
|
88
91
|
],
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
// Using fixed-length segments should smooth out the unrealistic spike
|
|
95
|
+
const result = getPitchData(profileGeometry, 50)
|
|
96
|
+
|
|
97
|
+
// The overall change should be more reasonable when calculated over fixed distances
|
|
98
|
+
expect(result.maxPitchInPercent).toBeLessThan(0.5) // Should be much less than the individual segment pitch
|
|
94
99
|
})
|
|
95
100
|
})
|
|
96
101
|
|
|
@@ -115,6 +120,25 @@ describe('ElevationProfile', () => {
|
|
|
115
120
|
11.175409464719593, 47.31138883724759,
|
|
116
121
|
])
|
|
117
122
|
})
|
|
123
|
+
|
|
124
|
+
it('handles points that already have elevation', () => {
|
|
125
|
+
const geometry: GeoJSON.LineString = {
|
|
126
|
+
type: 'LineString',
|
|
127
|
+
coordinates: [
|
|
128
|
+
[11.177452968770694, 47.312650638218656, 1500], // With elevation
|
|
129
|
+
[11.175409464719593, 47.31138883724759, 1450], // With elevation
|
|
130
|
+
],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result = extractPointsForElevationProfile(geometry, 20)
|
|
134
|
+
|
|
135
|
+
// Should still work properly, returning 2D points regardless of input dimension
|
|
136
|
+
expect(result.coordinates.length).toBeGreaterThan(1)
|
|
137
|
+
expect(result.coordinates[0].length).toBe(2) // Should always return 2D points
|
|
138
|
+
expect(result.coordinates[0]).toEqual([
|
|
139
|
+
11.177452968770694, 47.312650638218656,
|
|
140
|
+
])
|
|
141
|
+
})
|
|
118
142
|
})
|
|
119
143
|
|
|
120
144
|
describe('getRunElevationData', () => {
|
|
@@ -201,8 +225,8 @@ describe('ElevationProfile', () => {
|
|
|
201
225
|
const liftFeature: LiftFeature = {
|
|
202
226
|
type: 'Feature',
|
|
203
227
|
geometry: {
|
|
204
|
-
type: '
|
|
205
|
-
coordinates: [0, 0, 100],
|
|
228
|
+
type: 'MultiLineString',
|
|
229
|
+
coordinates: [[[0, 0, 100], [1, 1, 200]]],
|
|
206
230
|
},
|
|
207
231
|
properties: {
|
|
208
232
|
type: FeatureType.Lift,
|
|
@@ -228,5 +252,115 @@ describe('ElevationProfile', () => {
|
|
|
228
252
|
|
|
229
253
|
expect(getLiftElevationData(liftFeature)).toBeNull()
|
|
230
254
|
})
|
|
255
|
+
|
|
256
|
+
it('get max aspect', () => {
|
|
257
|
+
const runFeature = {
|
|
258
|
+
type: 'Feature',
|
|
259
|
+
properties: {
|
|
260
|
+
type: 'run',
|
|
261
|
+
id: 'c8c91edb337438a2cc8b96f3e3cc42887384de3b',
|
|
262
|
+
uses: ['nordic'],
|
|
263
|
+
name: 'City-Trail Davos',
|
|
264
|
+
ref: '3',
|
|
265
|
+
description: 'Golfplatz – Bünda',
|
|
266
|
+
difficulty: 'novice',
|
|
267
|
+
difficultyConvention: 'europe',
|
|
268
|
+
status: 'operating',
|
|
269
|
+
oneway: false,
|
|
270
|
+
lit: null,
|
|
271
|
+
gladed: null,
|
|
272
|
+
patrolled: null,
|
|
273
|
+
grooming: 'classic+skating',
|
|
274
|
+
skiAreas: [],
|
|
275
|
+
elevationProfile: {
|
|
276
|
+
heights: [
|
|
277
|
+
1546, 1546, 1546, 1546, 1546, 1545, 1546, 1546, 1546, 1546, 1546,
|
|
278
|
+
1545, 1544, 1543, 1543, 1543, 1543, 1543, 1543, 1543, 1544, 1544,
|
|
279
|
+
1545, 1546, 1547, 1547, 1547, 1547, 1547, 1547, 1547, 1547, 1547,
|
|
280
|
+
1547, 1547, 1547, 1547, 1547, 1548, 1549, 1549, 1549, 1549, 1549,
|
|
281
|
+
1550, 1550, 1551, 1552, 1552, 1552, 1552, 1552, 1552, 1552, 1553,
|
|
282
|
+
1554, 1554, 1554, 1555, 1556, 1557, 1557, 1557, 1557, 1558, 1560,
|
|
283
|
+
1563, 1564, 1567, 1569, 1570, 1570, 1572, 1572, 1571, 1570, 1572,
|
|
284
|
+
1574, 1572, 1571, 1571, 1572, 1572, 1573, 1574, 1573, 1572, 1572,
|
|
285
|
+
1573, 1575, 1576, 1577, 1580, 1583, 1584, 1586,
|
|
286
|
+
],
|
|
287
|
+
resolution: 25,
|
|
288
|
+
},
|
|
289
|
+
sources: [],
|
|
290
|
+
websites: [],
|
|
291
|
+
wikidata_id: null,
|
|
292
|
+
},
|
|
293
|
+
geometry: {
|
|
294
|
+
type: 'LineString',
|
|
295
|
+
coordinates: [
|
|
296
|
+
[9.8353072, 46.797782200000015, 1546],
|
|
297
|
+
[9.8353827, 46.797835800000016, 1546],
|
|
298
|
+
[9.8356987, 46.798060000000014, 1546],
|
|
299
|
+
[9.8360226, 46.79806910000001, 1546],
|
|
300
|
+
[9.8380422, 46.79899450000001, 1546],
|
|
301
|
+
[9.837935, 46.79945900000001, 1544],
|
|
302
|
+
[9.8376614, 46.79963530000002, 1543],
|
|
303
|
+
[9.8372698, 46.7996886, 1543],
|
|
304
|
+
[9.8370767, 46.79981160000001, 1543],
|
|
305
|
+
[9.8370096, 46.79993550000002, 1544],
|
|
306
|
+
[9.8371799, 46.8000539, 1543],
|
|
307
|
+
[9.837685500000003, 46.8002724, 1543],
|
|
308
|
+
[9.8379698, 46.8005066, 1543],
|
|
309
|
+
[9.8380396, 46.8006763, 1544],
|
|
310
|
+
[9.8379765, 46.8008132, 1544],
|
|
311
|
+
[9.837833, 46.80095, 1545],
|
|
312
|
+
[9.8378196, 46.8011363, 1546],
|
|
313
|
+
[9.8378488, 46.8013207, 1547],
|
|
314
|
+
[9.837881, 46.8014058, 1547],
|
|
315
|
+
[9.837895, 46.8014252, 1547],
|
|
316
|
+
[9.8382166, 46.801688099999986, 1547],
|
|
317
|
+
[9.8383936, 46.8019258, 1547],
|
|
318
|
+
[9.8386216, 46.8029164, 1547],
|
|
319
|
+
[9.8387771, 46.8031256, 1547],
|
|
320
|
+
[9.8394209, 46.8034763, 1547],
|
|
321
|
+
[9.8397186, 46.8037939, 1547],
|
|
322
|
+
[9.8398018, 46.804254799999995, 1548],
|
|
323
|
+
[9.8399613, 46.8044383, 1549],
|
|
324
|
+
[9.8401469, 46.80448349999998, 1549],
|
|
325
|
+
[9.8402551, 46.80451270000001, 1549],
|
|
326
|
+
[9.8405166, 46.80462749999998, 1549],
|
|
327
|
+
[9.8422614, 46.804981799999986, 1552],
|
|
328
|
+
[9.842446900000004, 46.805034, 1552],
|
|
329
|
+
[9.8425913, 46.805118600000014, 1552],
|
|
330
|
+
[9.8426637, 46.8053545, 1552],
|
|
331
|
+
[9.842754900000003, 46.80561510000001, 1552],
|
|
332
|
+
[9.8429278, 46.805750300000014, 1552],
|
|
333
|
+
[9.8434727, 46.80615580000001, 1553],
|
|
334
|
+
[9.8437722, 46.806829199999996, 1554],
|
|
335
|
+
[9.8437728, 46.807147199999996, 1555],
|
|
336
|
+
[9.8442519, 46.8075484, 1557],
|
|
337
|
+
[9.8446648, 46.807873199999996, 1557],
|
|
338
|
+
[9.8448358, 46.80802230000001, 1557],
|
|
339
|
+
[9.8453494, 46.80833250000001, 1562],
|
|
340
|
+
[9.8457157, 46.808526, 1564],
|
|
341
|
+
[9.8463504, 46.8087692, 1569],
|
|
342
|
+
[9.8467433, 46.8090115, 1570],
|
|
343
|
+
[9.8471886, 46.8092391, 1571],
|
|
344
|
+
[9.8474304, 46.8093297, 1572],
|
|
345
|
+
[9.847923499999998, 46.8095145, 1570],
|
|
346
|
+
[9.849087, 46.80979280000001, 1573],
|
|
347
|
+
[9.8495311, 46.809877599999986, 1571],
|
|
348
|
+
[9.850192000000003, 46.8099654, 1572],
|
|
349
|
+
[9.8513658, 46.81011090000001, 1572],
|
|
350
|
+
[9.852096400000004, 46.810099599999994, 1573],
|
|
351
|
+
[9.852562200000007, 46.810143499999995, 1575],
|
|
352
|
+
[9.852670200000004, 46.8103734, 1576],
|
|
353
|
+
[9.852746100000006, 46.8105425, 1577],
|
|
354
|
+
[9.852914200000006, 46.81067139999999, 1580],
|
|
355
|
+
[9.853248800000003, 46.810729599999995, 1583],
|
|
356
|
+
[9.853487800000007, 46.810662799999996, 1584],
|
|
357
|
+
[9.853535100000004, 46.81066409999998, 1586],
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
} as RunFeature
|
|
361
|
+
|
|
362
|
+
const result = getRunElevationData(runFeature)
|
|
363
|
+
expect(result?.maxPitchInPercent).toBeCloseTo(0.12, 2)
|
|
364
|
+
})
|
|
231
365
|
})
|
|
232
366
|
})
|
package/src/ElevationProfile.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import distance from '@turf/distance'
|
|
1
2
|
import length from '@turf/length'
|
|
2
3
|
import { lineChunk } from '@turf/line-chunk'
|
|
3
4
|
import { LineString } from 'geojson'
|
|
@@ -32,10 +33,10 @@ type AscentDescentData = {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
type PitchData = {
|
|
35
|
-
averagePitchInPercent: number
|
|
36
|
-
maxPitchInPercent: number
|
|
36
|
+
averagePitchInPercent: number | null
|
|
37
|
+
maxPitchInPercent: number | null
|
|
37
38
|
inclinedLengthInMeters: number
|
|
38
|
-
overallPitchInPercent: number
|
|
39
|
+
overallPitchInPercent: number | null
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function getElevationData(
|
|
@@ -48,17 +49,21 @@ export function getElevationData(
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
export function getPitchData(
|
|
52
|
+
export function getPitchData(
|
|
53
|
+
profileGeometry: GeoJSON.LineString,
|
|
54
|
+
resolutionInMeters: number = 25, // Default resolution of 25 meters for pitch calculation
|
|
55
|
+
): PitchData {
|
|
52
56
|
const coordinates = profileGeometry.coordinates
|
|
53
57
|
if (coordinates[0].length < 3) {
|
|
54
58
|
throw 'Elevation data is required for slope analysis'
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
// For overall calculations (average pitch, total length, etc.)
|
|
57
62
|
let totalLength = 0
|
|
58
63
|
let totalInclinedLength = 0
|
|
59
64
|
let totalElevationChange = 0
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
|
|
66
|
+
// First calculate the values needed for overall statistics
|
|
62
67
|
for (let i = 0; i < coordinates.length - 1; i++) {
|
|
63
68
|
const before = coordinates[i]
|
|
64
69
|
const after = coordinates[i + 1]
|
|
@@ -74,10 +79,6 @@ export function getPitchData(profileGeometry: GeoJSON.LineString): PitchData {
|
|
|
74
79
|
}
|
|
75
80
|
const lengthInMeters = length(feature, { units: 'meters' })
|
|
76
81
|
|
|
77
|
-
const percentSlope = elevation / lengthInMeters
|
|
78
|
-
maxUphillPitch = Math.max(maxUphillPitch, percentSlope)
|
|
79
|
-
maxDownhillPitch = Math.min(maxDownhillPitch, percentSlope)
|
|
80
|
-
|
|
81
82
|
totalLength += lengthInMeters
|
|
82
83
|
totalInclinedLength += Math.sqrt(
|
|
83
84
|
Math.pow(lengthInMeters, 2) + Math.pow(elevation, 2),
|
|
@@ -85,17 +86,59 @@ export function getPitchData(profileGeometry: GeoJSON.LineString): PitchData {
|
|
|
85
86
|
totalElevationChange += Math.abs(elevation)
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
// Chunk the line into fixed-length segments for max pitch calculation
|
|
90
|
+
const chunkedGeometries = lineChunk(profileGeometry, resolutionInMeters, {
|
|
91
|
+
units: 'meters',
|
|
92
|
+
}).features.map((feature) => feature.geometry)
|
|
93
|
+
|
|
94
|
+
// Mutates the geometries in place to add elevation data
|
|
95
|
+
interpolateElevation(chunkedGeometries)
|
|
96
|
+
|
|
97
|
+
// Initialize max pitch values
|
|
98
|
+
let maxPitchValue: number | null = null
|
|
99
|
+
|
|
100
|
+
// Calculate max pitch over the fixed-length chunks
|
|
101
|
+
for (const chunk of chunkedGeometries) {
|
|
102
|
+
const chunkCoords = chunk.coordinates
|
|
103
|
+
|
|
104
|
+
const startPoint = chunkCoords[0]
|
|
105
|
+
const endPoint = chunkCoords[chunkCoords.length - 1]
|
|
106
|
+
|
|
107
|
+
const elevationChange = endPoint[2] - startPoint[2]
|
|
108
|
+
const chunkLengthInMeters = length(
|
|
109
|
+
{ type: 'Feature', geometry: chunk, properties: {} },
|
|
110
|
+
{ units: 'meters' },
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// Skip chunks that are too close together to calculate pitch reliably (can happen for the last chunk)
|
|
114
|
+
if (chunkLengthInMeters < resolutionInMeters / 2) continue
|
|
115
|
+
|
|
116
|
+
const chunkPitch = Math.abs(elevationChange / chunkLengthInMeters)
|
|
117
|
+
if (maxPitchValue === null || chunkPitch > maxPitchValue) {
|
|
118
|
+
maxPitchValue = chunkPitch
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
92
122
|
const averagePitch = totalElevationChange / totalLength
|
|
93
123
|
const overallElevationChange = Math.abs(
|
|
94
124
|
coordinates[coordinates.length - 1][2] - coordinates[0][2],
|
|
95
125
|
)
|
|
126
|
+
|
|
127
|
+
// null maxPitchValue indicates the line is shorter than half the resolution, in which case the accuracy of pitch calculations
|
|
128
|
+
// is questionable as a 1m change in elevation over
|
|
129
|
+
// 1m of distance would be a 45 degree slope, this can happen due to resolution of the elevation data.
|
|
130
|
+
if (maxPitchValue === null) {
|
|
131
|
+
return {
|
|
132
|
+
averagePitchInPercent: null,
|
|
133
|
+
maxPitchInPercent: null,
|
|
134
|
+
inclinedLengthInMeters: totalInclinedLength,
|
|
135
|
+
overallPitchInPercent: null,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
96
139
|
return {
|
|
97
140
|
averagePitchInPercent: averagePitch,
|
|
98
|
-
maxPitchInPercent:
|
|
141
|
+
maxPitchInPercent: maxPitchValue,
|
|
99
142
|
inclinedLengthInMeters: totalInclinedLength,
|
|
100
143
|
overallPitchInPercent: overallElevationChange / totalLength,
|
|
101
144
|
}
|
|
@@ -105,20 +148,45 @@ export function getProfileGeometry(
|
|
|
105
148
|
geometry: GeoJSON.LineString,
|
|
106
149
|
elevationProfile: ElevationProfile,
|
|
107
150
|
): GeoJSON.LineString {
|
|
108
|
-
const
|
|
151
|
+
const geometries = lineChunksForElevationProfile(
|
|
109
152
|
geometry,
|
|
110
153
|
elevationProfile.resolution,
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
)
|
|
155
|
+
var index = 0
|
|
156
|
+
for (let subline of geometries) {
|
|
157
|
+
const firstPoint = subline.coordinates[0]
|
|
158
|
+
if (firstPoint.length === 2) {
|
|
159
|
+
firstPoint.push(elevationProfile.heights[index])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
index++
|
|
163
|
+
|
|
164
|
+
if (index === elevationProfile.heights.length) {
|
|
165
|
+
throw 'Mismatch of points & elevation profile.'
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const lastPoint = subline.coordinates[subline.coordinates.length - 1]
|
|
169
|
+
if (lastPoint.length === 2) {
|
|
170
|
+
lastPoint.push(elevationProfile.heights[index])
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (index !== elevationProfile.heights.length - 1) {
|
|
175
|
+
throw `Mismatch of points & elevation profile`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check all coordinates have a height
|
|
179
|
+
for (let subline of geometries) {
|
|
180
|
+
for (let point of subline.coordinates) {
|
|
181
|
+
if (point.length < 3) {
|
|
182
|
+
throw 'All points should have an elevation at this point.'
|
|
183
|
+
}
|
|
184
|
+
}
|
|
115
185
|
}
|
|
116
186
|
|
|
117
187
|
return {
|
|
118
188
|
type: 'LineString',
|
|
119
|
-
coordinates:
|
|
120
|
-
return [point[0], point[1], heights[index]]
|
|
121
|
-
}),
|
|
189
|
+
coordinates: geometries.flatMap((geometry) => geometry.coordinates),
|
|
122
190
|
}
|
|
123
191
|
}
|
|
124
192
|
|
|
@@ -129,25 +197,18 @@ export function extractPointsForElevationProfile(
|
|
|
129
197
|
geometry: LineString,
|
|
130
198
|
resolution: number,
|
|
131
199
|
): GeoJSON.LineString {
|
|
132
|
-
const
|
|
133
|
-
units: 'meters',
|
|
134
|
-
}).features
|
|
200
|
+
const geometries = lineChunksForElevationProfile(geometry, resolution)
|
|
135
201
|
const points: GeoJSON.Position[] = []
|
|
136
|
-
for (let subline of
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const point = geometry.coordinates[0]
|
|
140
|
-
points.push([point[0], point[1]])
|
|
141
|
-
}
|
|
202
|
+
for (let subline of geometries) {
|
|
203
|
+
const point = subline.coordinates[0]
|
|
204
|
+
points.push([point[0], point[1]])
|
|
142
205
|
}
|
|
143
|
-
if (
|
|
144
|
-
const geometry =
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
points.push([point[0], point[1]])
|
|
150
|
-
}
|
|
206
|
+
if (geometries.length > 0) {
|
|
207
|
+
const geometry = geometries[geometries.length - 1]
|
|
208
|
+
const coords = geometry.coordinates
|
|
209
|
+
if (coords.length > 1) {
|
|
210
|
+
const point = coords[coords.length - 1]
|
|
211
|
+
points.push([point[0], point[1]])
|
|
151
212
|
}
|
|
152
213
|
}
|
|
153
214
|
|
|
@@ -157,6 +218,15 @@ export function extractPointsForElevationProfile(
|
|
|
157
218
|
}
|
|
158
219
|
}
|
|
159
220
|
|
|
221
|
+
function lineChunksForElevationProfile(
|
|
222
|
+
geometry: LineString,
|
|
223
|
+
resolution: number,
|
|
224
|
+
): GeoJSON.LineString[] {
|
|
225
|
+
return lineChunk(geometry, resolution, {
|
|
226
|
+
units: 'meters',
|
|
227
|
+
}).features.map((feature) => feature.geometry)
|
|
228
|
+
}
|
|
229
|
+
|
|
160
230
|
export function getAscentAndDescent(
|
|
161
231
|
profileGeometry: GeoJSON.LineString,
|
|
162
232
|
): AscentDescentData {
|
|
@@ -207,3 +277,101 @@ export function getAscentAndDescent(
|
|
|
207
277
|
verticalInMeters: result.maxElevation - result.minElevation,
|
|
208
278
|
}
|
|
209
279
|
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Interpolate elevation for points along a list of linestrings that don't already have elevation.
|
|
283
|
+
* Uses geographic distances for proper interpolation.
|
|
284
|
+
* Mutates the geometries in place.
|
|
285
|
+
*
|
|
286
|
+
* @throws Error if no points have elevation data or if segments can't be properly formed
|
|
287
|
+
*/
|
|
288
|
+
function interpolateElevation(geometries: GeoJSON.LineString[]) {
|
|
289
|
+
if (geometries.length === 0) {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Flatten all coordinates into a single array for easier processing
|
|
294
|
+
const allPoints: {
|
|
295
|
+
point: GeoJSON.Position
|
|
296
|
+
hasElevation: boolean
|
|
297
|
+
index: number
|
|
298
|
+
distanceFromStart: number // Distance from the beginning of the line
|
|
299
|
+
}[] = []
|
|
300
|
+
|
|
301
|
+
// Collect all points while tracking which ones have elevation
|
|
302
|
+
let pointIndex = 0
|
|
303
|
+
let totalDistance = 0
|
|
304
|
+
let previousPoint: GeoJSON.Position | null = null
|
|
305
|
+
|
|
306
|
+
for (const geometry of geometries) {
|
|
307
|
+
for (const point of geometry.coordinates) {
|
|
308
|
+
// Calculate distance from previous point
|
|
309
|
+
if (previousPoint) {
|
|
310
|
+
const segmentLength = distance(previousPoint, point, {
|
|
311
|
+
units: 'meters',
|
|
312
|
+
})
|
|
313
|
+
totalDistance += segmentLength
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const hasElevation = point.length >= 3
|
|
317
|
+
allPoints.push({
|
|
318
|
+
point,
|
|
319
|
+
hasElevation,
|
|
320
|
+
index: pointIndex++,
|
|
321
|
+
distanceFromStart: totalDistance,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
previousPoint = point
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Find elevation reference points
|
|
329
|
+
const referencePoints = allPoints.filter((p) => p.hasElevation)
|
|
330
|
+
|
|
331
|
+
if (referencePoints.length <= 1) {
|
|
332
|
+
throw 'At least two points with elevation data are required'
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Process segments between each pair of reference points
|
|
336
|
+
for (let i = 0; i < referencePoints.length - 1; i++) {
|
|
337
|
+
const startPoint = referencePoints[i]
|
|
338
|
+
const endPoint = referencePoints[i + 1]
|
|
339
|
+
|
|
340
|
+
// Points must be in sequence
|
|
341
|
+
if (endPoint.index <= startPoint.index) {
|
|
342
|
+
throw 'Reference points must be in sequence'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const startElevation = startPoint.point[2]
|
|
346
|
+
const endElevation = endPoint.point[2]
|
|
347
|
+
const elevationDelta = endElevation - startElevation
|
|
348
|
+
|
|
349
|
+
// Calculate the geographic distance between reference points
|
|
350
|
+
const distanceBetweenRefs =
|
|
351
|
+
endPoint.distanceFromStart - startPoint.distanceFromStart
|
|
352
|
+
|
|
353
|
+
// Interpolate elevations for points between references
|
|
354
|
+
for (let j = startPoint.index + 1; j < endPoint.index; j++) {
|
|
355
|
+
const pointData = allPoints[j]
|
|
356
|
+
|
|
357
|
+
// Only interpolate if point doesn't already have elevation
|
|
358
|
+
if (pointData.hasElevation) {
|
|
359
|
+
throw "Can't interpolate elevation for points that already have it"
|
|
360
|
+
}
|
|
361
|
+
// Calculate how far along this point is between the reference points
|
|
362
|
+
const distanceFromStart =
|
|
363
|
+
pointData.distanceFromStart - startPoint.distanceFromStart
|
|
364
|
+
|
|
365
|
+
// Its possible both references are the same point, in which case we use the ref elevation
|
|
366
|
+
let interpolatedElevation = startElevation
|
|
367
|
+
if (distanceBetweenRefs > 0) {
|
|
368
|
+
const interpolationFactor = distanceFromStart / distanceBetweenRefs
|
|
369
|
+
interpolatedElevation =
|
|
370
|
+
startElevation + elevationDelta * interpolationFactor
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Add elevation to the existing point array (using reference semantics)
|
|
374
|
+
pointData.point.push(interpolatedElevation)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
package/src/Lift.ts
CHANGED
|
@@ -7,13 +7,7 @@ import { exhaustiveMatchingGuard } from './util/exhaustiveMatchingGuard'
|
|
|
7
7
|
|
|
8
8
|
export type LiftFeature = GeoJSON.Feature<LiftGeometry, LiftProperties>
|
|
9
9
|
|
|
10
|
-
export type LiftGeometry =
|
|
11
|
-
| GeoJSON.Point
|
|
12
|
-
| GeoJSON.MultiPoint
|
|
13
|
-
| GeoJSON.LineString
|
|
14
|
-
| GeoJSON.MultiLineString
|
|
15
|
-
| GeoJSON.Polygon
|
|
16
|
-
| GeoJSON.MultiPolygon
|
|
10
|
+
export type LiftGeometry = GeoJSON.LineString | GeoJSON.MultiLineString
|
|
17
11
|
|
|
18
12
|
/**
|
|
19
13
|
* A feature representing a ski lift.
|
package/src/SkiArea.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { Status } from './Status'
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* A ski area feature is derived from several sources:
|
|
10
|
-
* - OpenStreetMap site=piste relations
|
|
11
|
-
* - OpenStreetMap landuse=winter_sports areas
|
|
10
|
+
* - OpenStreetMap site=piste relations (must contain at least one lift or run of any kind)
|
|
11
|
+
* - OpenStreetMap landuse=winter_sports areas (must contain at least one downhill/nordic (non-backcountry) run or operational lift)
|
|
12
12
|
* - Skimap.org ski areas
|
|
13
13
|
*
|
|
14
14
|
* The processor attempts to merge the data of all sources to create a single ski area feature.
|