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
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import { RunDifficulty, RunUse } from "./Run";
|
|
2
|
-
import { getEstimatedRunDifficulty, getSlopeGradingScale } from "./SlopeGradingScale";
|
|
3
|
-
import { mockViewportHint } from "./testUtils";
|
|
4
|
-
|
|
5
|
-
describe("getEstimatedRunDifficulty", () => {
|
|
6
|
-
it("should return null for steepness below minimum threshold", () => {
|
|
7
|
-
const downhillScale = getSlopeGradingScale({
|
|
8
|
-
type: "Feature",
|
|
9
|
-
geometry: { type: "LineString", coordinates: [] },
|
|
10
|
-
properties: {
|
|
11
|
-
type: "Run" as any,
|
|
12
|
-
uses: [RunUse.Downhill],
|
|
13
|
-
id: "test",
|
|
14
|
-
name: null,
|
|
15
|
-
ref: null,
|
|
16
|
-
status: "operating" as any,
|
|
17
|
-
description: null,
|
|
18
|
-
difficulty: null,
|
|
19
|
-
difficultyConvention: "europe" as any,
|
|
20
|
-
oneway: null,
|
|
21
|
-
lit: null,
|
|
22
|
-
gladed: null,
|
|
23
|
-
patrolled: null,
|
|
24
|
-
snowmaking: null,
|
|
25
|
-
snowfarming: null,
|
|
26
|
-
grooming: null,
|
|
27
|
-
skiAreas: [],
|
|
28
|
-
elevationProfile: null,
|
|
29
|
-
sources: [],
|
|
30
|
-
websites: [],
|
|
31
|
-
wikidataID: null,
|
|
32
|
-
tunnel: null,
|
|
33
|
-
places: [],
|
|
34
|
-
viewportHint: mockViewportHint(),
|
|
35
|
-
},
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
expect(getEstimatedRunDifficulty(0.03, downhillScale)).toBeNull(); // 3% steepness
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("should return correct difficulty for downhill slopes", () => {
|
|
42
|
-
const downhillScale = getSlopeGradingScale({
|
|
43
|
-
type: "Feature",
|
|
44
|
-
geometry: { type: "LineString", coordinates: [] },
|
|
45
|
-
properties: {
|
|
46
|
-
type: "Run" as any,
|
|
47
|
-
uses: [RunUse.Downhill],
|
|
48
|
-
id: "test",
|
|
49
|
-
name: null,
|
|
50
|
-
ref: null,
|
|
51
|
-
status: "operating" as any,
|
|
52
|
-
description: null,
|
|
53
|
-
difficulty: null,
|
|
54
|
-
difficultyConvention: "europe" as any,
|
|
55
|
-
oneway: null,
|
|
56
|
-
lit: null,
|
|
57
|
-
gladed: null,
|
|
58
|
-
patrolled: null,
|
|
59
|
-
snowmaking: null,
|
|
60
|
-
snowfarming: null,
|
|
61
|
-
grooming: null,
|
|
62
|
-
skiAreas: [],
|
|
63
|
-
elevationProfile: null,
|
|
64
|
-
sources: [],
|
|
65
|
-
websites: [],
|
|
66
|
-
wikidataID: null,
|
|
67
|
-
tunnel: null,
|
|
68
|
-
places: [],
|
|
69
|
-
viewportHint: mockViewportHint(),
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
expect(getEstimatedRunDifficulty(0.1, downhillScale)).toBe(RunDifficulty.EASY); // 10%
|
|
74
|
-
expect(getEstimatedRunDifficulty(0.3, downhillScale)).toBe(RunDifficulty.INTERMEDIATE); // 30%
|
|
75
|
-
expect(getEstimatedRunDifficulty(0.6, downhillScale)).toBe(RunDifficulty.ADVANCED); // 60%
|
|
76
|
-
expect(getEstimatedRunDifficulty(0.9, downhillScale)).toBe(RunDifficulty.EXPERT); // 90%
|
|
77
|
-
expect(getEstimatedRunDifficulty(1.0, downhillScale)).toBe(RunDifficulty.FREERIDE); // 100%
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("should return correct difficulty for nordic slopes", () => {
|
|
81
|
-
const nordicScale = getSlopeGradingScale({
|
|
82
|
-
type: "Feature",
|
|
83
|
-
geometry: { type: "LineString", coordinates: [] },
|
|
84
|
-
properties: {
|
|
85
|
-
type: "Run" as any,
|
|
86
|
-
uses: [RunUse.Nordic],
|
|
87
|
-
id: "test",
|
|
88
|
-
name: null,
|
|
89
|
-
ref: null,
|
|
90
|
-
status: "operating" as any,
|
|
91
|
-
description: null,
|
|
92
|
-
difficulty: null,
|
|
93
|
-
difficultyConvention: "europe" as any,
|
|
94
|
-
oneway: null,
|
|
95
|
-
lit: null,
|
|
96
|
-
gladed: null,
|
|
97
|
-
patrolled: null,
|
|
98
|
-
snowmaking: null,
|
|
99
|
-
snowfarming: null,
|
|
100
|
-
grooming: null,
|
|
101
|
-
skiAreas: [],
|
|
102
|
-
elevationProfile: null,
|
|
103
|
-
sources: [],
|
|
104
|
-
websites: [],
|
|
105
|
-
wikidataID: null,
|
|
106
|
-
tunnel: null,
|
|
107
|
-
places: [],
|
|
108
|
-
viewportHint: mockViewportHint(),
|
|
109
|
-
},
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
expect(getEstimatedRunDifficulty(0.05, nordicScale)).toBe(RunDifficulty.EASY); // 5%
|
|
113
|
-
expect(getEstimatedRunDifficulty(0.12, nordicScale)).toBe(RunDifficulty.INTERMEDIATE); // 12%
|
|
114
|
-
expect(getEstimatedRunDifficulty(0.25, nordicScale)).toBe(RunDifficulty.ADVANCED); // 25%
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("should handle negative steepness (uphill)", () => {
|
|
118
|
-
const downhillScale = getSlopeGradingScale({
|
|
119
|
-
type: "Feature",
|
|
120
|
-
geometry: { type: "LineString", coordinates: [] },
|
|
121
|
-
properties: {
|
|
122
|
-
type: "Run" as any,
|
|
123
|
-
uses: [RunUse.Downhill],
|
|
124
|
-
id: "test",
|
|
125
|
-
name: null,
|
|
126
|
-
ref: null,
|
|
127
|
-
status: "operating" as any,
|
|
128
|
-
description: null,
|
|
129
|
-
difficulty: null,
|
|
130
|
-
difficultyConvention: "europe" as any,
|
|
131
|
-
oneway: null,
|
|
132
|
-
lit: null,
|
|
133
|
-
gladed: null,
|
|
134
|
-
patrolled: null,
|
|
135
|
-
snowmaking: null,
|
|
136
|
-
snowfarming: null,
|
|
137
|
-
grooming: null,
|
|
138
|
-
skiAreas: [],
|
|
139
|
-
elevationProfile: null,
|
|
140
|
-
sources: [],
|
|
141
|
-
websites: [],
|
|
142
|
-
wikidataID: null,
|
|
143
|
-
tunnel: null,
|
|
144
|
-
places: [],
|
|
145
|
-
viewportHint: mockViewportHint(),
|
|
146
|
-
},
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
expect(getEstimatedRunDifficulty(-0.1, downhillScale)).toBe(RunDifficulty.EASY); // -10%
|
|
150
|
-
});
|
|
151
|
-
});
|
package/src/SlopeGradingScale.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { RunDifficulty, RunFeature, RunUse } from "./Run";
|
|
2
|
-
import { RunDifficultyConvention } from "./RunDifficultyConvention";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Represents a slope grading scale with steepness thresholds for different run types.
|
|
6
|
-
* Used to estimate run difficulty based on slope steepness. Each scale defines
|
|
7
|
-
* threshold points where the difficulty classification changes based on gradient.
|
|
8
|
-
*/
|
|
9
|
-
export type SlopeGradingScale = {
|
|
10
|
-
/** Stops must be ordered ascending by steepness */
|
|
11
|
-
stops: { maxSteepness: number; difficulty: RunDifficulty | null }[];
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Gets the appropriate slope grading scale for a run based on its use types.
|
|
16
|
-
* Different run uses (downhill, nordic, etc.) have different steepness thresholds
|
|
17
|
-
* for difficulty classification due to the nature of the activity and equipment used.
|
|
18
|
-
*
|
|
19
|
-
* @param feature - The run feature to get the grading scale for
|
|
20
|
-
* @returns The appropriate slope grading scale with steepness thresholds
|
|
21
|
-
*/
|
|
22
|
-
export function getSlopeGradingScale(feature: RunFeature): SlopeGradingScale {
|
|
23
|
-
// Check for downhill or skitour uses first (they have the same scale)
|
|
24
|
-
if (
|
|
25
|
-
feature.properties.uses.includes(RunUse.Downhill) ||
|
|
26
|
-
feature.properties.uses.includes(RunUse.Skitour)
|
|
27
|
-
) {
|
|
28
|
-
return getSlopeGradingScaleForUse(RunUse.Downhill, feature.properties.difficultyConvention);
|
|
29
|
-
} else if (feature.properties.uses.includes(RunUse.Nordic)) {
|
|
30
|
-
return getSlopeGradingScaleForUse(RunUse.Nordic, feature.properties.difficultyConvention);
|
|
31
|
-
} else {
|
|
32
|
-
return { stops: [] };
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Estimates the run difficulty based on steepness and the provided slope grading scale.
|
|
38
|
-
* Uses the absolute value of steepness to handle both uphill and downhill slopes.
|
|
39
|
-
* Finds the first threshold that the steepness falls under.
|
|
40
|
-
*
|
|
41
|
-
* @param steepness - The steepness value (slope gradient as a decimal, e.g., 0.1 = 10%)
|
|
42
|
-
* @param scale - The slope grading scale with steepness thresholds
|
|
43
|
-
* @returns The estimated difficulty based on steepness, or null if no match or below minimum threshold
|
|
44
|
-
*/
|
|
45
|
-
export function getEstimatedRunDifficulty(
|
|
46
|
-
steepness: number,
|
|
47
|
-
scale: SlopeGradingScale
|
|
48
|
-
): RunDifficulty | null {
|
|
49
|
-
const absoluteSteepness = Math.abs(steepness);
|
|
50
|
-
return (
|
|
51
|
-
scale.stops.find((stop) => stop.maxSteepness > absoluteSteepness)
|
|
52
|
-
?.difficulty || null
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Gets the appropriate slope grading scale for a specific run use and region.
|
|
58
|
-
* Different run uses (downhill, nordic, etc.) have different steepness thresholds
|
|
59
|
-
* for difficulty classification. This function allows for regional variations
|
|
60
|
-
* in the future if different conventions use different slope scales.
|
|
61
|
-
*
|
|
62
|
-
* @param runUse - The type of run use to get the grading scale for
|
|
63
|
-
* @param convention - The regional difficulty convention (currently not used but available for future regional variations)
|
|
64
|
-
* @returns The appropriate slope grading scale with steepness thresholds
|
|
65
|
-
*/
|
|
66
|
-
export function getSlopeGradingScaleForUse(
|
|
67
|
-
runUse: RunUse,
|
|
68
|
-
convention: RunDifficultyConvention
|
|
69
|
-
): SlopeGradingScale {
|
|
70
|
-
// Currently convention is not used, but is available for future regional variations
|
|
71
|
-
switch (runUse) {
|
|
72
|
-
case RunUse.Downhill:
|
|
73
|
-
case RunUse.Skitour:
|
|
74
|
-
return {
|
|
75
|
-
stops: [
|
|
76
|
-
{ maxSteepness: 0.05, difficulty: null }, // No difficulty below 5%
|
|
77
|
-
{ maxSteepness: 0.25, difficulty: RunDifficulty.EASY },
|
|
78
|
-
{ maxSteepness: 0.4, difficulty: RunDifficulty.INTERMEDIATE },
|
|
79
|
-
{ maxSteepness: 0.8, difficulty: RunDifficulty.ADVANCED }, // Advanced up to 80%
|
|
80
|
-
{ maxSteepness: 1.0, difficulty: RunDifficulty.EXPERT }, // Expert up to 100%
|
|
81
|
-
{ maxSteepness: 1.2, difficulty: RunDifficulty.FREERIDE }, // Freeride up to 120%
|
|
82
|
-
{ maxSteepness: Infinity, difficulty: RunDifficulty.EXTREME }, // Extreme above 120%
|
|
83
|
-
],
|
|
84
|
-
};
|
|
85
|
-
case RunUse.Nordic:
|
|
86
|
-
return {
|
|
87
|
-
stops: [
|
|
88
|
-
{ maxSteepness: 0.1, difficulty: RunDifficulty.EASY },
|
|
89
|
-
{ maxSteepness: 0.15, difficulty: RunDifficulty.INTERMEDIATE },
|
|
90
|
-
{ maxSteepness: 0.3, difficulty: RunDifficulty.ADVANCED },
|
|
91
|
-
],
|
|
92
|
-
};
|
|
93
|
-
default:
|
|
94
|
-
return { stops: [] };
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { getMonthlySnowCover, SnowCoverHistory, MonthlySnowCoverHistory } from './SnowCoverHistory'
|
|
2
|
-
|
|
3
|
-
describe('getMonthlySnowCover', () => {
|
|
4
|
-
it('should process daily data into monthly averages', () => {
|
|
5
|
-
const input: SnowCoverHistory = [
|
|
6
|
-
{
|
|
7
|
-
year: 2023,
|
|
8
|
-
days: [
|
|
9
|
-
[1, 80, 95], // Jan 1
|
|
10
|
-
[15, 75, 90], // Jan 15
|
|
11
|
-
[32, 60, 85], // Feb 1
|
|
12
|
-
[46, 65, 80], // Feb 15
|
|
13
|
-
[60, 40, 75], // Mar 1
|
|
14
|
-
[74, 35, 70], // Mar 15
|
|
15
|
-
]
|
|
16
|
-
}
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
const result = getMonthlySnowCover(input)
|
|
20
|
-
|
|
21
|
-
expect(result).toHaveLength(1)
|
|
22
|
-
expect(result[0].year).toBe(2023)
|
|
23
|
-
expect(result[0].months).toHaveLength(3)
|
|
24
|
-
|
|
25
|
-
// January: (80*95 + 75*90) / (95 + 90) = (7600 + 6750) / 185 = 77.57
|
|
26
|
-
expect(result[0].months[0][0]).toBe(1) // Month
|
|
27
|
-
expect(result[0].months[0][1]).toBeCloseTo(77.57, 1) // Snow cover
|
|
28
|
-
expect(result[0].months[0][2]).toBe(92.5) // Valid pixels average
|
|
29
|
-
|
|
30
|
-
// February: (60*85 + 65*80) / (85 + 80) = (5100 + 5200) / 165 = 62.42
|
|
31
|
-
expect(result[0].months[1][0]).toBe(2)
|
|
32
|
-
expect(result[0].months[1][1]).toBeCloseTo(62.42, 1)
|
|
33
|
-
expect(result[0].months[1][2]).toBe(82.5)
|
|
34
|
-
|
|
35
|
-
// March: (40*75 + 35*70) / (75 + 70) = (3000 + 2450) / 145 = 37.59
|
|
36
|
-
expect(result[0].months[2][0]).toBe(3)
|
|
37
|
-
expect(result[0].months[2][1]).toBeCloseTo(37.59, 1)
|
|
38
|
-
expect(result[0].months[2][2]).toBe(72.5)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('should handle multiple years', () => {
|
|
42
|
-
const input: SnowCoverHistory = [
|
|
43
|
-
{
|
|
44
|
-
year: 2022,
|
|
45
|
-
days: [[1, 50, 80]]
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
year: 2023,
|
|
49
|
-
days: [[1, 60, 90]]
|
|
50
|
-
}
|
|
51
|
-
]
|
|
52
|
-
|
|
53
|
-
const result = getMonthlySnowCover(input)
|
|
54
|
-
|
|
55
|
-
expect(result).toHaveLength(2)
|
|
56
|
-
expect(result[0].year).toBe(2022)
|
|
57
|
-
expect(result[1].year).toBe(2023)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('should handle empty data', () => {
|
|
61
|
-
const input: SnowCoverHistory = []
|
|
62
|
-
const result = getMonthlySnowCover(input)
|
|
63
|
-
expect(result).toEqual([])
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('should handle zero valid pixels gracefully', () => {
|
|
67
|
-
const input: SnowCoverHistory = [
|
|
68
|
-
{
|
|
69
|
-
year: 2023,
|
|
70
|
-
days: [
|
|
71
|
-
[1, 80, 0], // No valid pixels
|
|
72
|
-
[15, 75, 100] // Full valid pixels
|
|
73
|
-
]
|
|
74
|
-
}
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
const result = getMonthlySnowCover(input)
|
|
78
|
-
|
|
79
|
-
expect(result[0].months[0][0]).toBe(1)
|
|
80
|
-
expect(result[0].months[0][1]).toBe(75) // Only the second day contributes
|
|
81
|
-
expect(result[0].months[0][2]).toBe(50) // Average of 0 and 100
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
it('should handle data across multiple months', () => {
|
|
85
|
-
const input: SnowCoverHistory = [
|
|
86
|
-
{
|
|
87
|
-
year: 2023,
|
|
88
|
-
days: [
|
|
89
|
-
[1, 80, 90], // January
|
|
90
|
-
[100, 40, 70], // April (day 100)
|
|
91
|
-
[335, 50, 80] // December (day 335)
|
|
92
|
-
]
|
|
93
|
-
}
|
|
94
|
-
]
|
|
95
|
-
|
|
96
|
-
const result = getMonthlySnowCover(input)
|
|
97
|
-
|
|
98
|
-
expect(result[0].months).toHaveLength(3)
|
|
99
|
-
expect(result[0].months[0][0]).toBe(1) // January
|
|
100
|
-
expect(result[0].months[1][0]).toBe(4) // April
|
|
101
|
-
expect(result[0].months[2][0]).toBe(12) // December
|
|
102
|
-
})
|
|
103
|
-
})
|
package/src/SnowCoverHistory.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Historical snow cover information for a geometry.
|
|
3
|
-
* Data is organized chronologically, with the oldest year first.
|
|
4
|
-
*
|
|
5
|
-
* @property {number} year - The year for this data
|
|
6
|
-
* @property {Array<[number, number, number]>} days - Daily measurements as tuples of [day of year, NDSI snow cover value, % of valid pixels]
|
|
7
|
-
*/
|
|
8
|
-
export type SnowCoverHistory = {
|
|
9
|
-
year: number
|
|
10
|
-
/**
|
|
11
|
-
* Array of daily snow cover measurements ordered chronologically by day of year.
|
|
12
|
-
* Each tuple contains:
|
|
13
|
-
* - Day of year (1-365/366)
|
|
14
|
-
* - NDSI snow cover value (0-100) - measures snow presence, not depth
|
|
15
|
-
* - Percentage of valid pixels (0-100) - pixels with valid data for that day
|
|
16
|
-
*
|
|
17
|
-
* Data source: VIIRS/NPP CGF Snow Cover Daily L3 Global 375m product (VNP10A1F)
|
|
18
|
-
* Resolution: 375m (affects measurement accuracy for small geometries)
|
|
19
|
-
* Frequency: Weekly under cloud-free conditions, varies in practice
|
|
20
|
-
*
|
|
21
|
-
* Valid pixel percentage may be less than 100% due to:
|
|
22
|
-
* - Cloud cover
|
|
23
|
-
* - Different cloud gap filling dates for pixels
|
|
24
|
-
* - Incomplete pipeline data
|
|
25
|
-
*/
|
|
26
|
-
days: [number, number, number][]
|
|
27
|
-
}[]
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Monthly aggregated snow cover history.
|
|
31
|
-
* Data is organized chronologically, with the oldest year first.
|
|
32
|
-
*
|
|
33
|
-
* @property {number} year - The year for this data
|
|
34
|
-
* @property {Array<[number, number, number]>} months - Monthly aggregations as tuples of [month, average snow cover, average valid pixels]
|
|
35
|
-
*/
|
|
36
|
-
export type MonthlySnowCoverHistory = {
|
|
37
|
-
year: number
|
|
38
|
-
/**
|
|
39
|
-
* Array of monthly snow cover aggregations ordered chronologically by month.
|
|
40
|
-
* Each tuple contains:
|
|
41
|
-
* - Month (1-12)
|
|
42
|
-
* - Average NDSI snow cover value (0-100) - weighted by valid pixels
|
|
43
|
-
* - Average percentage of valid pixels (0-100) - across all days in month
|
|
44
|
-
*/
|
|
45
|
-
months: [number, number, number][]
|
|
46
|
-
}[]
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Converts daily snow cover measurements to monthly aggregations.
|
|
50
|
-
*
|
|
51
|
-
* Snow cover values are weighted by valid pixel percentages when calculating monthly averages.
|
|
52
|
-
* Input data is assumed to be sorted chronologically.
|
|
53
|
-
*
|
|
54
|
-
* @param snowCoverHistory Daily snow cover history to aggregate
|
|
55
|
-
* @returns Monthly aggregated snow cover history
|
|
56
|
-
*/
|
|
57
|
-
export function getMonthlySnowCover(
|
|
58
|
-
snowCoverHistory: SnowCoverHistory
|
|
59
|
-
): MonthlySnowCoverHistory {
|
|
60
|
-
return snowCoverHistory.map(yearData => {
|
|
61
|
-
const monthlyData = new Map<number, { values: number[], validPixels: number[], count: number }>()
|
|
62
|
-
|
|
63
|
-
// Group daily data by month
|
|
64
|
-
yearData.days.forEach(([dayOfYear, snowCover, validPixelPercent]) => {
|
|
65
|
-
const date = dayOfYearToDate(yearData.year, dayOfYear)
|
|
66
|
-
const month = date.getMonth() + 1 // Convert to 1-12
|
|
67
|
-
|
|
68
|
-
if (!monthlyData.has(month)) {
|
|
69
|
-
monthlyData.set(month, { values: [], validPixels: [], count: 0 })
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const monthData = monthlyData.get(month)!
|
|
73
|
-
monthData.values.push(snowCover * validPixelPercent / 100) // Weight by valid pixels
|
|
74
|
-
monthData.validPixels.push(validPixelPercent)
|
|
75
|
-
monthData.count++
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// Calculate monthly averages
|
|
79
|
-
const months: [number, number, number][] = []
|
|
80
|
-
monthlyData.forEach((data, month) => {
|
|
81
|
-
const totalValidPixels = data.validPixels.reduce((sum, val) => sum + val, 0)
|
|
82
|
-
const weightedSnowCover = data.values.reduce((sum, val) => sum + val, 0)
|
|
83
|
-
const avgValidPixels = totalValidPixels / data.count
|
|
84
|
-
const avgSnowCover = avgValidPixels > 0 ? (weightedSnowCover / totalValidPixels) * 100 : 0
|
|
85
|
-
|
|
86
|
-
months.push([month, Math.round(avgSnowCover * 100) / 100, Math.round(avgValidPixels * 100) / 100])
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
year: yearData.year,
|
|
91
|
-
months
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Converts day of year to a Date object.
|
|
98
|
-
*
|
|
99
|
-
* @param year The year
|
|
100
|
-
* @param dayOfYear Day of year (1-365/366)
|
|
101
|
-
* @returns Date object for the specified day
|
|
102
|
-
*/
|
|
103
|
-
function dayOfYearToDate(year: number, dayOfYear: number): Date {
|
|
104
|
-
const date = new Date(year, 0, 1)
|
|
105
|
-
date.setDate(dayOfYear)
|
|
106
|
-
return date
|
|
107
|
-
}
|
package/src/Source.test.ts
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
getSourceName,
|
|
3
|
-
getSourceURL,
|
|
4
|
-
getStormSkiingChartCSVURL,
|
|
5
|
-
SourceType,
|
|
6
|
-
} from './Source'
|
|
7
|
-
|
|
8
|
-
describe('getSourceURL', () => {
|
|
9
|
-
it('links to an OpenStreetMap element', () => {
|
|
10
|
-
expect(
|
|
11
|
-
getSourceURL({ type: SourceType.OPENSTREETMAP, id: 'relation/5883752' }),
|
|
12
|
-
).toBe('https://www.openstreetmap.org/relation/5883752')
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('links to a Skimap.org ski area', () => {
|
|
16
|
-
expect(getSourceURL({ type: SourceType.SKIMAP_ORG, id: '181' })).toBe(
|
|
17
|
-
'https://www.skimap.org/SkiAreas/view/181',
|
|
18
|
-
)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('links to a cell of the ski pass chart', () => {
|
|
22
|
-
expect(
|
|
23
|
-
getSourceURL({ type: SourceType.STORM_SKIING, id: '677843907!AI64' }),
|
|
24
|
-
).toBe(
|
|
25
|
-
'https://docs.google.com/spreadsheets/d/1G2-l2DVg7-QwroOi7EqRDrJYJ4ICYLcJbLx-nARJdrA/edit?gid=677843907#gid=677843907&range=AI64',
|
|
26
|
-
)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('rejects a ski pass chart id without a sheet', () => {
|
|
30
|
-
expect(() =>
|
|
31
|
-
getSourceURL({ type: SourceType.STORM_SKIING, id: 'AI64' }),
|
|
32
|
-
).toThrow('Malformed stormskiing.com source id')
|
|
33
|
-
})
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
describe('getSourceName', () => {
|
|
37
|
-
it('names every source type', () => {
|
|
38
|
-
for (const type of Object.values(SourceType)) {
|
|
39
|
-
expect(getSourceName(type).length).toBeGreaterThan(0)
|
|
40
|
-
}
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe('getStormSkiingChartCSVURL', () => {
|
|
45
|
-
it('builds the CSV export URL for a sheet', () => {
|
|
46
|
-
expect(getStormSkiingChartCSVURL('677843907')).toBe(
|
|
47
|
-
'https://docs.google.com/spreadsheets/d/1G2-l2DVg7-QwroOi7EqRDrJYJ4ICYLcJbLx-nARJdrA/export?format=csv&gid=677843907',
|
|
48
|
-
)
|
|
49
|
-
})
|
|
50
|
-
})
|
package/src/Source.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { exhaustiveMatchingGuard } from './util/exhaustiveMatchingGuard'
|
|
2
|
-
|
|
3
|
-
export enum SourceType {
|
|
4
|
-
SKIMAP_ORG = 'skimap.org',
|
|
5
|
-
OPENSTREETMAP = 'openstreetmap',
|
|
6
|
-
STORM_SKIING = 'stormskiing.com',
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type Source = {
|
|
10
|
-
type: SourceType
|
|
11
|
-
id: string
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The Storm Skiing Journal's ski pass chart, a Google Sheets document.
|
|
16
|
-
*
|
|
17
|
-
* A `STORM_SKIING` source id references a single cell of that document, in `<gid>!<A1>` form
|
|
18
|
-
* (e.g. `677843907!AI64`), so it can be linked to directly.
|
|
19
|
-
*/
|
|
20
|
-
export const STORM_SKIING_CHART_DOCUMENT_ID =
|
|
21
|
-
'1G2-l2DVg7-QwroOi7EqRDrJYJ4ICYLcJbLx-nARJdrA'
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* URL of the CSV export of one sheet of the ski pass chart. Used to fetch the chart; the ids of
|
|
25
|
-
* the sources derived from it are resolved by `getSourceURL`.
|
|
26
|
-
*/
|
|
27
|
-
export function getStormSkiingChartCSVURL(gid: string): string {
|
|
28
|
-
return (
|
|
29
|
-
`https://docs.google.com/spreadsheets/d/${STORM_SKIING_CHART_DOCUMENT_ID}` +
|
|
30
|
-
`/export?format=csv&gid=${gid}`
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function getSourceURL(source: Source): string {
|
|
35
|
-
switch (source.type) {
|
|
36
|
-
case SourceType.OPENSTREETMAP:
|
|
37
|
-
return 'https://www.openstreetmap.org/' + source.id
|
|
38
|
-
case SourceType.SKIMAP_ORG:
|
|
39
|
-
return 'https://www.skimap.org/SkiAreas/view/' + source.id
|
|
40
|
-
case SourceType.STORM_SKIING: {
|
|
41
|
-
const separatorIndex = source.id.indexOf('!')
|
|
42
|
-
if (separatorIndex === -1) {
|
|
43
|
-
throw new Error(
|
|
44
|
-
`Malformed ${SourceType.STORM_SKIING} source id "${source.id}". Expected <gid>!<A1 reference>.`,
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
const gid = source.id.slice(0, separatorIndex)
|
|
48
|
-
const range = source.id.slice(separatorIndex + 1)
|
|
49
|
-
// The gid appears twice: as a query parameter to select the sheet, and in the fragment,
|
|
50
|
-
// which is the only place Google Sheets accepts a cell range.
|
|
51
|
-
return (
|
|
52
|
-
`https://docs.google.com/spreadsheets/d/${STORM_SKIING_CHART_DOCUMENT_ID}` +
|
|
53
|
-
`/edit?gid=${gid}#gid=${gid}&range=${range}`
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
default:
|
|
57
|
-
return exhaustiveMatchingGuard(source.type)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function getSourceName(type: SourceType): string {
|
|
62
|
-
switch (type) {
|
|
63
|
-
case SourceType.OPENSTREETMAP:
|
|
64
|
-
return 'OpenStreetMap'
|
|
65
|
-
case SourceType.SKIMAP_ORG:
|
|
66
|
-
return 'Skimap.org'
|
|
67
|
-
case SourceType.STORM_SKIING:
|
|
68
|
-
return 'The Storm Skiing Journal'
|
|
69
|
-
default:
|
|
70
|
-
return exhaustiveMatchingGuard(type)
|
|
71
|
-
}
|
|
72
|
-
}
|