This commit is contained in:
vcoppe committed 2026-10-08 18:34:19 +02:00
1 parent 606a0e974a
commit 67a4c090bd
107 files changed
+3474 -3948

No files matched your search

+1 -1570
View File
File diff suppressed because it is too large. Load diff
+1 -3
View File
@@ -10,6 +10,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node --import tsx --test \"src/**/*.test.ts\"",
"generate:example": "node --import tsx src/lib/scripts/example-statistics.ts",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
@@ -71,11 +72,8 @@
"@maplibre/maplibre-gl-geocoder": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-plugin-zoom": "^2.2.0",
"dexie": "^4.0.11",
"file-saver": "^2.0.5",
"gpx": "file:../gpx",
"gpx-rs": "file:../gpx-rs/pkg",
"immer": "^10.1.1",
"jszip": "^3.10.1",
"mapillary-js": "^4.1.2",
"maplibre-gl": "^5.21.1",
+330
View File
@@ -0,0 +1,330 @@
// Generated by src/lib/scripts/example-statistics.ts from example.gpx: do not edit.
export const exampleData = {
global: {
totalDistance: 35.97253714756412,
movingDistance: 35.97253714756412,
totalTime: 11033,
movingTime: 11033,
elevationGain: 860.2000000000002,
elevationLoss: 537.8000000000003,
startTime: 1672527600024,
endTime: 1672538633024,
totalSpeed: 11.737617486742575,
movingSpeed: 11.737617486742575,
atemp: { avg: 17.676470588235293, count: 102 },
bounds: { west: -66.660451, south: 48.745764, east: -66.510044, north: 48.92168 },
},
totalDistance: [
0, 0.04328068848542093, 0.11650720502525283, 0.3351587959345754, 1.0682436029275066,
1.5026156340883394, 1.7821196261503274, 2.106207216285154, 2.2410399811984894,
2.9453552435682093, 3.6025169832366335, 3.9844079748662025, 4.853369564407712,
5.245189634628891, 5.701904241715643, 5.8426474906816495, 6.569912055579026,
6.653689376713522, 6.7837648541758915, 6.977468888973151, 7.12513222708653,
7.250970625751078, 7.327345186465471, 7.523057936506123, 7.870270298566722,
8.331907238547238, 8.49966250663381, 8.712146310893873, 9.145642024727252,
9.591804416173956, 9.867268606811916, 10.354226519938479, 10.4995033343533,
10.768320772932757, 10.945182629048222, 11.179142591075422, 11.44408361293813,
11.958196546084086, 12.320595371530443, 12.674140395065313, 12.985892133977105,
13.620373921093831, 13.907784560535925, 14.347390028106815, 14.710337166798299,
15.20320527974895, 15.87459256056528, 18.06487313488486, 19.45780363453859,
19.576216563691453, 20.04629122790926, 20.11508563990313, 20.57906851024942,
20.967882401756427, 21.174446555588826, 21.44870285726096, 21.70815037486119,
21.853596645545945, 22.18038572464859, 22.673623863657042, 23.255471531923366,
23.804171560172605, 24.12646319419839, 24.583516514019728, 24.96563101163832,
25.28996993026779, 25.6464275900608, 25.83865311967249, 25.924206335752725,
26.46043835304079, 26.66269792060754, 26.868668538885316, 26.951359258371486,
27.47113438147256, 27.75981601401171, 27.946424773943168, 28.10186807045419,
28.737895463790068, 28.87567424451052, 29.35605997508892, 29.60491379421725,
29.786435932558835, 30.26638431743931, 30.60806722475413, 30.78584552536327,
30.913622065558272, 31.059459055705133, 31.303159892950628, 31.570611362268927,
31.794495167895366, 32.24998958790923, 32.37413113102769, 32.67948450835431,
32.985096351049044, 33.56292688668583, 33.93674728522087, 34.064219803714735,
34.54278128265628, 34.723139141379185, 35.335211178826825, 35.78464553975441,
35.97253714756412,
],
movingDistance: [
0, 0.04328068848542093, 0.11650720502525283, 0.3351587959345754, 1.0682436029275066,
1.5026156340883394, 1.7821196261503274, 2.106207216285154, 2.2410399811984894,
2.9453552435682093, 3.6025169832366335, 3.9844079748662025, 4.853369564407712,
5.245189634628891, 5.701904241715643, 5.8426474906816495, 6.569912055579026,
6.653689376713522, 6.7837648541758915, 6.977468888973151, 7.12513222708653,
7.250970625751078, 7.327345186465471, 7.523057936506123, 7.870270298566722,
8.331907238547238, 8.49966250663381, 8.712146310893873, 9.145642024727252,
9.591804416173956, 9.867268606811916, 10.354226519938479, 10.4995033343533,
10.768320772932757, 10.945182629048222, 11.179142591075422, 11.44408361293813,
11.958196546084086, 12.320595371530443, 12.674140395065313, 12.985892133977105,
13.620373921093831, 13.907784560535925, 14.347390028106815, 14.710337166798299,
15.20320527974895, 15.87459256056528, 18.06487313488486, 19.45780363453859,
19.576216563691453, 20.04629122790926, 20.11508563990313, 20.57906851024942,
20.967882401756427, 21.174446555588826, 21.44870285726096, 21.70815037486119,
21.853596645545945, 22.18038572464859, 22.673623863657042, 23.255471531923366,
23.804171560172605, 24.12646319419839, 24.583516514019728, 24.96563101163832,
25.28996993026779, 25.6464275900608, 25.83865311967249, 25.924206335752725,
26.46043835304079, 26.66269792060754, 26.868668538885316, 26.951359258371486,
27.47113438147256, 27.75981601401171, 27.946424773943168, 28.10186807045419,
28.737895463790068, 28.87567424451052, 29.35605997508892, 29.60491379421725,
29.786435932558835, 30.26638431743931, 30.60806722475413, 30.78584552536327,
30.913622065558272, 31.059459055705133, 31.303159892950628, 31.570611362268927,
31.794495167895366, 32.24998958790923, 32.37413113102769, 32.67948450835431,
32.985096351049044, 33.56292688668583, 33.93674728522087, 34.064219803714735,
34.54278128265628, 34.723139141379185, 35.335211178826825, 35.78464553975441,
35.97253714756412,
],
totalTime: [
0, 14000, 46000, 151000, 346000, 424000, 481000, 541000, 567000, 683000, 878000, 942000,
1181000, 1285000, 1384000, 1412000, 1565000, 1588000, 1618000, 1660000, 1693000, 1720000,
1734000, 1774000, 1841000, 1921000, 1952000, 1990000, 2063000, 2137000, 2183000, 2332000,
2375000, 2476000, 2537000, 2628000, 2733000, 2955000, 3078000, 3210000, 3330000, 3757000,
3916000, 4078000, 4146000, 4324000, 4571000, 5400000, 5824000, 6419000, 6578000, 6590000,
6726000, 6847000, 7063000, 7278000, 7578000, 7785000, 7865000, 7950000, 8263000, 8417000,
8482000, 8617000, 8682000, 8723000, 8788000, 8830000, 8853000, 8978000, 9012000, 9050000,
9063000, 9154000, 9211000, 9248000, 9278000, 9403000, 9435000, 9528000, 9571000, 9603000,
9685000, 9736000, 9780000, 9811000, 9843000, 9895000, 9940000, 9995000, 10126000, 10153000,
10216000, 10270000, 10389000, 10475000, 10511000, 10618000, 10653000, 10908000, 11003000,
11033000,
],
movingTime: [
0, 14000, 46000, 151000, 346000, 424000, 481000, 541000, 567000, 683000, 878000, 942000,
1181000, 1285000, 1384000, 1412000, 1565000, 1588000, 1618000, 1660000, 1693000, 1720000,
1734000, 1774000, 1841000, 1921000, 1952000, 1990000, 2063000, 2137000, 2183000, 2332000,
2375000, 2476000, 2537000, 2628000, 2733000, 2955000, 3078000, 3210000, 3330000, 3757000,
3916000, 4078000, 4146000, 4324000, 4571000, 5400000, 5824000, 6419000, 6578000, 6590000,
6726000, 6847000, 7063000, 7278000, 7578000, 7785000, 7865000, 7950000, 8263000, 8417000,
8482000, 8617000, 8682000, 8723000, 8788000, 8830000, 8853000, 8978000, 9012000, 9050000,
9063000, 9154000, 9211000, 9248000, 9278000, 9403000, 9435000, 9528000, 9571000, 9603000,
9685000, 9736000, 9780000, 9811000, 9843000, 9895000, 9940000, 9995000, 10126000, 10153000,
10216000, 10270000, 10389000, 10475000, 10511000, 10618000, 10653000, 10908000, 11003000,
11033000,
],
elevationGain: [
0, 5.2000000000000455, 19.000000000000057, 43.400000000000034, 75.60000000000002,
80.66666666666669, 84.2000000000001, 86.33333333333343, 86.60000000000008,
86.60000000000008, 120.40000000000009, 120.40000000000009, 143.40000000000015,
160.0000000000001, 160.4666666666668, 161.80000000000013, 163.6000000000002,
165.0666666666669, 165.26666666666682, 165.26666666666682, 165.26666666666682,
165.26666666666682, 165.26666666666682, 165.26666666666682, 165.26666666666682,
165.26666666666682, 165.26666666666682, 165.26666666666682, 165.26666666666682,
165.26666666666682, 165.26666666666682, 179.66666666666686, 194.4000000000002,
206.4000000000002, 221.9333333333335, 237.40000000000015, 263.73333333333346,
289.6000000000001, 318.0666666666668, 340.46666666666675, 372.6000000000001,
405.1333333333334, 438.86666666666684, 446.86666666666684, 448.86666666666684,
489.7333333333335, 552.4000000000001, 636.8666666666668, 667.7333333333335,
680.4000000000001, 680.4000000000001, 682.7333333333335, 692.2666666666668,
706.9333333333335, 725.2000000000002, 754.2000000000002, 782.6000000000003,
803.1333333333334, 803.1333333333334, 803.1333333333334, 843.5333333333334,
843.5333333333334, 843.5333333333334, 843.5333333333334, 843.5333333333334,
843.5333333333334, 843.5333333333334, 843.5333333333334, 845.2, 845.2, 845.2, 845.2, 845.2,
845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2, 845.2,
846.9333333333333, 848.2666666666667, 848.4666666666666, 848.4666666666666,
848.4666666666666, 850.4, 854.1333333333332, 856.2666666666668, 856.2666666666668,
856.2666666666668, 856.2666666666668, 856.2666666666668, 858.4000000000001,
860.2000000000002, 860.2000000000002, 860.2000000000002, 860.2000000000002,
860.2000000000002,
],
elevationLoss: [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0.20000000000004547, 0.20000000000004547, 20.40000000000009,
20.40000000000009, 20.40000000000009, 20.40000000000009, 20.40000000000009,
20.40000000000009, 20.40000000000009, 20.40000000000009, 20.866666666666788,
21.333333333333485, 21.800000000000182, 22.26666666666688, 23.133333333333496,
25.26666666666688, 27.933333333333508, 31.06666666666689, 34.46666666666681,
39.46666666666681, 45.400000000000205, 51.26666666666682, 51.26666666666682,
51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682,
51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682,
51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682,
51.26666666666682, 51.26666666666682, 51.26666666666682, 51.26666666666682,
51.26666666666682, 51.26666666666682, 54.73333333333352, 54.73333333333352,
54.73333333333352, 54.73333333333352, 54.73333333333352, 54.73333333333352,
54.73333333333352, 54.73333333333352, 71.33333333333354, 90.93333333333345,
90.93333333333345, 103.26666666666671, 118.93333333333345, 137.93333333333345,
182.53333333333347, 231.86666666666684, 275.6666666666669, 313.5333333333335,
313.5333333333335, 314.5333333333335, 317.6666666666668, 323.80000000000024,
332.40000000000026, 340.1333333333335, 346.5333333333335, 349.00000000000006,
355.8666666666666, 361.7999999999999, 367.59999999999997, 371.13333333333327,
378.59999999999997, 389.06666666666666, 400.3999999999999, 412.2000000000001,
412.2000000000001, 412.2000000000001, 412.2000000000001, 414.0666666666668,
415.2666666666668, 415.2666666666668, 415.2666666666668, 415.2666666666668,
440.0666666666668, 462.2666666666668, 469.6000000000001, 472.2666666666667,
472.2666666666667, 472.2666666666667, 473.40000000000015, 497.93333333333356,
519.4000000000003, 537.8000000000003,
],
slope: [
5.54519829509753, 11.329771405243958, 13.361742112430905, 6.556437280063828,
3.3748571006965706, 1.4008034553191204, 0.8946446834706256, 0.34864437421436856,
0.023833697214010865, 2.4238382250011337, 1.3088844858730833, 0.7354983423695479,
3.1409086334150653, 1.2256423076302692, 0.20085098686760294, 0.437784077436669,
0.5425120546520237, 0.7481781910257762, -0.3088521546718895, -0.4101153511136872,
-0.1462513564675528, -0.4945281465002597, -0.5145407171378057, -0.2946999437560697,
-0.7417945450232967, -1.0804074014330973, -1.2097652068240252, -1.269390092150544,
-1.4096385772574478, -1.6906250826786617, 1.6526278471972953, 3.985861405936538,
6.327061969720709, 6.462045767621801, 6.718241042985547, 7.336125039207492,
7.855681831346254, 6.708409718819214, 6.0060576064991755, 7.635690246436876,
7.376614554438903, 7.961883391751326, 5.7495287376423185, 1.246024238407464,
4.2065153584168264, 7.094663287987479, 5.1718091508901205, 4.191770925552759,
3.162749054180518, -1.155504394077654, -2.486689363250133, 3.1532876038197415,
3.7758117104036595, 3.829499622714933, 9.10942941353019, 14.240107950438869,
12.941665571593896, -0.9317388039630531, -4.414487617055606, 1.6556817957741452,
2.4589851525664814, -2.8014045434450368, -4.413963268792533, -7.960267137482532,
-12.683072647006348, -15.24684513552079, -15.673890080960359, -9.647966381748613,
0.6754743880371754, -0.5145623969089836, -3.135485920405431, -3.879979247223856,
-3.352887180002204, -2.176987189136962, -0.9678293676606319, -1.2863539093324792,
-2.400594266311405, -1.938469929926933, -0.19412308181177693, -1.6181239765102629,
-4.647099739859, -3.3259229591723516, -2.84799279461206, -1.7710658389233644,
0.785456382783462, 0.4385748023866403, 0.25671447794793106, -1.4085821207384774,
-0.6105810335595012, 1.8251983257651685, 1.3111677817452918, -6.658984457921569,
-7.692745586787598, -3.282613638567991, -1.324014882722225, 0.2792778338655941,
1.5840695473562374, 0.1214109154598013, -4.214883885805801, -4.182735032776085,
-5.523076373263165, -10.644434966065772,
],
slopeSegmentSlope: [
7.077037465314024, 7.077037465314024, 7.077037465314024, 7.077037465314024,
0.5753520337401837, 0.5753520337401837, 0.5753520337401837, 0.5753520337401837,
0.5753520337401837, 5.143330470981779, -5.289467529413177, 3.1409086334150653,
3.1409086334150653, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 1.246024238407464, 1.246024238407464,
5.604351892140715, 5.604351892140715, 5.604351892140715, 2.2941711127147597,
2.2941711127147597, 2.2941711127147597, 2.2941711127147597, 2.2941711127147597,
2.2941711127147597, 10.861290836693483, 10.861290836693483, 10.861290836693483,
10.861290836693483, -4.414487617055606, -4.414487617055606, 6.94339810974512,
-3.539036752039, -3.539036752039, -3.539036752039, -13.990509017835178, -13.990509017835178,
-13.990509017835178, -13.990509017835178, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, 0.45298474033593794, 0.45298474033593794,
0.45298474033593794, 0.45298474033593794, 0.45298474033593794, 0.45298474033593794,
0.45298474033593794, 0.45298474033593794, -7.692745586787598, -7.692745586787598,
-0.4142590757867528, -0.4142590757867528, -0.4142590757867528, -0.4142590757867528,
-0.4142590757867528, -5.154482373206842, -5.154482373206842, -5.154482373206842,
-5.154482373206842,
],
slopeSegmentDistance: [
1.0682436029275066, 1.0682436029275066, 1.0682436029275066, 1.0682436029275066,
1.8771116406407027, 1.8771116406407027, 1.8771116406407027, 1.8771116406407027,
1.8771116406407027, 0.6571617396684242, 0.38189099162956897, 1.2607816597626882,
1.2607816597626882, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 0.802552606262374, 0.802552606262374,
3.3545359680865605, 3.3545359680865605, 3.3545359680865605, 2.9030092668715675,
2.9030092668715675, 2.9030092668715675, 2.9030092668715675, 2.9030092668715675,
2.9030092668715675, 0.8857142437895185, 0.8857142437895185, 0.8857142437895185,
0.8857142437895185, 0.8200272181110968, 0.8200272181110968, 0.5818476682663238,
1.3280449820963618, 1.3280449820963618, 1.3280449820963618, 1.255136605652762,
1.255136605652762, 1.255136605652762, 1.255136605652762, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 1.7660639062735584,
1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 1.7660639062735584,
1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 0.610965220021356,
0.610965220021356, 1.738042790330141, 1.738042790330141, 1.738042790330141,
1.738042790330141, 1.738042790330141, 1.2493980061849328, 1.2493980061849328,
1.2493980061849328, 1.2493980061849328,
],
lng: [
-66.658624, -66.658127, -66.658472, -66.660451, -66.650629, -66.646183, -66.646388,
-66.642677, -66.640854, -66.633175, -66.635593, -66.630508, -66.633652, -66.633982,
-66.636621, -66.636833, -66.632505, -66.632642, -66.63365, -66.636288, -66.637602,
-66.639281, -66.640209, -66.641257, -66.645941, -66.648858, -66.647392, -66.647989,
-66.643139, -66.637565, -66.63382, -66.627346, -66.627095, -66.623563, -66.622373,
-66.619597, -66.617774, -66.61598, -66.612289, -66.612662, -66.611191, -66.610542,
-66.611253, -66.614945, -66.615775, -66.613768, -66.608474, -66.608964, -66.610557,
-66.609899, -66.604874, -66.605225, -66.610658, -66.609756, -66.610351, -66.609261,
-66.612153, -66.61214, -66.607951, -66.604558, -66.598406, -66.591258, -66.586899,
-66.580708, -66.575533, -66.571339, -66.567595, -66.564982, -66.56396, -66.558605,
-66.557755, -66.555736, -66.555848, -66.551324, -66.549922, -66.547886, -66.54855,
-66.545275, -66.543694, -66.53717, -66.533767, -66.531437, -66.524936, -66.520542,
-66.518785, -66.517736, -66.517472, -66.518888, -66.516545, -66.5175, -66.514672,
-66.515141, -66.517794, -66.51814, -66.515673, -66.512786, -66.513151, -66.5121, -66.513039,
-66.513625, -66.512188, -66.510044,
],
lat: [
48.745764, 48.745974, 48.746592, 48.748063, 48.749297, 48.751879, 48.754389, 48.755973,
48.756134, 48.759941, 48.765632, 48.764883, 48.772418, 48.775935, 48.779656, 48.780914,
48.7868, 48.787548, 48.788511, 48.788629, 48.789636, 48.789875, 48.789562, 48.787943,
48.787467, 48.791147, 48.792306, 48.794176, 48.79641, 48.798028, 48.798255, 48.797259,
48.798555, 48.799212, 48.800596, 48.801637, 48.803695, 48.808165, 48.810336, 48.813506,
48.816137, 48.821827, 48.824369, 48.827487, 48.830705, 48.834936, 48.839867, 48.859562,
48.872045, 48.873018, 48.875654, 48.876228, 48.878383, 48.881829, 48.883645, 48.886005,
48.887357, 48.888665, 48.88969, 48.893524, 48.896844, 48.89835, 48.897915, 48.89849,
48.898005, 48.898957, 48.901011, 48.900816, 48.901191, 48.897895, 48.896164, 48.894872,
48.894132, 48.890526, 48.888099, 48.887087, 48.885759, 48.88046, 48.879786, 48.87928,
48.879289, 48.879852, 48.879259, 48.880304, 48.879199, 48.880118, 48.881418, 48.883402,
48.885249, 48.887162, 48.890812, 48.891885, 48.894006, 48.896745, 48.901682, 48.904457,
48.905578, 48.909826, 48.911326, 48.916817, 48.920747, 48.92168,
],
ele: [
290.4, 292.8, 303.6, 331.8, 366, 371.2, 376, 376.6, 377.6, 376.8, 410.6, 390.4, 419.8, 430,
430.2, 431.2, 434, 435.6, 435.6, 434.6, 434.2, 434.2, 433.2, 432.8, 431.6, 426.8, 424.8,
422.2, 416.6, 409.8, 404.4, 422.4, 429.6, 448.6, 458.4, 476.2, 495, 537.4, 553.8, 580.4,
604.6, 650.2, 678, 692, 688, 728, 770.6, 876, 920.8, 923.8, 914, 910.4, 930.8, 942.6, 953.6,
986.4, 1029.6, 1038.8, 1025.2, 1002.6, 1043, 1030.4, 1018.6, 996, 951.8, 906.4, 848, 820.4,
821.2, 824.6, 817.4, 811.8, 806.2, 791.6, 788.6, 787, 784.2, 768, 769.2, 766.8, 757.4,
746.8, 735.4, 723.4, 726.2, 725.8, 727.4, 726.8, 720.2, 723.8, 732.6, 731.4, 704, 684.4,
675, 671.8, 676.4, 681.4, 677.2, 648, 632.8, 612.8,
],
timestamps: [
1672527600024, 1672527614024, 1672527646024, 1672527751024, 1672527946024, 1672528024024,
1672528081024, 1672528141024, 1672528167024, 1672528283024, 1672528478024, 1672528542024,
1672528781024, 1672528885024, 1672528984024, 1672529012024, 1672529165024, 1672529188024,
1672529218024, 1672529260024, 1672529293024, 1672529320024, 1672529334024, 1672529374024,
1672529441024, 1672529521024, 1672529552024, 1672529590024, 1672529663024, 1672529737024,
1672529783024, 1672529932024, 1672529975024, 1672530076024, 1672530137024, 1672530228024,
1672530333024, 1672530555024, 1672530678024, 1672530810024, 1672530930024, 1672531357024,
1672531516024, 1672531678024, 1672531746024, 1672531924024, 1672532171024, 1672533000024,
1672533424024, 1672534019024, 1672534178024, 1672534190024, 1672534326024, 1672534447024,
1672534663024, 1672534878024, 1672535178024, 1672535385024, 1672535465024, 1672535550024,
1672535863024, 1672536017024, 1672536082024, 1672536217024, 1672536282024, 1672536323024,
1672536388024, 1672536430024, 1672536453024, 1672536578024, 1672536612024, 1672536650024,
1672536663024, 1672536754024, 1672536811024, 1672536848024, 1672536878024, 1672537003024,
1672537035024, 1672537128024, 1672537171024, 1672537203024, 1672537285024, 1672537336024,
1672537380024, 1672537411024, 1672537443024, 1672537495024, 1672537540024, 1672537595024,
1672537726024, 1672537753024, 1672537816024, 1672537870024, 1672537989024, 1672538075024,
1672538111024, 1672538218024, 1672538253024, 1672538508024, 1672538603024, 1672538633024,
],
speed: [
11.129319896251097, 9.117955175889351, 7.6697896847953, 11.420836774827047,
15.395035228401284, 19.036693952608555, 18.572048682978913, 19.210619513643987,
21.274175339570412, 15.759862403013887, 14.442431786381372, 14.861614825794996,
13.232693805089438, 15.04790560743128, 16.935813242440407, 17.264243811647404,
16.589493123379203, 14.52585047073047, 16.18897561298147, 16.38563389971064,
16.410104206675626, 17.755284238150917, 18.13915405033632, 18.266639285649575,
19.80855433570078, 20.41272026163526, 19.83856029634617, 20.950687073300834,
21.54264747624692, 21.64879746253991, 14.075484992575811, 11.854401141400949,
10.35235632485696, 9.903984326553841, 9.729990429694698, 9.163487459202377,
8.576740789086204, 9.146209654876312, 10.107442573852628, 9.50423946352374,
6.2274966978110875, 5.663502961794799, 8.153451667435336, 12.561692967584985,
12.524125633787333, 9.861928041320306, 9.574353418670329, 10.294940036954442,
5.339387972231342, 2.8097550877114332, 11.34461213077211, 12.95944740827415,
11.945791216622078, 6.360121552587119, 4.016133735072683, 3.7307451444281803,
2.874985478946628, 5.923509614057983, 17.89150294060575, 9.724394236656252,
8.715142842518263, 14.317671160685329, 14.0282091692482, 15.10502071391872,
23.992757532953096, 23.121393229442734, 18.46036898931696, 15.384668992168189,
15.124505676526212, 16.720564185392, 20.41150929222635, 20.376094430396314,
20.854586858789187, 19.665164326383863, 18.202610775470102, 18.378916465566082,
18.382545054508643, 17.743326284094163, 17.803137925406997, 19.303399845178138,
20.658045958555817, 20.88854283859139, 22.239643999271156, 19.684845773960607,
14.666632358598804, 15.635058876677832, 16.694478316815264, 18.97060106834699,
17.688069898010582, 13.14925598013496, 13.206895362508604, 17.179796817802924,
18.798929846810953, 18.383772034644412, 16.711918843992997, 14.792250010689015,
15.256799935436915, 16.704997292901556, 9.837060780048157, 10.918351526145143,
18.354987899634057, 22.54699293716527,
],
atemp: [
18, 18, 18, 19, 22, 22, 22, 21, 20, 20, 23, 23, 19, 19, 18, 18, 17, 17, 18, 18, 18, 19, 19,
19, 19, 19, 18, 18, 17, 17, 16, 16, 16, 17, 18, 17, 17, 18, 18, 18, 19, 22, 23, 23, 23, 22,
19, 18, 20, 20, 18, 18, 17, 19, 20, 21, 22, 22, 21, 19, 18, 16, 16, 16, 16, 16, 15, 15, 15,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 16, 16, 16, 16, 16, 15, 15,
15, 15, 15, 15, 16, 16, 16, 18, 17, 17,
],
};
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><gpx creator="StravaGPX" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2"><metadata><name>Example</name><author><name>gpx.studio</name><link href="https://gpx.studio"/></author><time>2023-08-20T12:42:07.000Z</time></metadata><trk><name>Example</name><type>cycling</type><trkseg><trkpt lat="48.745764" lon="-66.658624"><ele>290.4</ele><time>2022-12-31T23:00:00.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.745974" lon="-66.658127"><ele>292.8</ele><time>2022-12-31T23:00:14.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.746592" lon="-66.658472"><ele>303.6</ele><time>2022-12-31T23:00:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.748063" lon="-66.660451"><ele>331.8</ele><time>2022-12-31T23:02:31.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.749297" lon="-66.650629"><ele>366</ele><time>2022-12-31T23:05:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.751879" lon="-66.646183"><ele>371.2</ele><time>2022-12-31T23:07:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.754389" lon="-66.646388"><ele>376</ele><time>2022-12-31T23:08:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.755973" lon="-66.642677"><ele>376.6</ele><time>2022-12-31T23:09:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>21</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.756134" lon="-66.640854"><ele>377.6</ele><time>2022-12-31T23:09:27.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.759941" lon="-66.633175"><ele>376.8</ele><time>2022-12-31T23:11:23.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.765632" lon="-66.635593"><ele>410.6</ele><time>2022-12-31T23:14:38.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.764883" lon="-66.630508"><ele>390.4</ele><time>2022-12-31T23:15:42.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.772418" lon="-66.633652"><ele>419.8</ele><time>2022-12-31T23:19:41.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.775935" lon="-66.633982"><ele>430</ele><time>2022-12-31T23:21:25.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.779656" lon="-66.636621"><ele>430.2</ele><time>2022-12-31T23:23:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.780914" lon="-66.636833"><ele>431.2</ele><time>2022-12-31T23:23:32.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.7868" lon="-66.632505"><ele>434</ele><time>2022-12-31T23:26:05.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.787548" lon="-66.632642"><ele>435.6</ele><time>2022-12-31T23:26:28.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkLine truncated
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { computeExampleData, engineIsBuilt } from '../scripts/example-statistics';
import { exampleData } from './example-data';
import { exampleStatistics } from './example';
describe('exampleStatistics', () => {
it('has the statistics of the whole example for the whole range', () => {
const whole = exampleStatistics.slice(0, exampleStatistics.length - 1);
const global = exampleStatistics.global;
assert.ok(whole);
for (const key of [
'totalDistance',
'movingDistance',
'totalTime',
'movingTime',
'elevationGain',
'elevationLoss',
'startTime',
'endTime',
'totalSpeed',
'movingSpeed',
] as const) {
assert.ok(Math.abs((whole[key] as number) - (global[key] as number)) < 1e-6, key);
}
});
it('has no speed for a range that does not last', () => {
const point = exampleStatistics.slice(20, 20);
assert.equal(point?.totalTime, 0);
assert.equal(point?.totalSpeed, undefined);
assert.equal(point?.movingSpeed, undefined);
});
it('has nothing for ranges that are not in the example', () => {
assert.equal(exampleStatistics.slice(5, 500), undefined);
assert.equal(exampleStatistics.slice(-1, 3), undefined);
assert.equal(exampleStatistics.slice(10, 5), undefined);
});
it(
'is what the engine computes (npm run generate:example if not)',
{ skip: !engineIsBuilt() && 'the engine is not built' },
async () => {
assert.deepEqual(JSON.parse(JSON.stringify(await computeExampleData())), exampleData);
}
);
});
+63 -4
View File
@@ -1,4 +1,63 @@
import { parseGPX } from 'gpx';
export const exampleGPXFile = parseGPX(
`<?xml version="1.0" encoding="UTF-8"?><gpx creator="StravaGPX" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd" version="1.1" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2"><metadata><name>Example</name><author><name>gpx.studio</name><link href="https://gpx.studio"/></author><time>2023-08-20T12:42:07.000Z</time></metadata><trk><name>Example</name><type>cycling</type><trkseg><trkpt lat="48.745764" lon="-66.658624"><ele>290.4</ele><time>2022-12-31T23:00:00.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.745974" lon="-66.658127"><ele>292.8</ele><time>2022-12-31T23:00:14.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.746592" lon="-66.658472"><ele>303.6</ele><time>2022-12-31T23:00:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.748063" lon="-66.660451"><ele>331.8</ele><time>2022-12-31T23:02:31.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.749297" lon="-66.650629"><ele>366</ele><time>2022-12-31T23:05:46.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.751879" lon="-66.646183"><ele>371.2</ele><time>2022-12-31T23:07:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.754389" lon="-66.646388"><ele>376</ele><time>2022-12-31T23:08:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>22</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.755973" lon="-66.642677"><ele>376.6</ele><time>2022-12-31T23:09:01.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>21</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.756134" lon="-66.640854"><ele>377.6</ele><time>2022-12-31T23:09:27.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.759941" lon="-66.633175"><ele>376.8</ele><time>2022-12-31T23:11:23.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>20</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.765632" lon="-66.635593"><ele>410.6</ele><time>2022-12-31T23:14:38.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.764883" lon="-66.630508"><ele>390.4</ele><time>2022-12-31T23:15:42.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>23</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.772418" lon="-66.633652"><ele>419.8</ele><time>2022-12-31T23:19:41.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.775935" lon="-66.633982"><ele>430</ele><time>2022-12-31T23:21:25.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.779656" lon="-66.636621"><ele>430.2</ele><time>2022-12-31T23:23:04.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.780914" lon="-66.636833"><ele>431.2</ele><time>2022-12-31T23:23:32.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>18</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.7868" lon="-66.632505"><ele>434</ele><time>2022-12-31T23:26:05.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt><trkpt lat="48.787548" lon="-66.632642"><ele>435.6</ele><time>2022-12-31T23:26:28.024Z</time><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions> Line truncated
);
/**
* The statistics of an example file (a ride of 102 trackpoints with timestamps and temperatures),
* for the pages that show the elevation profile and the statistics without a file: the home page
* and the documentation. They are the ones that the engine computes for `example.gpx`, kept as
* they are (see `example-data.ts`) so that the example does not need the engine.
*/
import type { GlobalStatistics, SelectionStatistics } from '$lib/engine';
import { exampleData as data } from './example-data';
const length = data.lng.length;
// the values that are cumulative over the selection: those of a range are the difference of its ends
const cumulative = {
totalDistance: data.totalDistance,
movingDistance: data.movingDistance,
totalTime: data.totalTime,
movingTime: data.movingTime,
elevationGain: data.elevationGain,
elevationLoss: data.elevationLoss,
};
/** The statistics of the trackpoints from `start` to `end` (both included), as the engine does. */
function slice(start: number, end: number): GlobalStatistics | undefined {
if (!(start >= 0 && start <= end && end < length)) {
return undefined;
}
const delta = (values: number[]) => values[end] - values[start];
// the times are in ms, in seconds in the statistics; a range cannot last a negative time
const totalTime = Math.max(0, delta(cumulative.totalTime));
const movingTime = Math.max(0, delta(cumulative.movingTime));
const totalDistance = delta(cumulative.totalDistance);
const movingDistance = delta(cumulative.movingDistance);
const hours = (ms: number) => ms / 3_600_000;
return {
...data.global,
totalDistance,
movingDistance,
totalTime: totalTime / 1000,
movingTime: movingTime / 1000,
elevationGain: delta(cumulative.elevationGain),
elevationLoss: delta(cumulative.elevationLoss),
startTime: data.timestamps[start],
endTime: data.timestamps[end],
totalSpeed: totalTime > 0 ? totalDistance / hours(totalTime) : undefined,
movingSpeed: movingTime > 0 ? movingDistance / hours(movingTime) : undefined,
};
}
export const exampleStatistics: SelectionStatistics = {
global: data.global,
length,
totalDistance: Float64Array.from(data.totalDistance),
slope: Float64Array.from(data.slope),
lng: Float64Array.from(data.lng),
lat: Float64Array.from(data.lat),
ele: Float64Array.from(data.ele),
timestamps: BigInt64Array.from(data.timestamps, (time) => BigInt(time)),
speed: Float64Array.from(data.speed),
atemp: Float64Array.from(data.atemp),
slopeSegmentSlope: Float64Array.from(data.slopeSegmentSlope),
slopeSegmentDistance: Float64Array.from(data.slopeSegmentDistance),
slice,
};
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import { cachedFetch, pruneCache } from './cached-fetch';
const keyOf = (request: RequestInfo | URL) =>
request instanceof Request ? request.url : String(request);
/** A Cache API in memory: only what `cachedFetch` uses. */
class FakeCache {
entries = new Map<string, Response>();
failToPut = false;
async match(request: RequestInfo | URL) {
return this.entries.get(keyOf(request))?.clone();
}
async put(request: RequestInfo | URL, response: Response) {
if (this.failToPut) {
throw new Error('quota');
}
this.entries.set(keyOf(request), response);
}
async delete(request: RequestInfo | URL) {
return this.entries.delete(keyOf(request));
}
async keys() {
return [...this.entries.keys()].map((url) => new Request(url));
}
}
const global = globalThis as unknown as { caches?: unknown; fetch: unknown };
const original = { caches: global.caches, fetch: global.fetch };
describe('cachedFetch', () => {
let cache: FakeCache;
let fetched: string[];
let names: string[];
let options: { name: string; maxAge: number };
let counter = 0;
beforeEach(() => {
cache = new FakeCache();
fetched = [];
names = [];
// the caches are cleaned once per session and name: a new name for each test
options = { name: `test-${counter++}`, maxAge: 60_000 };
global.caches = {
open: async (name: string) => {
names.push(name);
return cache;
},
};
global.fetch = async (url: string) => {
fetched.push(url);
return new Response(JSON.stringify({ url }), {
status: url.includes('missing') ? 404 : 200,
});
};
});
afterEach(() => {
global.caches = original.caches;
global.fetch = original.fetch;
mock.timers.reset();
});
it('asks the network once, then uses what it kept', async () => {
const first = await cachedFetch('https://example.com/a', options);
assert.deepEqual(await first.json(), { url: 'https://example.com/a' });
const second = await cachedFetch('https://example.com/a', options);
assert.deepEqual(await second.json(), { url: 'https://example.com/a' });
assert.deepEqual(fetched, ['https://example.com/a']);
assert.ok(names.every((name) => name === options.name));
await cachedFetch('https://example.com/b', options);
assert.equal(fetched.length, 2);
});
it('asks the network again for what is too old, and keeps the new response', async () => {
mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
await cachedFetch('https://example.com/a', options);
mock.timers.setTime(1_000_000 + options.maxAge - 1);
await cachedFetch('https://example.com/a', options);
assert.equal(fetched.length, 1);
mock.timers.setTime(1_000_000 + options.maxAge);
await cachedFetch('https://example.com/a', options);
assert.equal(fetched.length, 2);
// the new one is the one that is kept: it is not too old a minute later
mock.timers.setTime(1_000_000 + options.maxAge + 30_000);
await cachedFetch('https://example.com/a', options);
assert.equal(fetched.length, 2);
});
it('does not keep what failed', async () => {
const response = await cachedFetch('https://example.com/missing', options);
assert.equal(response.status, 404);
assert.equal(cache.entries.size, 0);
await cachedFetch('https://example.com/missing', options);
assert.equal(fetched.length, 2);
});
it('answers even if the response cannot be kept', async () => {
cache.failToPut = true;
const response = await cachedFetch('https://example.com/a', options);
assert.deepEqual(await response.json(), { url: 'https://example.com/a' });
// let the failed put settle: it must not throw
await new Promise((resolve) => setTimeout(resolve, 0));
});
it('works without the Cache API', async () => {
global.caches = undefined;
const response = await cachedFetch('https://example.com/a', options);
assert.deepEqual(await response.json(), { url: 'https://example.com/a' });
await cachedFetch('https://example.com/a', options);
assert.equal(fetched.length, 2);
});
it('works when the Cache API cannot be opened', async () => {
global.caches = {
open: async () => {
throw new Error('blocked');
},
};
const response = await cachedFetch('https://example.com/a', options);
assert.equal(response.status, 200);
});
it('cleans what is too old', async () => {
mock.timers.enable({ apis: ['Date'], now: 5_000_000 });
await cachedFetch('https://example.com/old', options);
mock.timers.setTime(5_000_000 + options.maxAge / 2);
await cachedFetch('https://example.com/recent', options);
// a response that was kept by something else is not trusted
await cache.put('https://example.com/foreign', new Response('{}'));
mock.timers.setTime(5_000_000 + options.maxAge + 1);
await pruneCache(options);
assert.deepEqual([...cache.entries.keys()], ['https://example.com/recent']);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* `fetch` that keeps the responses in the Cache API, which the browser does whatever the headers
* of the server are: the HTTP cache needs them to keep a response (and `cache: 'force-cache'`
* can only use what it kept). A kept response is used until it is older than `maxAge`.
*
* It works without the Cache API (insecure pages, private windows, blocked storage): the
* responses are then just not kept.
*/
export type CachedFetchOptions = {
/** Name of the cache the responses are kept in. */
name: string;
/** How long a kept response is used, in milliseconds. */
maxAge: number;
};
/** When a response was kept (ms since the epoch): the server's date can be wrong or missing. */
const KEPT_AT = 'x-kept-at';
/** The caches that were cleaned of their old responses in this session. */
const pruned = new Set<string>();
async function openCache(name: string): Promise<Cache | undefined> {
try {
return typeof caches === 'undefined' ? undefined : await caches.open(name);
} catch {
return undefined;
}
}
function age(response: Response): number {
const keptAt = Number(response.headers.get(KEPT_AT));
return keptAt > 0 ? Date.now() - keptAt : Infinity;
}
/** Deletes the responses that are too old to be used. */
export async function pruneCache({ name, maxAge }: CachedFetchOptions) {
const cache = await openCache(name);
if (!cache) {
return;
}
try {
for (const request of await cache.keys()) {
const response = await cache.match(request);
if (!response || !(age(response) < maxAge)) {
await cache.delete(request);
}
}
} catch {
// what is left is cleaned another time
}
}
/**
* The response for `url`: the kept one if there is one that is not too old, otherwise the one of
* the network, which is kept if it is successful.
*/
export async function cachedFetch(url: string, options: CachedFetchOptions): Promise<Response> {
const cache = await openCache(options.name);
if (cache && !pruned.has(options.name)) {
pruned.add(options.name);
void pruneCache(options);
}
try {
const kept = await cache?.match(url);
if (kept && age(kept) < options.maxAge) {
return kept;
}
} catch {
// as if there was nothing
}
const response = await fetch(url);
if (!response.ok || !cache) {
return response;
}
const headers = new Headers(response.headers);
headers.set(KEPT_AT, String(Date.now()));
const stamped = new Response(await response.arrayBuffer(), {
status: response.status,
statusText: response.statusText,
headers,
});
// best effort: the storage can be full
cache.put(url, stamped.clone()).catch(() => undefined);
return stamped;
}
@@ -18,7 +18,7 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { Coordinates } from 'gpx';
import type { Coordinates } from '$lib/geo';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings';
@@ -21,7 +21,7 @@ import Chart, {
type TooltipItem,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from 'gpx';
import type { Coordinates } from '$lib/geo';
import {
engine,
NO_TIME,
@@ -456,7 +456,7 @@ export class ElevationProfile {
highway: highways[index],
sacScale: sacScales[index],
mtbScale: mtbScales[index],
coordinates: { lat: data.lat[index], lon: data.lng[index] },
coordinates: { lat: data.lat[index], lng: data.lng[index] },
index,
});
if (data.speed && (global.totalTime ?? 0) > 0) {
@@ -18,8 +18,7 @@
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine';
import { hoveredPoint } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
let {
useHash = true,
@@ -39,7 +38,6 @@
distanceUnits,
velocityUnits,
temperatureUnits,
fileOrder,
distanceMarkers,
directionMarkers,
} = settings;
@@ -63,10 +63,10 @@
let manualCamera = $state(false);
let zoom = $state('0');
let lat = $state('0');
let lon = $state('0');
let lng = $state('0');
let bearing = $state('0');
let pitch = $state('0');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lng}/${bearing}/${pitch}` : '');
$effect(() => {
if (options.elevation.show || options.elevation.height) {
@@ -78,7 +78,7 @@
if ($map) {
let center = $map.getCenter();
lat = center.lat.toFixed(4);
lon = center.lng.toFixed(4);
lng = center.lng.toFixed(4);
zoom = $map.getZoom().toFixed(2);
bearing = $map.getBearing().toFixed(1);
pitch = $map.getPitch().toFixed(0);
@@ -293,7 +293,7 @@
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.longitude')}</span>
<span>{lon}</span>
<span>{lng}</span>
</Label>
<Label class="flex flex-col gap-1">
<span>{i18n._('embedding.zoom')}</span>
@@ -3,8 +3,7 @@
import FileListNodeStore from './FileListNodeStore.svelte';
import FileListNode from './FileListNode.svelte';
import FileListNodeContent from './FileListNodeContent.svelte';
import { ListLevel } from './file-list';
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
import { engine, type FileState } from '$lib/engine';
import type { FileTreeNode } from '$lib/selection-helpers';
@@ -26,14 +25,14 @@
let container: HTMLElement;
let sortableLevel: ListLevel =
node === null
? ListLevel.FILE
? 'file'
: node.type === 'file'
? waypointRoot
? ListLevel.WAYPOINTS
: ListLevel.TRACK
? 'waypoints'
: 'track'
: node.type === 'waypoints'
? ListLevel.WAYPOINT
: ListLevel.SEGMENT;
? 'waypoint'
: 'segment';
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
@@ -18,7 +18,6 @@
Scissors,
FileStack,
} from '@lucide/svelte';
import { ListLevel } from './file-list';
import { getContext } from 'svelte';
import { i18n } from '$lib/i18n.svelte';
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
@@ -56,14 +55,7 @@
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding');
const levels = {
file: ListLevel.FILE,
track: ListLevel.TRACK,
segment: ListLevel.SEGMENT,
waypoints: ListLevel.WAYPOINTS,
waypoint: ListLevel.WAYPOINT,
};
let level = $derived(levels[node.type]);
let level = $derived(node.type);
let singleSelection = $derived(selectionSize($selection) === 1);
@@ -128,7 +120,7 @@
<MetadataDialog bind:open={openEditMetadata} {fileState} {node} />
<StyleDialog bind:open={openEditStyle} {fileState} {node} />
{/if}
{#if level === ListLevel.FILE || level === ListLevel.TRACK}
{#if level === 'file' || level === 'track'}
<div
class="absolute {orientation === 'vertical'
? 'top-0 bottom-0 right-0 w-1'
@@ -178,9 +170,9 @@
}
}}
>
{#if level === ListLevel.SEGMENT}
{#if level === 'segment'}
<Waypoints size="16" class="mx-1 shrink-0" />
{:else if level === ListLevel.WAYPOINT}
{:else if level === 'waypoint'}
{#if symbolKey && symbols[symbolKey].icon}
{@const SymbolIcon = symbols[symbolKey].icon}
<SymbolIcon size="16" class="mx-1 shrink-0" />
@@ -250,7 +242,7 @@
<ContextMenu.Separator />
{/if}
{/if}
{#if level !== ListLevel.WAYPOINTS}
{#if level !== 'waypoints'}
<ContextMenu.Item onclick={() => engine.selectAll()}>
<FileStack size="16" />
{i18n._('menu.select_all')}
@@ -1,300 +0,0 @@
export enum ListLevel {
ROOT,
FILE,
TRACK,
SEGMENT,
WAYPOINTS,
WAYPOINT,
}
export abstract class ListItem {
[x: string]: any;
level: ListLevel;
constructor(level: ListLevel) {
this.level = level;
}
abstract getId(): string | number;
abstract getFullId(): string;
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
abstract getFileId(): string;
abstract getParent(): ListItem;
abstract extend(id: string | number): ListItem;
}
export class ListRootItem extends ListItem {
constructor() {
super(ListLevel.ROOT);
}
getId(): string {
return 'root';
}
getFullId(): string {
return 'root';
}
getIdAtLevel(level: ListLevel): string | number | undefined {
return undefined;
}
getFileId(): string {
return '';
}
getParent(): ListItem {
return this;
}
extend(id: string): ListFileItem {
return new ListFileItem(id);
}
}
export class ListFileItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.FILE);
this.fileId = fileId;
}
getId(): string {
return this.fileId;
}
getFullId(): string {
return this.fileId;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListRootItem();
}
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
if (id === 'waypoints') {
return new ListWaypointsItem(this.fileId);
} else {
return new ListTrackItem(this.fileId, id);
}
}
}
export class ListTrackItem extends ListItem {
fileId: string;
trackIndex: number;
constructor(fileId: string, trackIndex: number) {
super(ListLevel.TRACK);
this.fileId = fileId;
this.trackIndex = trackIndex;
}
getId(): number {
return this.trackIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListTrackSegmentItem {
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
}
}
export class ListTrackSegmentItem extends ListItem {
fileId: string;
trackIndex: number;
segmentIndex: number;
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
super(ListLevel.SEGMENT);
this.fileId = fileId;
this.trackIndex = trackIndex;
this.segmentIndex = segmentIndex;
}
getId(): number {
return this.segmentIndex;
}
getFullId(): string {
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return this.trackIndex;
case ListLevel.TRACK:
return this.segmentIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getTrackIndex(): number {
return this.trackIndex;
}
getSegmentIndex(): number {
return this.segmentIndex;
}
getParent(): ListItem {
return new ListTrackItem(this.fileId, this.trackIndex);
}
extend(): ListTrackSegmentItem {
return this;
}
}
export class ListWaypointsItem extends ListItem {
fileId: string;
constructor(fileId: string) {
super(ListLevel.WAYPOINTS);
this.fileId = fileId;
}
getId(): string {
return 'waypoints';
}
getFullId(): string {
return `${this.fileId}-waypoints`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getParent(): ListItem {
return new ListFileItem(this.fileId);
}
extend(id: number): ListWaypointItem {
return new ListWaypointItem(this.fileId, id);
}
}
export class ListWaypointItem extends ListItem {
fileId: string;
waypointIndex: number;
constructor(fileId: string, waypointIndex: number) {
super(ListLevel.WAYPOINT);
this.fileId = fileId;
this.waypointIndex = waypointIndex;
}
getId(): number {
return this.waypointIndex;
}
getFullId(): string {
return `${this.fileId}-waypoint-${this.waypointIndex}`;
}
getIdAtLevel(level: ListLevel): string | number | undefined {
switch (level) {
case ListLevel.ROOT:
return this.fileId;
case ListLevel.FILE:
return 'waypoints';
case ListLevel.WAYPOINTS:
return this.waypointIndex;
default:
return undefined;
}
}
getFileId(): string {
return this.fileId;
}
getWaypointIndex(): number {
return this.waypointIndex;
}
getParent(): ListItem {
return new ListWaypointsItem(this.fileId);
}
extend(): ListWaypointItem {
return this;
}
}
export function sortItems(items: ListItem[], reverse: boolean = false) {
items.sort((a, b) => {
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
return a.getTrackIndex() - b.getTrackIndex();
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
return a.getSegmentIndex() - b.getSegmentIndex();
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
return a.getWaypointIndex() - b.getWaypointIndex();
}
return a.level - b.level;
});
if (reverse) {
items.reverse();
}
}
@@ -1,18 +1,19 @@
import { isMac } from '$lib/utils';
import Sortable, { type Direction } from 'sortablejs/Sortable';
import { ListLevel } from './file-list';
import { get, writable } from 'svelte/store';
import { tick } from 'svelte';
import { engine, type MoveTarget, type Selection } from '$lib/engine';
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.ROOT]: [],
[ListLevel.FILE]: [ListLevel.FILE],
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
file: ['file'],
track: ['file', 'track'],
segment: ['file', 'track', 'segment'],
waypoints: ['waypoints'],
waypoint: ['waypoints', 'waypoint'],
};
export const dragging = writable<ListLevel | null>(null);
@@ -84,11 +85,11 @@ export class SortableFileList {
childNode(id: string): FileTreeNode | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'file', fileId: id };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackId: id } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
@@ -97,9 +98,9 @@ export class SortableFileList {
segmentId: id,
}
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointId: id }
: undefined;
@@ -110,11 +111,11 @@ export class SortableFileList {
private selectionOf(ids: string[]): Selection | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'file', fileIds: ids };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? {
type: 'segment',
@@ -123,9 +124,9 @@ export class SortableFileList {
segmentIds: ids,
}
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent
? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids }
: undefined;
@@ -136,20 +137,20 @@ export class SortableFileList {
private targetAt(index: number): MoveTarget | undefined {
const parent = this._parent;
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
return { type: 'files', index };
case ListLevel.TRACK:
case 'track':
return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined;
case ListLevel.SEGMENT:
case 'segment':
return parent?.type === 'track'
? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index }
: undefined;
case ListLevel.WAYPOINTS:
case 'waypoints':
// the list holds the node standing for the waypoints: they go after the others
return parent
? { type: 'waypoints', fileId: parent.fileId, index: Infinity }
: undefined;
case ListLevel.WAYPOINT:
case 'waypoint':
return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined;
}
}
@@ -161,7 +162,7 @@ export class SortableFileList {
return;
}
if (from === to && this._sortableLevel === ListLevel.FILE) {
if (from === to && this._sortableLevel === 'file') {
// the order of the files is the one of the list
this.updateToFileOrder();
return;
@@ -169,7 +170,7 @@ export class SortableFileList {
// The event is triggered on the source and on the destination list: handle it once.
// Nothing to move in the list of the single node of the waypoints.
if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) {
if (to !== this || (from === to && this._sortableLevel === 'waypoints')) {
return;
}
@@ -245,19 +246,19 @@ export class SortableFileList {
engine.select([]);
} else {
switch (this._sortableLevel) {
case ListLevel.FILE:
case 'file':
engine.select(selected);
break;
case ListLevel.TRACK:
case 'track':
engine.selectTracks(parent!.fileId, selected);
break;
case ListLevel.SEGMENT:
case 'segment':
engine.selectSegments(parent!.fileId, (parent as any).trackId, selected);
break;
case ListLevel.WAYPOINTS:
case 'waypoints':
engine.selectWaypointGroup(parent!.fileId);
break;
case ListLevel.WAYPOINT:
case 'waypoint':
engine.selectWaypoints(parent!.fileId, selected);
break;
}
@@ -266,7 +267,7 @@ export class SortableFileList {
}
updateFromFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
@@ -282,7 +283,7 @@ export class SortableFileList {
}
updateToFileOrder() {
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
if (!this._sortable || this._sortableLevel !== 'file') {
return;
}
@@ -304,7 +305,7 @@ export class SortableFileList {
if (element instanceof HTMLElement) {
let attr = element.getAttribute('data-id');
if (attr) {
if (this._sortableLevel === ListLevel.FILE && !files.has(attr)) {
if (this._sortableLevel === 'file' && !files.has(attr)) {
element.remove();
} else {
this._elements[attr] = element;
@@ -1,5 +1,4 @@
import { get, type Readable } from 'svelte/store';
import type { GPXFile } from 'gpx';
import maplibregl, {
type GeoJSONSource,
type FilterSpecification,
@@ -13,7 +12,6 @@ import { getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { fileStateCollection } from '$lib/logic/file-state';
import { engine, type FileState, type Selection } from '$lib/engine';
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
@@ -615,7 +613,7 @@ export class GPXLayer {
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
lng: e.lngLat.lng,
},
]).then(async (ele) => {
if (this.draggedWaypointIndex === null) {
@@ -1,12 +1,11 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { hoveredPoint } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/all-hidden';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
@@ -93,7 +92,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
coordinates: [hovered.lng, hovered.lat],
},
properties: {
icon: 'hover-marker',
@@ -33,7 +33,7 @@
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
const [ele] = await getElevation([{ lat: poi.item.lat, lon: poi.item.lng }]);
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
await engine.newWaypoint({
lng: poi.item.lng,
lat: poi.item.lat,
@@ -1,39 +1,38 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { overpassQueryData } from '$lib/assets/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import { cachedFetch } from '$lib/cached-fetch';
const { currentOverpassQueries } = settings;
/** The responses of Overpass are kept in the browser for a week. */
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
const mercator = new SphericalMercator({
size: 256,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
});
export class OverpassLayer {
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: maplibregl.Map;
layerEventManager: MapLayerEventManager;
popup: MapPopup;
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
// What was queried this session: the queries that were made for each tile. The responses are
// kept longer, see `cachedFetch`.
queriedTiles = new Map<string, Set<string>>();
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -54,7 +53,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -77,7 +76,7 @@ export class OverpassLayer {
update() {
this.loadIcons();
const fullData = get(data);
const fullData = get(this.data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@@ -158,7 +157,6 @@ export class OverpassLayer {
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
@@ -166,66 +164,35 @@ export class OverpassLayer {
continue;
}
db.overpasstiles
.where('[x+y]')
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
const queried = this.queriedTiles.get(`${x},${y}`);
const missingQueries = queries.filter((query) => !queried?.has(query));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
}
}
}
queryTile(x: number, y: number, queries: string[]) {
if (this.currentQueries.size > 5) {
return;
}
this.currentQueries.add(`${x},${y}`);
const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then(
(response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
},
() => this.currentQueries.delete(`${x},${y}`)
)
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
return;
}
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
pois.push({
query,
id: element.id,
poi: {
this.data.update((updatedData) => {
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
updatedData.features.push({
type: 'Feature',
geometry: {
type: 'Point',
@@ -242,17 +209,17 @@ export class OverpassLayer {
tags: element.tags,
type: element.type,
},
},
});
});
}
}
}
}
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
return updatedData;
});
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
queries.forEach((query) => queried.add(query));
this.queriedTiles.set(`${x},${y}`, queried);
this.currentQueries.delete(`${x},${y}`);
}
@@ -1,4 +1,5 @@
import { fileStateCollection } from '$lib/logic/file-state';
import { engine } from '$lib/engine';
import { get } from 'svelte/store';
import maplibregl from 'maplibre-gl';
type MapLayerMouseEventListener = (e: maplibregl.MapLayerMouseEvent) => void;
@@ -246,17 +247,23 @@ export class MapLayerEventManager {
layerIds: string[],
bounds: maplibregl.LngLatBounds
): string[] {
const files = get(engine.files);
let result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
// the tracks of a file are all within its bounds
const file = files.get(fileId);
const box = file && get(file).statistics.bounds;
return box
? bounds.getWest() <= box.east &&
bounds.getEast() >= box.west &&
bounds.getSouth() <= box.north &&
bounds.getNorth() >= box.south
: true;
}
// the waypoints have no bounds of their own
return true;
});
return result;
}
@@ -17,7 +17,7 @@
async function addElevation() {
const stats = get(statistics);
const points = Array.from(stats.lng, (lon, i) => ({ lon, lat: stats.lat[i] }));
const points = Array.from(stats.lng, (lng, i) => ({ lng, lat: stats.lat[i] }));
const ele = await getElevation(points);
// the selection or the files may have changed while the elevation was loading
@@ -1,4 +1,4 @@
import { distance, type Coordinates } from 'gpx';
import { distance, type Coordinates } from '$lib/geo';
import { get, writable } from 'svelte/store';
import maplibregl, {
type MapMouseEvent,
@@ -50,17 +50,11 @@ type Anchor = {
type AnchorProperties = { anchorIndex: number; minZoom: number };
type AnchorFeature = GeoJSON.Feature<GeoJSON.Point, AnchorProperties>;
type Position = { lng: number; lat: number };
/** Whether the tool has to show controls for a selection: it covers segments, or can get some. */
function isRoutable(selection: Selection) {
return selection.type === 'file' || selection.type === 'track' || selection.type === 'segment';
}
function toCoordinates({ lng, lat }: Position): Coordinates {
return { lat, lon: lng };
}
/**
* The anchors of the selection of the engine, shown on the map where the user can drag them to
* reroute the segments, or click on the segments to add some. Every interaction is turned into a
@@ -103,7 +97,7 @@ export class RoutingControls {
* Where the pointer hovers a segment of the selection: an anchor that can be dragged or
* clicked to be added.
*/
temporaryAnchor: (Position & { segment: number }) | null = null;
temporaryAnchor: (Coordinates & { segment: number }) | null = null;
showTemporaryAnchorBinded: (e: MapLayerMouseEvent) => void =
this.showTemporaryAnchor.bind(this);
updateTemporaryAnchorBinded: (e: MapMouseEvent) => void = this.updateTemporaryAnchor.bind(this);
@@ -328,11 +322,11 @@ export class RoutingControls {
this.popup.remove();
}
position(index: number): Position {
position(index: number): Coordinates {
return { lng: this.statistics!.lng[index], lat: this.statistics!.lat[index] };
}
async moveAnchor(anchor: Anchor, coordinates: Position) {
async moveAnchor(anchor: Anchor, coordinates: Coordinates) {
// Move the anchor and update the route from and to the neighbouring anchors
const initialAnchor = anchor;
const initialCoordinates = { lng: anchor.lng, lat: anchor.lat };
@@ -349,7 +343,7 @@ export class RoutingControls {
const [previousAnchor, nextAnchor] = this.getNeighbouringAnchors(anchor);
const chain: Anchor[] = [];
const targets: Position[] = [];
const targets: Coordinates[] = [];
if (previousAnchor) {
chain.push(previousAnchor);
targets.push(this.position(previousAnchor.index));
@@ -457,7 +451,7 @@ export class RoutingControls {
const first = this.position(start);
const last = this.position(end - 1);
// the end of the segment has to be close to its start, up to a kilometer
return distance(toCoordinates(first), toCoordinates(last)) <= 1000;
return distance(first, last) <= 1000;
}
async appendAnchor(e: maplibregl.MapMouseEvent) {
@@ -480,7 +474,7 @@ export class RoutingControls {
this.appendAnchorWithCoordinates({ lng: e.lngLat.lng, lat: e.lngLat.lat });
}
async appendAnchorWithCoordinates(coordinates: Position) {
async appendAnchorWithCoordinates(coordinates: Coordinates) {
// Add a new anchor to the end of the last segment
const statistics = this.statistics;
const revision = this.anchorData?.revision;
@@ -546,7 +540,7 @@ export class RoutingControls {
* single segment) or where they were moved to, and replaces what is between the first and
* the last anchor by the route.
*/
async routeBetweenAnchors(anchors: Anchor[], targets: Position[]): Promise<boolean> {
async routeBetweenAnchors(anchors: Anchor[], targets: Coordinates[]): Promise<boolean> {
const revision = this.anchorData?.revision;
if (revision === undefined) {
return false;
@@ -565,7 +559,7 @@ export class RoutingControls {
let response;
try {
response = await route(targets.map(toCoordinates));
response = await route(targets);
} catch (e: any) {
toast.error(i18n._(e.message, e.message));
return false;
@@ -581,9 +575,9 @@ export class RoutingControls {
const keepFirst =
first.index !== segmentStart &&
(first.index !== lastIndex ||
distance(toCoordinates(targets[0]), {
distance(targets[0], {
lat: response.lat[0],
lon: response.lng[0],
lng: response.lng[0],
}) > 1);
const keepLast = last.index !== lastIndex;
@@ -843,7 +837,7 @@ export class RoutingControls {
return false;
}
moveAnchorFeature(anchor: Anchor, coordinates: Position) {
moveAnchorFeature(anchor: Anchor, coordinates: Coordinates) {
const source = get(map)?.getSource(
this.layers.get(anchor.id === this.anchors.length ? 0 : anchor.zoom)?.id ?? ''
) as GeoJSONSource | undefined;
@@ -1,4 +1,4 @@
import { distance, type Coordinates } from 'gpx';
import { distance, type Coordinates } from '$lib/geo';
import { settings } from '$lib/logic/settings';
import { getElevation } from '$lib/utils';
import { get } from 'svelte/store';
@@ -143,7 +143,7 @@ async function getGraphHopperRoute(
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
points: points.map((point) => [point.lng, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
@@ -209,7 +209,7 @@ async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<RoutedPoints> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lng.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
@@ -283,15 +283,15 @@ function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
const dist = distance(points[i], points[i + 1]) / 1000;
for (let d = 0; d < dist; d += step) {
const lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
const lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
addPoint(route, lon, lat, 0);
const lng = points[i].lng + (d / dist) * (points[i + 1].lng - points[i].lng);
addPoint(route, lng, lat, 0);
}
}
const last = points[points.length - 1];
addPoint(route, last.lon, last.lat, 0);
addPoint(route, last.lng, last.lat, 0);
return getElevation(route.lng.map((lng, i) => ({ lon: lng, lat: route.lat[i] }))).then(
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;
return route;
@@ -1,51 +0,0 @@
import { ramerDouglasPeucker, type GPXFile, type TrackSegment } from 'gpx';
const earthRadius = 6371008.8;
export const MIN_ANCHOR_ZOOM = 0;
export const MAX_ANCHOR_ZOOM = 22;
export function getZoomLevelForDistance(latitude: number, distance?: number): number {
if (distance === undefined) {
return MIN_ANCHOR_ZOOM;
}
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(
MAX_ANCHOR_ZOOM,
Math.max(MIN_ANCHOR_ZOOM, Math.round(Math.log2((earthRadius * Math.cos(lat)) / distance)))
);
}
export function updateAnchorPoints(file: GPXFile) {
let segments = file.getSegments();
for (let segment of segments) {
if (!segment._data.anchors) {
// New segment, compute anchor points for it
computeAnchorPoints(segment);
continue;
}
if (segment.trkpt.length > 0) {
// Ensure first and last points are anchors and always visible
segment.trkpt[0]._data.anchor = true;
segment.trkpt[0]._data.zoom = 0;
segment.trkpt[segment.trkpt.length - 1]._data.anchor = true;
segment.trkpt[segment.trkpt.length - 1]._data.zoom = 0;
}
}
}
function computeAnchorPoints(segment: TrackSegment) {
let points = segment.trkpt;
let anchors = ramerDouglasPeucker(points, 1);
anchors.forEach((anchor) => {
let point = anchor.point;
point._data.anchor = true;
point._data.zoom = getZoomLevelForDistance(point.getLatitude(), anchor.distance);
});
segment._data.anchors = true;
}
@@ -88,7 +88,7 @@
longitude = parseFloat(longitude.toFixed(6));
const target = $selectedWaypoint;
const [ele] = await getElevation([{ lat: latitude, lon: longitude }]);
const [ele] = await getElevation([{ lat: latitude, lng: longitude }]);
const waypoint = {
lng: longitude,
lat: latitude,
-37
View File
@@ -1,37 +0,0 @@
import Dexie from 'dexie';
import type { GPXFile } from 'gpx';
import { enableMapSet, enablePatches, type Patch } from 'immer';
enableMapSet();
enablePatches();
export class Database extends Dexie {
fileids!: Dexie.Table<string, string>;
files!: Dexie.Table<GPXFile, string>;
patches!: Dexie.Table<{ patch: Patch[]; inversePatch: Patch[]; index: number }, number>;
settings!: Dexie.Table<any, string>;
overpasstiles!: Dexie.Table<
{ query: string; x: number; y: number; time: number },
[string, number, number]
>;
overpassdata!: Dexie.Table<
{ query: string; id: number; poi: GeoJSON.Feature },
[string, number]
>;
constructor() {
super('Database', {
cache: 'immutable',
});
this.version(1).stores({
fileids: ',&fileid',
files: '',
patches: ',patch',
settings: '',
overpasstiles: '[query+x+y],[x+y]',
overpassdata: '[query+id]',
});
}
}
export const db = new Database();
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Fitxers i estadístiques
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'e
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ També pots utilitzar la rodeta del ratolí per apropar o allunyar el perfil d'e
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Soubory a statistiky
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Pomocí kolečka myši můžete také výškový profil přiblížit a oddálit
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Pomocí kolečka myši můžete také výškový profil přiblížit a oddálit
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Dateien und Statistiken
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Sie können auch das Mausrad verwenden, um auf dem Höhenprofil heranzuzoomen un
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Sie können auch das Mausrad verwenden, um auf dem Höhenprofil heranzuzoomen un
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Archivos y estadísticas
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Puede usar el ratón para acercar o alejar el perfil de elevación y moverse hac
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Puede usar el ratón para acercar o alejar el perfil de elevación y moverse hac
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Fitxategiak eta estatistikak
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -82,8 +82,8 @@ Saguaren gurpila ere erabil dezakezu altueren profila handitzeko eta mugitzeko e
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -91,8 +91,8 @@ Saguaren gurpila ere erabil dezakezu altueren profila handitzeko eta mugitzeko e
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Fichiers et statistiques
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Vous pouvez également utiliser la molette de la souris pour zoomer ou dézoomer
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Vous pouvez également utiliser la molette de la souris pour zoomer ou dézoomer
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Fájlok és statisztikák
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Az egérgörgővel is méretezheti a magassági profilt. Balra és jobbra mozogh
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Az egérgörgővel is méretezheti a magassági profilt. Balra és jobbra mozogh
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: File e statistiche
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Fare clic sul profilo per resettare la selezione.
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Fare clic sul profilo per resettare la selezione.
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Puslapis nerastas
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Bestanden en statistieken
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Je kunt het muiswiel ook gebruiken om in en uit te zoomen op het hoogte profiel,
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Je kunt het muiswiel ook gebruiken om in en uit te zoomen op het hoogte profiel,
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Pliki i statystyki
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
@@ -7,11 +7,11 @@ title: Arquivos e estatísticas
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Você também pode usar a roda do mouse para aumentar e diminuir o zoom no perfi
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Você também pode usar a roda do mouse para aumentar e diminuir o zoom no perfi
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Файлы и статистика
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ title: Файлы и статистика
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ title: Файлы и статистика
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Datoteke i statistike
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Takođe možete da koristite točkić miša da zumirate i umanjite profil nadmo
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Takođe možete da koristite točkić miša da zumirate i umanjite profil nadmo
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ Du kan också använda mushjulet för att zooma in och ut på höjdprofilen och
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ Du kan också använda mushjulet för att zooma in och ut på höjdprofilen och
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Dosyalar ve istatistikler
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -82,8 +82,8 @@ Ayrıca, fare tekerleği ile yükselti profiline yakınlaşıp uzaklaşabilir ve
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -91,8 +91,8 @@ Ayrıca, fare tekerleği ile yükselti profiline yakınlaşıp uzaklaşabilir ve
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Файли та статистика
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: Files and statistics
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
@@ -7,11 +7,11 @@ title: 檔案和統計
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ You can also use the mouse wheel to zoom in and out on the elevation profile, an
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+7 -7
View File
@@ -7,11 +7,11 @@ title: 文件和统计
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
import { exampleGPXFile } from '$lib/assets/example';
import { exampleStatistics } from '$lib/assets/example';
import { writable } from 'svelte/store';
let gpxStatistics = writable(exampleGPXFile.getStatistics());
let slicedGPXStatistics = writable(undefined);
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
let elevationFill = writable(undefined);
@@ -83,8 +83,8 @@ title: 文件和统计
<div class="h-48 w-full">
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
@@ -92,8 +92,8 @@ title: 文件和统计
</div>
<div class="flex flex-col items-center w-full">
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={'horizontal'}
/>
</div>
+102 -1
View File
@@ -1,7 +1,14 @@
import { browser } from '$app/environment';
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import {
parseVisibility,
setHidden,
stringifyVisibility,
VISIBILITY_KEY,
type Visibility,
} from '$lib/file-visibility';
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
@@ -32,6 +39,9 @@ export type {
type Wasm = typeof import('gpx-rs');
/** Name of the IndexedDB database that keeps the files and the settings. */
const STORAGE_NAME = 'gpx-studio';
/**
* How a merge puts the selected elements together: `connect` makes a single segment of their
* trackpoints, `group` keeps them as they are, in the first one.
@@ -345,6 +355,85 @@ class Engine {
: new Promise(() => {});
}
// Storage
private _storage?: Promise<Record<string, string>>;
/**
* Opens the storage of the browser: the files that it holds are put in the engine, and the
* changes of the files are kept from then on. Resolves to the settings, as JSON strings by
* key. Only the app does it (the embedded map has no files of its own), once.
*
* The first time, what the previous versions of the app kept in the browser is imported.
*/
openStorage(): Promise<Record<string, string>> {
this._storage ??= this.openStorageOnce();
return this._storage;
}
private async openStorageOnce(): Promise<Record<string, string>> {
await this.ready;
const wasm = this.wasm!;
let settings: Record<string, string>;
try {
settings = (await wasm.open_storage(STORAGE_NAME)) as Record<string, string>;
} catch (error) {
console.error('The files cannot be kept in this browser', error);
return {};
}
// what was hidden, which the files that were restored need when they are read
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
this._visibility.set(fileId, visibility)
);
this.sync(wasm);
if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) {
settings = { ...settings, ...(await this.importLegacyData(wasm)) };
await wasm.flush_storage();
}
return settings;
}
/** Puts the files and the settings of the former database in the engine. */
private async importLegacyData(wasm: Wasm): Promise<Record<string, string>> {
const imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
if (legacy) {
const encoder = new TextEncoder();
if (legacy.files.length > 0) {
await this.loadFiles(
legacy.files.map((file) => ({
data: encoder.encode(file.gpx),
name: file.name,
}))
);
await this.select([]);
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
imported[key] = value;
}
}
} catch (error) {
// it is tried again next time
console.error('The files of the previous version could not be imported', error);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
setSetting(key: string, json: string) {
this.wasm?.set_setting(key, json);
}
deleteSetting(key: string) {
this.wasm?.delete_setting(key);
}
// Actions. Each one resolves to whether the engine changed something.
/**
@@ -845,6 +934,18 @@ class Engine {
);
this._visibility.set(fileId, visibility);
store.update((state) => ({ ...state, visibility }));
this.saveVisibility();
}
/** Keeps what is hidden, for the files that exist (the others are not coming back). */
private saveVisibility() {
const files = get(this._files);
const json = stringifyVisibility(this._visibility, (fileId) => files.has(fileId));
if (json === undefined) {
this.deleteSetting(VISIBILITY_KEY);
} else {
this.setSetting(VISIBILITY_KEY, json);
}
}
// Coordinates, as flat [lng, lat, ...] arrays. The getters copy them out of the WASM memory.
+53
View File
@@ -5,7 +5,9 @@ import {
isHidden,
isSegmentHidden,
isWaypointHidden,
parseVisibility,
setHidden,
stringifyVisibility,
waypointsKey,
type Visibility,
type VisibilityState,
@@ -142,3 +144,54 @@ describe('file visibility', () => {
assert.deepEqual([...before], [['S1', false]]);
});
});
describe('kept visibility', () => {
const visibility = (entries: [string, boolean][]): Visibility => new Map(entries);
it('comes back as it was kept', () => {
const files = new Map([
[
'F',
visibility([
['F', false],
['S1', true],
[WPTS, false],
]),
],
['G', visibility([['G', false]])],
]);
const json = stringifyVisibility(files, () => true);
assert.deepEqual(parseVisibility(json), files);
});
it('leaves out the files that are gone and the ones with nothing set', () => {
const files = new Map([
['F', visibility([['F', false]])],
['G', visibility([['G', false]])],
['H', visibility([])],
]);
const json = stringifyVisibility(files, (fileId) => fileId !== 'G');
assert.deepEqual([...parseVisibility(json).keys()], ['F']);
});
it('is nothing when there is nothing to keep', () => {
assert.equal(
stringifyVisibility(new Map(), () => true),
undefined
);
assert.equal(
stringifyVisibility(new Map([['F', visibility([['F', false]])]]), () => false),
undefined
);
assert.equal(parseVisibility(undefined).size, 0);
});
it('ignores what is not visibility', () => {
assert.equal(parseVisibility('not json').size, 0);
assert.equal(parseVisibility('3').size, 0);
assert.equal(parseVisibility('null').size, 0);
const parsed = parseVisibility('{"F":{"F":false,"S1":"no"},"G":3,"H":{}}');
assert.deepEqual([...parsed.keys()], ['F']);
assert.deepEqual([...parsed.get('F')!], [['F', false]]);
});
});
+52
View File
@@ -110,3 +110,55 @@ export function setHidden(
});
return next;
}
/** Name under which the visibility of the files is kept in the storage. */
export const VISIBILITY_KEY = 'visibility';
/**
* The visibility of the files as JSON, to keep it: the explicit visibility of each file by id,
* leaving out the files that `keep` refuses (the ones that are gone) and the ones that have none.
* `undefined` when there is nothing to keep.
*/
export function stringifyVisibility(
files: ReadonlyMap<string, Visibility>,
keep: (fileId: string) => boolean
): string | undefined {
const data: Record<string, Record<string, boolean>> = {};
files.forEach((visibility, fileId) => {
if (visibility.size > 0 && keep(fileId)) {
data[fileId] = Object.fromEntries(visibility);
}
});
return Object.keys(data).length > 0 ? JSON.stringify(data) : undefined;
}
/** What `stringifyVisibility` gave back. What does not make sense is left out. */
export function parseVisibility(json: string | undefined): Map<string, Visibility> {
const files = new Map<string, Visibility>();
if (json === undefined) {
return files;
}
try {
const data: unknown = JSON.parse(json);
if (typeof data !== 'object' || data === null) {
return files;
}
for (const [fileId, entries] of Object.entries(data)) {
if (typeof entries !== 'object' || entries === null) {
continue;
}
const visibility = new Map<string, boolean>();
for (const [id, shown] of Object.entries(entries)) {
if (typeof shown === 'boolean') {
visibility.set(id, shown);
}
}
if (visibility.size > 0) {
files.set(fileId, visibility);
}
}
} catch {
// not something that was kept
}
return files;
}
+19
View File
@@ -0,0 +1,19 @@
export type Coordinates = {
lat: number;
lng: number;
};
const EARTH_RADIUS = 6371008.8;
/** The distance in meters between two coordinates (haversine formula). */
export function distance(a: Coordinates, b: Coordinates): number {
const rad = Math.PI / 180;
const lat1 = a.lat * rad;
const lat2 = b.lat * rad;
const dLat = lat2 - lat1;
const dLng = (b.lng - a.lng) * rad;
const h =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return EARTH_RADIUS * 2 * Math.asin(Math.sqrt(Math.min(h, 1)));
}
+211
View File
@@ -0,0 +1,211 @@
/**
* One-time import of what the previous versions of the app kept in the browser: the files and the
* settings of an IndexedDB database named `Database` (managed with Dexie, which is not needed to
* read it). The files were stored as the objects of the former `gpx` library; they are written
* back as GPX, which the engine loads. The old database is left as it was.
*/
const LEGACY_DATABASE = 'Database';
/** Setting that tells that the import is done (or that there was nothing to import). */
export const LEGACY_IMPORTED_KEY = 'legacyImported';
/** Settings of the old database that mean nothing anymore. */
const DROPPED_SETTINGS = new Set(['patchIndex', 'fileOrder']);
type Json = any;
function request<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Opens the old database, `undefined` if there is none (and none is created). */
async function openLegacyDatabase(): Promise<IDBDatabase | undefined> {
if (typeof indexedDB === 'undefined') {
return undefined;
}
if (typeof indexedDB.databases === 'function') {
const databases = await indexedDB.databases();
if (!databases.some((database) => database.name === LEGACY_DATABASE)) {
return undefined;
}
}
return new Promise((resolve) => {
const open = indexedDB.open(LEGACY_DATABASE);
let created = false;
open.onupgradeneeded = () => {
// it did not exist: do not leave an empty database behind
created = true;
open.transaction?.abort();
};
open.onsuccess = () => resolve(open.result);
open.onerror = () => {
if (created) {
indexedDB.deleteDatabase(LEGACY_DATABASE);
}
resolve(undefined);
};
open.onblocked = () => resolve(undefined);
});
}
async function readAll(
database: IDBDatabase,
store: string
): Promise<Map<string, Json> | undefined> {
if (!database.objectStoreNames.contains(store)) {
return undefined;
}
const objectStore = database.transaction(store, 'readonly').objectStore(store);
const [keys, values] = await Promise.all([
request(objectStore.getAllKeys()),
request(objectStore.getAll()),
]);
return new Map(keys.map((key, i) => [String(key), values[i]]));
}
// GPX, from the stored objects
function escape(text: unknown): string {
return String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
function element(name: string, value: unknown): string {
return value === undefined || value === null || value === ''
? ''
: `<${name}>${escape(value)}</${name}>`;
}
function timeElement(value: unknown): string {
const date = value instanceof Date ? value : new Date(value as string);
return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
}
function link(value: Json): string {
const href = value?.attributes?.href;
return href
? `<link href="${escape(href).replaceAll('"', '&quot;')}">${element('text', value.text)}</link>`
: '';
}
function coordinates(point: Json): string {
const { lat, lon } = point.attributes ?? {};
return `lat="${Number(lat)}" lon="${Number(lon)}"`;
}
function trackpoint(point: Json): string {
const extensions = point.extensions ?? {};
const trackPointExtension = extensions['gpxtpx:TrackPointExtension'] ?? {};
const osm = Object.entries(trackPointExtension['gpxtpx:Extensions'] ?? {})
.map(([key, value]) => element(key, value))
.join('');
const inner = [
element('gpxtpx:atemp', trackPointExtension['gpxtpx:atemp']),
element('gpxtpx:hr', trackPointExtension['gpxtpx:hr']),
element('gpxtpx:cad', trackPointExtension['gpxtpx:cad']),
osm ? `<gpxtpx:Extensions>${osm}</gpxtpx:Extensions>` : '',
].join('');
const power = element(
'gpxpx:PowerInWatts',
extensions['gpxpx:PowerExtension']?.['gpxpx:PowerInWatts']
);
const extension =
inner || power
? `<extensions>${inner ? `<gpxtpx:TrackPointExtension>${inner}</gpxtpx:TrackPointExtension>` : ''}${power ? `<gpxpx:PowerExtension>${power}</gpxpx:PowerExtension>` : ''}</extensions>`
: '';
return `<trkpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${extension}</trkpt>`;
}
function waypoint(point: Json): string {
return `<wpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${element('name', point.name)}${element('cmt', point.cmt)}${element('desc', point.desc)}${link(point.link)}${element('sym', point.sym)}${element('type', point.type)}</wpt>`;
}
function track(track: Json): string {
const style = track.extensions?.['gpx_style:line'];
const line = style
? `<extensions><gpx_style:line>${element('gpx_style:color', style['gpx_style:color'])}${element('gpx_style:opacity', style['gpx_style:opacity'])}${element('gpx_style:width', style['gpx_style:width'])}</gpx_style:line></extensions>`
: '';
const segments = (track.trkseg ?? [])
.map(
(segment: Json) => `<trkseg>${(segment.trkpt ?? []).map(trackpoint).join('')}</trkseg>`
)
.join('');
return `<trk>${element('name', track.name)}${element('cmt', track.cmt)}${element('desc', track.desc)}${element('src', track.src)}${link(track.link)}${element('type', track.type)}${line}${segments}</trk>`;
}
/** A file as the old versions stored it, as GPX. */
export function legacyFileToGPX(file: Json): string {
const metadata = file.metadata ?? {};
const author = metadata.author;
const authorXml = author
? `<author>${element('name', author.name)}${link(author.link)}</author>`
: '';
const metadataXml = `<metadata>${element('name', metadata.name)}${element('desc', metadata.desc)}${authorXml}${link(metadata.link)}${metadata.time ? timeElement(metadata.time) : ''}</metadata>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="https://gpx.studio" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2">${metadataXml}${(file.wpt ?? []).map(waypoint).join('')}${(file.trk ?? []).map(track).join('')}</gpx>`;
}
/** What the old database holds. */
export type LegacyData = {
/** The files as GPX, in the order they had. */
files: { gpx: string; name: string }[];
/** The settings, as JSON strings by key. */
settings: Record<string, string>;
};
/** Reads the old database, `undefined` if there is none. */
export async function readLegacyData(): Promise<LegacyData | undefined> {
const database = await openLegacyDatabase();
if (!database) {
return undefined;
}
try {
const [files, settings] = await Promise.all([
readAll(database, 'files'),
readAll(database, 'settings'),
]);
if (!files && !settings) {
return undefined;
}
// the order of the files was a setting; the ids (`gpx-<n>`) tell the order they were added in
const byAge = [...(files?.keys() ?? [])].sort(
(a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1])
);
const order: string[] = Array.isArray(settings?.get('fileOrder'))
? settings!.get('fileOrder')
: [];
const ids = [
...order.filter((id) => files?.has(id)),
...byAge.filter((id) => !order.includes(id)),
];
const result: LegacyData = { files: [], settings: {} };
for (const id of ids) {
try {
const file = files!.get(id);
result.files.push({
gpx: legacyFileToGPX(file),
name: file?.metadata?.name || 'file',
});
} catch (error) {
console.error(`Could not import the file ${id}`, error);
}
}
for (const [key, value] of settings ?? []) {
if (!DROPPED_SETTINGS.has(key) && value !== undefined) {
try {
result.settings[key] = JSON.stringify(value);
} catch {
// not something that can be kept
}
}
}
return result;
} finally {
database.close();
}
}
@@ -1,253 +0,0 @@
import { db, type Database } from '$lib/db';
import { liveQuery } from 'dexie';
import type { GPXFile } from 'gpx';
import { applyPatches, produceWithPatches, type Patch, type WritableDraft } from 'immer';
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import {
derived,
get,
writable,
type Readable,
type Unsubscriber,
type Writable,
} from 'svelte/store';
import { selection } from '$lib/logic/selection';
const MAX_PATCHES = 100;
export class FileActionManager {
private _db: Database;
private _files: Map<string, GPXFile>;
private _fileSubscriptions: Map<string, Unsubscriber>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
private _patchIndex: Writable<number>;
private _patchMinIndex: Writable<number>;
private _patchMaxIndex: Writable<number>;
private _canUndo: Readable<boolean>;
private _canRedo: Readable<boolean>;
constructor(db: Database) {
this._db = db;
this._files = new Map();
this._fileSubscriptions = new Map();
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
this._fileSubscriptions.set(
fileId,
fileState.subscribe((fileWithStatistics) => {
if (fileWithStatistics) {
this._files.set(fileId, fileWithStatistics.file);
}
})
);
});
},
(fileId) => {
let unsubscribe = this._fileSubscriptions.get(fileId);
if (unsubscribe) {
unsubscribe();
this._fileSubscriptions.delete(fileId);
}
this._files.delete(fileId);
},
() => {
this._fileSubscriptions.forEach((unsubscribe) => unsubscribe());
this._fileSubscriptions.clear();
this._files.clear();
}
);
this._patchIndex = writable(-1);
this._patchMinIndex = writable(0);
this._patchMaxIndex = writable(0);
liveQuery(() => db.settings.get('patchIndex')).subscribe((value) => {
if (value !== undefined) {
this._patchIndex.set(value);
}
});
liveQuery(() =>
(db.patches.orderBy(':id').keys() as Promise<number[]>).then((keys) => {
if (keys.length === 0) {
return { min: 0, max: 0 };
} else {
return { min: keys[0], max: keys[keys.length - 1] + 1 };
}
})
).subscribe((value) => {
this._patchMinIndex.set(value.min);
this._patchMaxIndex.set(value.max);
});
this._canUndo = derived(
[this._patchIndex, this._patchMinIndex],
([$patchIndex, $patchMinIndex]) => {
return $patchIndex >= $patchMinIndex;
}
);
this._canRedo = derived(
[this._patchIndex, this._patchMaxIndex],
([$patchIndex, $patchMaxIndex]) => {
return $patchIndex < $patchMaxIndex - 1;
}
);
}
get canUndo(): Readable<boolean> {
return this._canUndo;
}
get canRedo(): Readable<boolean> {
return this._canRedo;
}
undo() {
if (get(this.canUndo)) {
const patchIndex = get(this._patchIndex);
this._db.patches.get(patchIndex).then((patch) => {
if (patch) {
this.apply(patch.inversePatch);
this._db.settings.put(patchIndex - 1, 'patchIndex');
}
});
}
}
redo() {
if (get(this.canRedo)) {
const patchIndex = get(this._patchIndex) + 1;
this._db.patches.get(patchIndex).then((patch) => {
if (patch) {
this.apply(patch.patch);
this._db.settings.put(patchIndex, 'patchIndex');
}
});
}
}
apply(patch: Patch[]) {
let newFiles = applyPatches(this._files, patch);
return this.commitFileStateChange(newFiles, patch);
}
commitFileStateChange(newFiles: ReadonlyMap<string, GPXFile>, patch: Patch[]) {
let changedFileIds = getChangedFileIds(patch);
let updatedFileIds: string[] = [],
deletedFileIds: string[] = [];
changedFileIds.forEach((id) => {
if (newFiles.has(id)) {
updatedFileIds.push(id);
} else {
deletedFileIds.push(id);
}
});
let updatedFiles = updatedFileIds
.map((id) => newFiles.get(id))
.filter((file) => file !== undefined) as GPXFile[];
updatedFileIds = updatedFiles.map((file) => file._data.id);
selection.updateFiles(updatedFiles, deletedFileIds);
// @ts-ignore
return this._db.transaction('rw', this._db.fileids, this._db.files, async () => {
if (updatedFileIds.length > 0) {
await this._db.fileids.bulkPut(updatedFileIds, updatedFileIds);
await this._db.files.bulkPut(updatedFiles, updatedFileIds);
}
if (deletedFileIds.length > 0) {
await this._db.fileids.bulkDelete(deletedFileIds);
await this._db.files.bulkDelete(deletedFileIds);
}
});
}
applyGlobal(callback: (files: Map<string, GPXFile>) => void) {
const [newFileCollection, patch, inversePatch] = produceWithPatches(this._files, callback);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
applyToFiles(fileIds: string[], callback: (file: WritableDraft<GPXFile>) => void) {
const [newFileCollection, patch, inversePatch] = produceWithPatches(
this._files,
(draft) => {
fileIds.forEach((fileId) => {
let file = draft.get(fileId);
if (file) {
callback(file);
}
});
}
);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
applyToFile(fileId: string, callback: (file: WritableDraft<GPXFile>) => void) {
return this.applyToFiles([fileId], callback);
}
applyEachToFilesAndGlobal(
fileIds: string[],
callbacks: ((file: WritableDraft<GPXFile>, context?: any) => void)[],
globalCallback: (files: Map<string, GPXFile>, context?: any) => void,
context?: any
) {
const [newFileCollection, patch, inversePatch] = produceWithPatches(
this._files,
(draft) => {
fileIds.forEach((fileId, index) => {
let file = draft.get(fileId);
if (file) {
callbacks[index](file, context);
}
});
globalCallback(draft, context);
}
);
this.storePatches(patch, inversePatch);
return this.commitFileStateChange(newFileCollection, patch);
}
async storePatches(patch: Patch[], inversePatch: Patch[]) {
this._db.patches.where(':id').above(get(this._patchIndex)).delete(); // Delete all patches after the current patch to avoid redoing them
if (get(this._patchMaxIndex) - get(this._patchMinIndex) + 1 > MAX_PATCHES) {
this._db.patches
.where(':id')
.belowOrEqual(get(this._patchMaxIndex) - MAX_PATCHES)
.delete();
}
this._db.transaction('rw', this._db.patches, this._db.settings, async () => {
let index = get(this._patchIndex) + 1;
await this._db.patches.put(
{
patch,
inversePatch,
index,
},
index
);
await this._db.settings.put(index, 'patchIndex');
});
}
}
// Get the file ids of the files that have changed in the patch
function getChangedFileIds(patch: Patch[]): string[] {
let changedFileIds = new Set<string>();
for (let p of patch) {
changedFileIds.add(p.path[0] as string);
}
return Array.from(changedFileIds);
}
export const fileActionManager = new FileActionManager(db);
-43
View File
@@ -1,59 +1,16 @@
import { fileStateCollection } from '$lib/logic/file-state';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { applyToOrderedItemsFromFile, copied, cut, selection } from '$lib/logic/selection';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import {
ListFileItem,
ListLevel,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
type ListItem,
} from '$lib/components/file-list/file-list';
import { i18n } from '$lib/i18n.svelte';
import { freeze } from 'immer';
import {
GPXFile,
Track,
TrackPoint,
TrackSegment,
Waypoint,
type LineStyleExtension,
type WaypointType,
} from 'gpx';
import { get } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { getClosestTrackSegments, getElevation } from '$lib/utils';
import { gpxStatistics } from '$lib/logic/statistics';
import { boundsManager } from './bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/default-file-name';
// Generate unique file ids, different from the ones in the database
export function getFileIds(n: number) {
let ids = [];
for (let index = 0; ids.length < n; index++) {
let id = `gpx-${index}`;
if (!fileStateCollection.getFile(id)) {
ids.push(id);
}
}
return ids;
}
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function newGPXFile() {
let file = new GPXFile();
file.metadata.name = newFileName();
return file;
}
export function createFile() {
// the engine selects the new file
engine.newFile(newFileName());
-208
View File
@@ -1,208 +0,0 @@
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/simplify';
import { type Database } from '$lib/db';
import { liveQuery } from 'dexie';
import { GPXFile } from 'gpx';
import { GPXStatisticsTree, type GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { settings } from '$lib/logic/settings';
import { get, writable, type Subscriber, type Writable } from 'svelte/store';
// Observe a single file from the database, and maintain its statistics
export class GPXFileState {
private _fileId: string;
private _file: Writable<GPXFileWithStatistics | undefined>;
private _subscription: { unsubscribe: () => void } | undefined;
constructor(fileId: string, file?: GPXFile) {
this._fileId = fileId;
this._file = writable(file ? { file, statistics: new GPXStatisticsTree(file) } : undefined);
}
connectToDatabase(db: Database) {
if (this._subscription) return;
this._subscription = liveQuery(() => db.files.get(this._fileId)).subscribe((value) => {
if (value !== undefined) {
let file = new GPXFile(value);
updateAnchorPoints(file);
let statistics = new GPXStatisticsTree(file);
this._file.set({ file, statistics });
}
});
}
subscribe(run: Subscriber<GPXFileWithStatistics | undefined>, invalidate?: () => void) {
return this._file.subscribe(run, invalidate);
}
destroy() {
this._subscription?.unsubscribe();
this._subscription = undefined;
}
get file(): GPXFile | undefined {
return get(this._file)?.file;
}
get statistics(): GPXStatisticsTree | undefined {
return get(this._file)?.statistics;
}
}
// Observe the file ids in the database, and maintain a map of file states for the corresponding files
export class GPXFileStateCollection {
private _files: Writable<Map<string, GPXFileState>>;
private _subscription: { unsubscribe: () => void } | null = null;
constructor() {
this._files = writable(new Map());
}
connectToDatabase(db: Database): Promise<void> {
return new Promise((resolve) => {
if (this._subscription) {
resolve();
return;
}
this._subscription = liveQuery(() => db.fileids.toArray()).subscribe((dbFileIds) => {
const currentFiles = get(this._files);
// Find new files to observe
let newFiles = dbFileIds
.filter((id) => !currentFiles.has(id))
.sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// Find deleted files to stop observing
let deletedFiles = Array.from(currentFiles.keys()).filter(
(id) => !dbFileIds.find((fileId) => fileId === id)
);
if (newFiles.length > 0 || deletedFiles.length > 0) {
// Update the map of file states
this._files.update(($files) => {
newFiles.forEach((id) => {
const fileState = new GPXFileState(id);
fileState.connectToDatabase(db);
$files.set(id, fileState);
});
deletedFiles.forEach((id) => {
$files.get(id)?.destroy();
$files.delete(id);
});
return $files;
});
// Update the file order
let fileOrder = get(settings.fileOrder).filter(
(id) => !deletedFiles.includes(id)
);
newFiles.forEach((id) => {
if (!fileOrder.includes(id)) {
fileOrder.push(id);
}
});
settings.fileOrder.set(fileOrder);
}
resolve();
});
});
}
disconnectFromDatabase() {
this._subscription?.unsubscribe();
this._subscription = null;
this._files.update(($files) => {
$files.forEach((fileState) => {
fileState.destroy();
});
return new Map();
});
}
setEmbeddedFiles(files: GPXFile[]) {
this._files.update(($files) => {
$files.clear();
files.forEach((file) => {
const id = file._data.id;
if (!$files.has(id)) {
const fileState = new GPXFileState(id, file);
$files.set(id, fileState);
}
});
return $files;
});
}
subscribe(run: Subscriber<Map<string, GPXFileState>>, invalidate?: () => void) {
return this._files.subscribe(run, invalidate);
}
get size(): number {
return get(this._files).size;
}
getFileState(fileId: string): GPXFileState | undefined {
return get(this._files).get(fileId);
}
getFile(fileId: string): GPXFile | undefined {
let fileState = get(this._files).get(fileId);
return fileState?.file;
}
getStatistics(fileId: string): GPXStatisticsTree | undefined {
let fileState = get(this._files).get(fileId);
return fileState?.statistics;
}
forEach(callback: (fileId: string, file: GPXFile) => void) {
get(this._files).forEach((fileState, fileId) => {
if (fileState.file) {
callback(fileId, fileState.file);
}
});
}
}
// Collection of all file states
export const fileStateCollection = new GPXFileStateCollection();
export type GPXFileStateCallback = (files: Map<string, GPXFileState>) => void;
export class GPXFileStateCollectionObserver {
private _fileIds: Set<string>;
private _onFilesAdded: GPXFileStateCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
private _unsubscribe: () => void;
constructor(
onFilesAdded: GPXFileStateCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._fileIds = new Set();
this._onFilesAdded = onFilesAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
this._unsubscribe = fileStateCollection.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
let newFiles = new Map<string, GPXFileState>();
files.forEach((file: GPXFileState, fileId: string) => {
if (!this._fileIds.has(fileId)) {
newFiles.set(fileId, file);
this._fileIds.add(fileId);
}
});
if (newFiles.size > 0) {
this._onFilesAdded(newFiles);
}
});
}
destroy() {
this._onDestroy();
this._unsubscribe();
}
}
-67
View File
@@ -1,67 +0,0 @@
import { get, writable, type Writable } from 'svelte/store';
import { SelectedGPXFilesObserver, selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import {
ListFileItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
export class AllHidden {
private _value: Writable<boolean>;
constructor() {
this._value = writable(false);
new SelectedGPXFilesObserver(() => this.update());
}
subscribe(run: (value: boolean) => void, invalidate?: () => void) {
return this._value.subscribe(run, invalidate);
}
update() {
let hidden = true;
selection.applyToSelectedItemsFromFile((fileId, level, items) => {
let file = fileStateCollection.getFile(fileId);
if (file) {
for (let item of items) {
if (!hidden) {
return;
}
if (item instanceof ListFileItem) {
hidden = hidden && file._data.hidden === true;
} else if (
item instanceof ListTrackItem &&
item.getTrackIndex() < file.trk.length
) {
hidden = hidden && file.trk[item.getTrackIndex()]._data.hidden === true;
} else if (
item instanceof ListTrackSegmentItem &&
item.getTrackIndex() < file.trk.length &&
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
) {
hidden =
hidden &&
file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()]._data
.hidden === true;
} else if (item instanceof ListWaypointsItem) {
hidden = hidden && file._data.hiddenWpt === true;
} else if (
item instanceof ListWaypointItem &&
item.getWaypointIndex() < file.wpt.length
) {
hidden = hidden && file.wpt[item.getWaypointIndex()]._data.hidden === true;
}
}
}
});
if (hidden != get(this._value)) {
this._value.set(hidden);
}
}
}
export const allHidden = new AllHidden();
@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import { writable, type Writable } from 'svelte/store';
import { engine, type GlobalStatistics } from '$lib/engine';
import type { Coordinates } from '$lib/geo';
/** The part of the selection that is highlighted in the elevation profile. */
export type SlicedStatistics = {
@@ -14,3 +15,6 @@ export const slicedStatistics = writable<SlicedStatistics | undefined>(undefined
// the indices only make sense for the selection they were made on
engine.statistics.subscribe(() => slicedStatistics.set(undefined));
/** The point of the elevation profile that the pointer is over, shown on the map. */
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
-140
View File
@@ -1,140 +0,0 @@
import type { ListItem } from '$lib/components/file-list/file-list';
export class SelectionTreeType {
item: ListItem;
selected: boolean;
children: {
[key: string | number]: SelectionTreeType;
};
size: number = 0;
constructor(item: ListItem) {
this.item = item;
this.selected = false;
this.children = {};
}
clear() {
this.selected = false;
for (let key in this.children) {
this.children[key].clear();
}
this.size = 0;
}
_setOrToggle(item: ListItem, value?: boolean) {
if (item.level === this.item.level) {
let newSelected = value === undefined ? !this.selected : value;
if (this.selected !== newSelected) {
this.selected = newSelected;
this.size += this.selected ? 1 : -1;
}
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (!this.children.hasOwnProperty(id)) {
this.children[id] = new SelectionTreeType(this.item.extend(id));
}
this.size -= this.children[id].size;
this.children[id]._setOrToggle(item, value);
this.size += this.children[id].size;
}
}
}
set(item: ListItem, value: boolean) {
this._setOrToggle(item, value);
}
toggle(item: ListItem) {
this._setOrToggle(item);
}
has(item: ListItem): boolean {
if (item.level === this.item.level) {
return this.selected;
} else {
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].has(item);
}
}
}
return false;
}
hasAnyParent(item: ListItem, self: boolean = true): boolean {
if (
this.selected &&
this.item.level <= item.level &&
(self || this.item.level < item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyParent(item, self);
}
}
return false;
}
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
if (
this.selected &&
this.item.level >= item.level &&
(self || this.item.level > item.level)
) {
return this.selected;
}
let id = item.getIdAtLevel(this.item.level);
if (id !== undefined) {
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
if (this.children.hasOwnProperty(id)) {
return this.children[id].hasAnyChildren(item, self, ignoreIds);
}
}
} else {
for (let key in this.children) {
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
return true;
}
}
}
}
return false;
}
getSelected(selection: ListItem[] = []): ListItem[] {
if (this.selected) {
selection.push(this.item);
}
for (let key in this.children) {
this.children[key].getSelected(selection);
}
return selection;
}
forEach(callback: (item: ListItem) => void) {
if (this.selected) {
callback(this.item);
}
for (let key in this.children) {
this.children[key].forEach(callback);
}
}
getChild(id: string | number): SelectionTreeType | undefined {
return this.children[id];
}
deleteChild(id: string | number) {
if (this.children.hasOwnProperty(id)) {
this.size -= this.children[id].size;
delete this.children[id];
}
}
}
-424
View File
@@ -1,424 +0,0 @@
import {
ListFileItem,
ListItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
ListWaypointItem,
ListLevel,
sortItems,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import { fileStateCollection, GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import type { GPXFile } from 'gpx';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { SelectionTreeType } from '$lib/logic/selection-tree';
export class Selection {
private _selection: Writable<SelectionTreeType>;
private _copied: Writable<ListItem[] | undefined>;
private _cut: Writable<boolean>;
constructor() {
this._selection = writable(new SelectionTreeType(new ListRootItem()));
this._copied = writable(undefined);
this._cut = writable(false);
}
subscribe(
run: (value: SelectionTreeType) => void,
invalidate?: (value?: SelectionTreeType) => void
) {
return this._selection.subscribe(run, invalidate);
}
update(updater: (value: SelectionTreeType) => SelectionTreeType) {
this._selection.update(updater);
}
selectItem(item: ListItem) {
this._selection.update(($selection) => {
$selection.clear();
$selection.set(item, true);
return $selection;
});
}
selectFile(fileId: string) {
this.selectItem(new ListFileItem(fileId));
}
addSelectItem(item: ListItem) {
this._selection.update(($selection) => {
$selection.toggle(item);
return $selection;
});
}
addSelectFile(fileId: string) {
this.addSelectItem(new ListFileItem(fileId));
}
selectAll() {
let item: ListItem = new ListRootItem();
get(this._selection).forEach((i) => {
item = i;
});
this._selection.update(($selection) => {
$selection.clear();
if (item instanceof ListRootItem || item instanceof ListFileItem) {
fileStateCollection.forEach((fileId, file) => {
$selection.set(new ListFileItem(fileId), true);
});
} else if (item instanceof ListTrackItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk.forEach((_track, trackId) => {
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
});
}
} else if (item instanceof ListTrackSegmentItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
$selection.set(
new ListTrackSegmentItem(
item.getFileId(),
item.getTrackIndex(),
segmentId
),
true
);
});
}
} else if (item instanceof ListWaypointItem) {
let file = fileStateCollection.getFile(item.getFileId());
if (file) {
file.wpt.forEach((_waypoint, waypointId) => {
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
});
}
}
return $selection;
});
}
selectFileWhenLoaded(fileId: string) {
const unsubscribe = fileStateCollection.subscribe((files) => {
if (files.has(fileId)) {
this.selectFile(fileId);
unsubscribe();
}
});
}
set(items: ListItem[]) {
this._selection.update(($selection) => {
$selection.clear();
items.forEach((item) => {
$selection.set(item, true);
});
return $selection;
});
}
updateFiles(updatedFiles: GPXFile[], deletedFileIds: string[]) {
let removedItems: ListItem[] = [];
applyToOrderedItemsFromFile(get(this._selection).getSelected(), (fileId, level, items) => {
let file = updatedFiles.find((file) => file._data.id === fileId);
if (file) {
items.forEach((item) => {
if (item instanceof ListTrackItem) {
let newTrackIndex = file.trk.findIndex(
(track) => track._data.trackIndex === item.getTrackIndex()
);
if (newTrackIndex === -1) {
removedItems.push(item);
}
} else if (item instanceof ListTrackSegmentItem) {
let newTrackIndex = file.trk.findIndex(
(track) => track._data.trackIndex === item.getTrackIndex()
);
if (newTrackIndex === -1) {
removedItems.push(item);
} else {
let newSegmentIndex = file.trk[newTrackIndex].trkseg.findIndex(
(segment) => segment._data.segmentIndex === item.getSegmentIndex()
);
if (newSegmentIndex === -1) {
removedItems.push(item);
}
}
} else if (item instanceof ListWaypointItem) {
let newWaypointIndex = file.wpt.findIndex(
(wpt) => wpt._data.index === item.getWaypointIndex()
);
if (newWaypointIndex === -1) {
removedItems.push(item);
}
}
});
} else if (deletedFileIds.includes(fileId)) {
items.forEach((item) => {
removedItems.push(item);
});
}
});
if (removedItems.length > 0) {
this._selection.update(($selection) => {
removedItems.forEach((item) => {
if (item instanceof ListFileItem) {
$selection.deleteChild(item.getFileId());
} else {
$selection.set(item, false);
}
});
return $selection;
});
}
}
updateFromKey(down: boolean, shift: boolean) {
let selected = get(this._selection).getSelected();
if (selected.length === 0) {
return;
}
let next: ListItem | undefined = undefined;
if (selected[0] instanceof ListFileItem) {
let order = get(settings.fileOrder);
let limitIndex: number | undefined = undefined;
selected.forEach((item) => {
let index = order.indexOf(item.getFileId());
if (
limitIndex === undefined ||
(down && index > limitIndex) ||
(!down && index < limitIndex)
) {
limitIndex = index;
}
});
if (limitIndex !== undefined) {
let nextIndex = down ? limitIndex + 1 : limitIndex - 1;
while (true) {
if (nextIndex < 0) {
nextIndex = order.length - 1;
} else if (nextIndex >= order.length) {
nextIndex = 0;
}
if (nextIndex === limitIndex) {
break;
}
next = new ListFileItem(order[nextIndex]);
if (!get(selection).has(next)) {
break;
}
nextIndex += down ? 1 : -1;
}
}
} else if (
selected[0] instanceof ListTrackItem &&
selected[selected.length - 1] instanceof ListTrackItem
) {
let fileId = selected[0].getFileId();
let file = fileStateCollection.getFile(fileId);
if (file) {
let numberOfTracks = file.trk.length;
let trackIndex = down
? selected[selected.length - 1].getTrackIndex()
: selected[0].getTrackIndex();
if (down && trackIndex < numberOfTracks - 1) {
next = new ListTrackItem(fileId, trackIndex + 1);
} else if (!down && trackIndex > 0) {
next = new ListTrackItem(fileId, trackIndex - 1);
}
}
} else if (
selected[0] instanceof ListTrackSegmentItem &&
selected[selected.length - 1] instanceof ListTrackSegmentItem
) {
let fileId = selected[0].getFileId();
let file = fileStateCollection.getFile(fileId);
if (file) {
let trackIndex = selected[0].getTrackIndex();
let numberOfSegments = file.trk[trackIndex].trkseg.length;
let segmentIndex = down
? selected[selected.length - 1].getSegmentIndex()
: selected[0].getSegmentIndex();
if (down && segmentIndex < numberOfSegments - 1) {
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex + 1);
} else if (!down && segmentIndex > 0) {
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex - 1);
}
}
} else if (
selected[0] instanceof ListWaypointItem &&
selected[selected.length - 1] instanceof ListWaypointItem
) {
let fileId = selected[0].getFileId();
let file = fileStateCollection.getFile(fileId);
if (file) {
let numberOfWaypoints = file.wpt.length;
let waypointIndex = down
? selected[selected.length - 1].getWaypointIndex()
: selected[0].getWaypointIndex();
if (down && waypointIndex < numberOfWaypoints - 1) {
next = new ListWaypointItem(fileId, waypointIndex + 1);
} else if (!down && waypointIndex > 0) {
next = new ListWaypointItem(fileId, waypointIndex - 1);
}
}
}
if (next && (!get(this._selection).has(next) || !shift)) {
if (shift) {
this.addSelectItem(next);
} else {
this.selectItem(next);
}
}
}
getOrderedSelection(reverse: boolean = false): ListItem[] {
let selected: ListItem[] = [];
this.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
selected.push(...items);
}, reverse);
return selected;
}
applyToSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void
) {
let selectedItems = get(this._selection).getSelected();
get(fileStateCollection).forEach((_, fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
callback(fileId, level, items);
}
});
}
applyToOrderedSelectedItemsFromFile(
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
applyToOrderedItemsFromFile(get(this._selection).getSelected(), callback, reverse);
}
copySelection(): boolean {
let selected = get(this._selection).getSelected();
if (selected.length > 0) {
this._copied.set(selected);
this._cut.set(false);
return true;
}
return false;
}
cutSelection() {
if (this.copySelection()) {
this._cut.set(true);
}
}
resetCopied() {
this._copied.set(undefined);
this._cut.set(false);
}
get copied(): Readable<ListItem[] | undefined> {
return this._copied;
}
get cut(): Readable<boolean> {
return this._cut;
}
}
export const selection = new Selection();
export const copied = selection.copied;
export const cut = selection.cut;
export function applyToOrderedItemsFromFile(
selectedItems: ListItem[],
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
reverse: boolean = true
) {
get(settings.fileOrder).forEach((fileId) => {
let level: ListLevel | undefined = undefined;
let items: ListItem[] = [];
selectedItems.forEach((item) => {
if (item.getFileId() === fileId) {
level = item.level;
if (
item instanceof ListFileItem ||
item instanceof ListTrackItem ||
item instanceof ListTrackSegmentItem ||
item instanceof ListWaypointsItem ||
item instanceof ListWaypointItem
) {
items.push(item);
}
}
});
if (items.length > 0) {
sortItems(items, reverse);
callback(fileId, level, items);
}
});
}
export class SelectedGPXFilesObserver {
private _fileStateCollectionObserver: GPXFileStateCollectionObserver;
private _unsubscribes: Map<string, () => void> = new Map();
constructor(onSelectedFileChange: () => void) {
this._unsubscribes = new Map();
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
this._unsubscribes.set(
fileId,
fileState.subscribe(() => {
if (get(selection).hasAnyChildren(new ListFileItem(fileId))) {
onSelectedFileChange();
}
})
);
});
},
(fileId) => {
this._unsubscribes.get(fileId)?.();
this._unsubscribes.delete(fileId);
},
() => {
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
this._unsubscribes.clear();
}
);
selection.subscribe(() => onSelectedFileChange());
}
}
+47 -76
View File
@@ -1,5 +1,3 @@
import { type Database } from '$lib/db';
import { liveQuery } from 'dexie';
import {
basemaps,
defaultBasemap,
@@ -15,11 +13,35 @@ import {
type LayerTreeType,
} from '$lib/assets/layers';
import { browser } from '$app/environment';
import { engine } from '$lib/engine';
import { get, writable, type Writable } from 'svelte/store';
/** What the settings were saved as, by key: JSON strings (see `Engine.openStorage`). */
export type StoredSettings = Record<string, string>;
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
function storedValue(stored: StoredSettings, key: string): { value: any } | undefined {
if (!Object.hasOwn(stored, key)) {
return undefined;
}
try {
return { value: JSON.parse(stored[key]) };
} catch {
return undefined;
}
}
/** Keeps a value; `undefined` is not something JSON can hold, it means no value. */
function save(key: string, value: unknown) {
const json = JSON.stringify(value);
if (json === undefined) {
engine.deleteSetting(key);
} else {
engine.setSetting(key, json);
}
}
export class Setting<V> {
private _db: Database | null = null;
private _subscription: { unsubscribe: () => void } | null = null;
private _key: string;
private _value: Writable<V>;
private _validator?: (value: V) => V;
@@ -30,30 +52,12 @@ export class Setting<V> {
this._validator = validator;
}
connectToDatabase(db: Database) {
if (this._db) return;
this._db = db;
let first = true;
this._subscription = liveQuery(() => db.settings.get(this._key)).subscribe((value) => {
if (value === undefined) {
if (!first) {
this._value.set(value);
}
} else {
if (this._validator) {
value = this._validator(value);
}
this._value.set(value);
}
first = false;
});
}
disconnectFromDatabase() {
this._subscription?.unsubscribe();
this._subscription = null;
this._db = null;
/** Takes the saved value, if there is one. */
connect(stored: StoredSettings) {
const saved = storedValue(stored, this._key);
if (saved) {
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
}
}
subscribe(run: (value: V) => void, invalidate?: (value?: V) => void) {
@@ -62,11 +66,8 @@ export class Setting<V> {
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
if (this._db) {
this._db.settings.put(value, this._key);
} else {
this._value.set(value);
}
this._value.set(value);
save(this._key, value);
}
}
@@ -75,9 +76,8 @@ export class Setting<V> {
}
}
/** A setting that has no value until it is connected: then the saved one, or the initial one. */
export class SettingInitOnFirstRead<V> {
private _db: Database | null = null;
private _subscription: { unsubscribe: () => void } | null = null;
private _key: string;
private _value: Writable<V | undefined>;
private _initial: V;
@@ -90,49 +90,27 @@ export class SettingInitOnFirstRead<V> {
this._validator = validator;
}
connectToDatabase(db: Database) {
if (this._db) return;
this._db = db;
let first = true;
this._subscription = liveQuery(() => db.settings.get(this._key)).subscribe((value) => {
if (value === undefined) {
if (first) {
this._value.set(this._initial);
} else {
this._value.set(value);
}
} else {
if (this._validator) {
value = this._validator(value);
}
this._value.set(value);
}
first = false;
});
connect(stored: StoredSettings) {
const saved = storedValue(stored, this._key);
if (saved) {
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
} else {
this._value.set(this._initial);
}
}
initialize() {
this.set(this._initial);
}
disconnectFromDatabase() {
this._subscription?.unsubscribe();
this._subscription = null;
this._db = null;
}
subscribe(run: (value: V | undefined) => void, invalidate?: (value?: V | undefined) => void) {
return this._value.subscribe(run, invalidate);
}
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
if (this._db) {
this._db.settings.put(value, this._key);
} else {
this._value.set(value);
}
this._value.set(value);
save(this._key, value);
}
}
@@ -322,19 +300,12 @@ export const settings = {
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: new Setting('bottomPanelSize', 170),
rightPanelSize: new Setting('rightPanelSize', 240),
connectToDatabase(db: Database) {
/** Takes the saved values (see `Engine.openStorage`). */
connect(stored: StoredSettings) {
for (const key in settings) {
const setting = (settings as any)[key];
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
setting.connectToDatabase(db);
}
}
},
disconnectFromDatabase() {
for (const key in settings) {
const setting = (settings as any)[key];
if (setting instanceof Setting || setting instanceof SettingInitOnFirstRead) {
setting.disconnectFromDatabase();
setting.connect(stored);
}
}
},
-74
View File
@@ -1,74 +0,0 @@
import { ListItem, ListLevel } from '$lib/components/file-list/file-list';
import { GPXFile, GPXStatistics, GPXStatisticsGroup, type Track } from 'gpx';
import maplibregl from 'maplibre-gl';
export class GPXStatisticsTree {
level: ListLevel;
statistics: {
[key: string]: GPXStatisticsTree | GPXStatistics;
} = {};
wptBounds: maplibregl.LngLatBounds;
constructor(element: GPXFile | Track) {
this.wptBounds = new maplibregl.LngLatBounds();
if (element instanceof GPXFile) {
this.level = ListLevel.FILE;
element.children.forEach((child, index) => {
this.statistics[index] = new GPXStatisticsTree(child);
});
element.wpt.forEach((wpt) => {
this.wptBounds.extend(wpt.getCoordinates());
});
} else {
this.level = ListLevel.TRACK;
element.children.forEach((child, index) => {
this.statistics[index] = child.getStatistics();
});
}
}
getStatisticsFor(item: ListItem): GPXStatisticsGroup {
let statistics = new GPXStatisticsGroup();
let id = item.getIdAtLevel(this.level);
if (id === undefined || id === 'waypoints') {
Object.keys(this.statistics).forEach((key) => {
if (this.statistics[key] instanceof GPXStatistics) {
statistics.add(this.statistics[key]);
} else {
statistics.add(this.statistics[key].getStatisticsFor(item));
}
});
} else {
let child = this.statistics[id];
if (child instanceof GPXStatistics) {
statistics.add(child);
} else if (child !== undefined) {
statistics.add(child.getStatisticsFor(item));
}
}
return statistics;
}
intersectsBBox(bounds: maplibregl.LngLatBounds): boolean {
for (let key in this.statistics) {
const stats = this.statistics[key];
if (stats instanceof GPXStatistics) {
const bbox = new maplibregl.LngLatBounds(
stats.global.bounds.southWest,
stats.global.bounds.northEast
);
if (!bbox.isEmpty() && bbox.intersects(bounds)) {
return true;
}
} else if (stats.intersectsBBox(bounds)) {
return true;
}
}
return false;
}
intersectsWaypointBBox(bounds: maplibregl.LngLatBounds): boolean {
return !this.wptBounds.isEmpty() && this.wptBounds.intersects(bounds);
}
}
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };
-89
View File
@@ -1,89 +0,0 @@
import { selection } from '$lib/logic/selection';
import { GPXGlobalStatistics, GPXStatisticsGroup, type Coordinates } from 'gpx';
import { fileStateCollection, GPXFileState } from '$lib/logic/file-state';
import {
ListFileItem,
ListWaypointItem,
ListWaypointsItem,
} from '$lib/components/file-list/file-list';
import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
const { fileOrder } = settings;
export class SelectedGPXStatistics {
private _statistics: Writable<GPXStatisticsGroup>;
private _files: Map<
string,
{
file: GPXFileState;
unsubscribe: () => void;
}
>;
constructor() {
this._statistics = writable(new GPXStatisticsGroup());
this._files = new Map();
selection.subscribe(() => this.update());
fileOrder.subscribe(() => this.update());
}
subscribe(
run: (value: GPXStatisticsGroup) => void,
invalidate?: (value?: GPXStatisticsGroup) => void
) {
return this._statistics.subscribe(run, invalidate);
}
update() {
let statistics = new GPXStatisticsGroup();
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let stats = fileStateCollection.getStatistics(fileId);
if (stats) {
let first = true;
items.forEach((item) => {
if (
!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) ||
first
) {
statistics.add(stats.getStatisticsFor(item));
first = false;
}
});
}
if (!this._files.has(fileId)) {
let file = fileStateCollection.getFileState(fileId);
if (file) {
let first = true;
let unsubscribe = file.subscribe(() => {
if (first) first = false;
else this.update();
});
this._files.set(fileId, { file, unsubscribe });
}
}
}, false);
this._statistics.set(statistics);
for (let [fileId, entry] of this._files) {
if (
!get(fileStateCollection).has(fileId) ||
!get(selection).hasAnyChildren(new ListFileItem(fileId))
) {
entry.unsubscribe();
this._files.delete(fileId);
}
}
}
}
export const gpxStatistics = new SelectedGPXStatistics();
export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> =
writable(undefined);
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
gpxStatistics.subscribe(() => {
slicedGPXStatistics.set(undefined);
});
@@ -0,0 +1,88 @@
/**
* Computes the statistics of `assets/example.gpx` with the engine, and writes them to
* `assets/example-data.ts`, where the pages that show an example (the home page, the
* documentation) take them from. Run it when the example, or the way the engine computes the
* statistics, changes:
*
* npm run generate:example
*
* The engine has to be built (`wasm-pack build wasm --target bundler --out-dir ../pkg`, in
* `gpx-rs`).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import prettier from 'prettier';
const here = path.dirname(fileURLToPath(import.meta.url));
const assets = path.resolve(here, '../assets');
const packageDirectory = path.resolve(here, '../../../../gpx-rs/pkg');
export const exampleGPX = path.join(assets, 'example.gpx');
export const exampleData = path.join(assets, 'example-data.ts');
/** Whether the engine is built. */
export function engineIsBuilt() {
return fs.existsSync(path.join(packageDirectory, 'gpx_rs_bg.wasm'));
}
/** Loads the engine in Node: the package is made for bundlers, which import the wasm file. */
async function loadEngine() {
const glue = await import(pathToFileURL(path.join(packageDirectory, 'gpx_rs_bg.js')).href);
const { instance } = await WebAssembly.instantiate(
fs.readFileSync(path.join(packageDirectory, 'gpx_rs_bg.wasm')),
{ './gpx_rs_bg.js': glue }
);
glue.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
return glue as typeof import('gpx-rs');
}
type Values = Float64Array | BigInt64Array | undefined;
/** The arrays that the engine has for the example, which are all the pages need. */
export async function computeExampleData() {
const engine = await loadEngine();
engine.start();
const data = fs.readFileSync(exampleGPX);
engine.load_files(new Uint8Array(data), Uint32Array.from([data.length]), ['example']);
// numbers are enough for the times, which are milliseconds
const numbers = (values: Values) =>
values && Array.from(values as ArrayLike<number | bigint>, (value) => Number(value));
return {
global: engine.selection_statistics(),
totalDistance: numbers(engine.total_distance()),
movingDistance: numbers(engine.moving_distance()),
totalTime: numbers(engine.total_time()),
movingTime: numbers(engine.moving_time()),
elevationGain: numbers(engine.elevation_gain()),
elevationLoss: numbers(engine.elevation_loss()),
slope: numbers(engine.slope()),
slopeSegmentSlope: numbers(engine.slope_segment_slope()),
slopeSegmentDistance: numbers(engine.slope_segment_distance()),
lng: numbers(engine.lng()),
lat: numbers(engine.lat()),
ele: numbers(engine.ele()),
timestamps: numbers(engine.timestamps()),
speed: numbers(engine.speed()),
atemp: numbers(engine.atemp()),
};
}
async function generate() {
const data = await computeExampleData();
const source = `// Generated by src/lib/scripts/example-statistics.ts from example.gpx: do not edit.
export const exampleData = ${JSON.stringify(data)};
`;
const options = (await prettier.resolveConfig(exampleData)) ?? {};
fs.writeFileSync(
exampleData,
await prettier.format(source, { ...options, parser: 'typescript' })
);
console.log(`Wrote ${path.relative(process.cwd(), exampleData)}`);
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
generate();
}
+5 -88
View File
@@ -2,11 +2,9 @@ import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance, GPXFile } from 'gpx';
import type { Coordinates } from '$lib/geo';
import maplibregl from 'maplibre-gl';
import { pointToTile, pointToTileFraction } from '@mapbox/tilebelt';
import type { GPXStatisticsTree } from '$lib/logic/statistics-tree';
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -19,97 +17,16 @@ export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'childre
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
export function getClosestLinePoint(
points: TrackPoint[],
point: TrackPoint | Coordinates,
details: any = {}
): TrackPoint {
let closest = points[0];
let closestDist = Number.MAX_VALUE;
for (let i = 0; i < points.length - 1; i++) {
let dist = crossarcDistance(points[i], points[i + 1], point);
if (dist < closestDist) {
closestDist = dist;
if (distance(points[i], point) <= distance(points[i + 1], point)) {
closest = points[i];
details['before'] = true;
details['index'] = i;
} else {
closest = points[i + 1];
details['before'] = false;
details['index'] = i + 1;
}
}
}
details['distance'] = closestDist;
return closest;
}
export function getClosestTrackSegments(
file: GPXFile,
statistics: GPXStatisticsTree,
point: Coordinates
): [number, number][] {
let segmentBoundsDistances: [number, number, number][] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentStatistics = statistics.getStatisticsFor(
new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex)
);
let segmentBounds = segmentStatistics.global.bounds;
let northEast = segmentBounds.northEast;
let southWest = segmentBounds.southWest;
let bounds = new maplibregl.LngLatBounds(southWest, northEast);
if (bounds.contains(point)) {
segmentBoundsDistances.push([0, trackIndex, segmentIndex]);
} else {
let northWest: Coordinates = { lat: northEast.lat, lon: southWest.lon };
let southEast: Coordinates = { lat: southWest.lat, lon: northEast.lon };
let distanceToBounds = Math.min(
crossarcDistance(northWest, northEast, point),
crossarcDistance(northEast, southEast, point),
crossarcDistance(southEast, southWest, point),
crossarcDistance(southWest, northWest, point)
);
segmentBoundsDistances.push([distanceToBounds, trackIndex, segmentIndex]);
}
});
segmentBoundsDistances.sort((a, b) => a[0] - b[0]);
let closest: { distance: number; indices: [number, number][] } = {
distance: Number.MAX_VALUE,
indices: [],
};
for (let s = 0; s < segmentBoundsDistances.length; s++) {
if (segmentBoundsDistances[s][0] > closest.distance) {
break;
}
const segment = file.getSegment(segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]);
segment.trkpt.forEach((pt) => {
let dist = distance(pt.getCoordinates(), point);
if (dist < closest.distance) {
closest.distance = dist;
closest.indices = [[segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]];
} else if (dist === closest.distance) {
closest.indices.push([segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]);
}
});
}
return closest.indices;
}
export function getElevation(
points: (TrackPoint | Waypoint | Coordinates)[],
points: Coordinates[],
ELEVATION_ZOOM: number = 12,
tileSize = 512
): Promise<number[]> {
let coordinates = points.map((point) =>
point instanceof TrackPoint || point instanceof Waypoint ? point.getCoordinates() : point
);
let coordinates = points;
let bbox = new maplibregl.LngLatBounds();
coordinates.forEach((coord) => bbox.extend(coord));
let tiles = coordinates.map((coord) => pointToTile(coord.lon, coord.lat, ELEVATION_ZOOM));
let tiles = coordinates.map((coord) => pointToTile(coord.lng, coord.lat, ELEVATION_ZOOM));
let uniqueTiles = Array.from(new Set(tiles.map((tile) => tile.join(',')))).map((tile) =>
tile.split(',').map((x) => parseInt(x))
);
@@ -161,7 +78,7 @@ export function getElevation(
return 0;
}
let tf = pointToTileFraction(coord.lon, coord.lat, ELEVATION_ZOOM);
let tf = pointToTileFraction(coord.lng, coord.lat, ELEVATION_ZOOM);
let x = tileSize * (tf[0] - tile[0]);
let y = tileSize * (tf[1] - tile[1]);
let _x = Math.floor(x);
+2 -12
View File
@@ -17,24 +17,14 @@
ExternalLink,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { SelectionStatistics } from '$lib/engine';
import { exampleStatistics } from '$lib/assets/example';
import { getURLForLanguage } from '$lib/utils';
import { writable } from 'svelte/store';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { onDestroy, onMount } from 'svelte';
// TODO the example comes from the previous implementation
let statistics = writable<SelectionStatistics>({
global: { totalDistance: 0, elevationGain: 0, elevationLoss: 0 },
length: 0,
totalDistance: new Float64Array(),
slope: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
});
let statistics = writable(exampleStatistics);
let slicedStatistics = writable(undefined);
let hoveredPoint = writable(null);
let additionalDatasets = writable(['speed', 'atemp']);
@@ -14,15 +14,11 @@
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { loadFiles } from '$lib/logic/file-actions';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { hoveredPoint } from '$lib/logic/statistics';
import { slicedStatistics } from '$lib/logic/selection-statistics';
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
import { engine } from '$lib/engine';
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
import { db } from '$lib/db';
import { fileStateCollection } from '$lib/logic/file-state';
import { browser } from '$app/environment';
const {
treeFileView,
@@ -39,32 +35,26 @@
);
onMount(async () => {
settings.connectToDatabase(db);
fileStateCollection.connectToDatabase(db).then(() => {
let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');
let ids: string[] = JSON.parse(page.url.searchParams.get('ids') || '[]');
let urls: string[] = files.concat(ids.map(getURLForGoogleDriveFile));
settings.connect(await engine.openStorage());
if (urls.length > 0) {
let downloads: Promise<File | null>[] = [];
urls.forEach((url) => {
downloads.push(
fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? ''))
);
});
let files: string[] = JSON.parse(page.url.searchParams.get('files') || '[]');
let ids: string[] = JSON.parse(page.url.searchParams.get('ids') || '[]');
let urls: string[] = files.concat(ids.map(getURLForGoogleDriveFile));
Promise.all(downloads).then((files) => {
loadFiles(files.filter((file) => file !== null));
});
}
});
});
if (urls.length > 0) {
let downloads: Promise<File | null>[] = [];
urls.forEach((url) => {
downloads.push(
fetch(url)
.then((response) => response.blob())
.then((blob) => new File([blob], url.split('/').pop() ?? ''))
);
});
onDestroy(() => {
settings.disconnectFromDatabase();
fileStateCollection.disconnectFromDatabase();
Promise.all(downloads).then((files) => {
loadFiles(files.filter((file) => file !== null));
});
}
});
</script>
-3
View File
@@ -5,8 +5,5 @@ import tailwindcss from '@tailwindcss/vite';
import wasm from 'vite-plugin-wasm';
export default defineConfig({
ssr: {
noExternal: ['gpx'],
},
plugins: [enhancedImages(), tailwindcss(), wasm(), sveltekit()],
});