incyclist-services 1.7.131 → 1.7.133

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/lib/cjs/activities/base/repo/db.js +7 -0
  2. package/lib/cjs/activities/base/utils/activity.js +2 -1
  3. package/lib/cjs/activities/base/utils/helpers.js +2 -2
  4. package/lib/cjs/activities/ride/service.js +5 -3
  5. package/lib/cjs/devices/page/statemachine.js +3 -2
  6. package/lib/cjs/ride/display/service.js +20 -1
  7. package/lib/cjs/ride/route/RLVDisplayService.js +2 -2
  8. package/lib/cjs/ride/route/RouteDisplayService.js +2 -2
  9. package/lib/cjs/routes/base/parsers/gpx.js +1 -0
  10. package/lib/cjs/routes/base/parsers/incyclist.js +12 -3
  11. package/lib/cjs/routes/base/utils/index.js +1 -0
  12. package/lib/cjs/routes/base/utils/smoothing.js +332 -0
  13. package/lib/cjs/routes/list/cards/RouteCard.js +64 -4
  14. package/lib/cjs/routes/list/service.js +85 -1
  15. package/lib/cjs/workouts/ride/service.js +19 -1
  16. package/lib/esm/activities/base/repo/db.js +7 -0
  17. package/lib/esm/activities/base/utils/activity.js +2 -1
  18. package/lib/esm/activities/base/utils/helpers.js +2 -2
  19. package/lib/esm/activities/ride/service.js +5 -3
  20. package/lib/esm/devices/page/statemachine.js +3 -2
  21. package/lib/esm/ride/display/service.js +20 -1
  22. package/lib/esm/ride/route/RLVDisplayService.js +2 -2
  23. package/lib/esm/ride/route/RouteDisplayService.js +2 -2
  24. package/lib/esm/routes/base/parsers/gpx.js +1 -0
  25. package/lib/esm/routes/base/parsers/incyclist.js +12 -3
  26. package/lib/esm/routes/base/utils/index.js +1 -0
  27. package/lib/esm/routes/base/utils/smoothing.js +322 -0
  28. package/lib/esm/routes/list/cards/RouteCard.js +64 -4
  29. package/lib/esm/routes/list/service.js +85 -1
  30. package/lib/esm/workouts/ride/service.js +19 -1
  31. package/lib/types/activities/base/model/index.d.ts +2 -0
  32. package/lib/types/activities/base/repo/db.d.ts +1 -0
  33. package/lib/types/activities/base/repo/types.d.ts +1 -0
  34. package/lib/types/activities/ride/service.d.ts +1 -1
  35. package/lib/types/ride/base/types.d.ts +1 -0
  36. package/lib/types/ride/display/service.d.ts +2 -0
  37. package/lib/types/routes/base/api/types.d.ts +1 -0
  38. package/lib/types/routes/base/parsers/incyclist.d.ts +3 -2
  39. package/lib/types/routes/base/types/index.d.ts +1 -0
  40. package/lib/types/routes/base/utils/index.d.ts +1 -0
  41. package/lib/types/routes/base/utils/smoothing.d.ts +26 -0
  42. package/lib/types/routes/list/cards/RouteCard.d.ts +12 -1
  43. package/lib/types/routes/list/cards/types.d.ts +13 -1
  44. package/lib/types/routes/list/service.d.ts +16 -0
  45. package/lib/types/workouts/ride/service.d.ts +2 -0
  46. package/lib/types/workouts/ride/types.d.ts +1 -0
  47. package/package.json +2 -2
@@ -240,6 +240,7 @@ let ActivitiesRepository = (() => {
240
240
  result = this.checkStartPosFilter(result, criteria);
241
241
  result = this.checkEndPosFilter(result, criteria);
242
242
  result = this.checkRealityFactorFilter(result, criteria);
243
+ result = this.checkSmoothingFilter(result, criteria);
243
244
  result = this.checkIsSavedFilter(result, criteria);
244
245
  result = this.checkUploadStatusFilter(result, criteria);
245
246
  result = this.checkTimeFilter(result, criteria);
@@ -287,6 +288,12 @@ let ActivitiesRepository = (() => {
287
288
  }
288
289
  return result;
289
290
  }
291
+ checkSmoothingFilter(result, criteria) {
292
+ if (result?.length > 0 && criteria?.smoothingLevel !== undefined) {
293
+ result = result.filter(ai => (ai.summary.smoothingLevel ?? 0) === criteria.smoothingLevel);
294
+ }
295
+ return result;
296
+ }
290
297
  checkIsSavedFilter(result, criteria) {
291
298
  if (result?.length > 0 && criteria?.isSaved !== undefined) {
292
299
  result = result.filter(ai => ai.summary.isSaved === criteria.isSaved);
@@ -291,13 +291,14 @@ let Activity = (() => {
291
291
  }
292
292
  createStartSettings() {
293
293
  try {
294
- const { startPos, realityFactor, segment, endPos } = this.details;
294
+ const { startPos, realityFactor, segment, endPos, smoothingLevel } = this.details;
295
295
  const card = this.getRouteCard();
296
296
  const settings = {
297
297
  startPos,
298
298
  endPos,
299
299
  segment,
300
300
  realityFactor,
301
+ smoothingLevel,
301
302
  showPrev: true,
302
303
  type: card?.getCardType() ?? 'Route'
303
304
  };
@@ -18,7 +18,7 @@ exports.getActivityTotalElevation = exports.buildSummary = void 0;
18
18
  __exportStar(require("./activity"), exports);
19
19
  const model_1 = require("../model");
20
20
  const buildSummary = (activity, proposedName) => {
21
- const { id, route, screenshots, startTime: startTimeUTC, time: rideTime, distance, startPos, endPos, realityFactor = 100, links, laps, fileName } = activity;
21
+ const { id, route, screenshots, startTime: startTimeUTC, time: rideTime, distance, startPos, endPos, realityFactor = 100, smoothingLevel, links, laps, fileName } = activity;
22
22
  let name = proposedName ?? activity.name;
23
23
  const routeId = route?.id;
24
24
  const routeHash = route?.hash;
@@ -48,7 +48,7 @@ const buildSummary = (activity, proposedName) => {
48
48
  uploadStatus.push({ service: 'strava', status: 'success' });
49
49
  }
50
50
  return {
51
- id, title, name, routeId, routeHash, previewImage, startTime, rideTime, distance, totalElevation, startPos, endPos, realityFactor, uploadStatus, laps
51
+ id, title, name, routeId, routeHash, previewImage, startTime, rideTime, distance, totalElevation, startPos, endPos, realityFactor, smoothingLevel, uploadStatus, laps
52
52
  };
53
53
  };
54
54
  exports.buildSummary = buildSummary;
@@ -841,7 +841,8 @@ let ActivityRideService = (() => {
841
841
  const { startPos, realityFactor } = settings;
842
842
  const routeId = this.activity.route.id;
843
843
  const routeHash = this.activity.route.hash;
844
- const filter = { routeId, routeHash, startPos, realityFactor, minTime: 30, minDistance: 500 };
844
+ const smoothingLevel = this.getRouteList().getAppliedSmoothingLevel();
845
+ const filter = { routeId, routeHash, startPos, realityFactor, smoothingLevel, minTime: 30, minDistance: 500 };
845
846
  (0, list_1.useActivityList)()
846
847
  .getPastActivitiesWithDetails(filter)
847
848
  .then(prevRides => {
@@ -1025,8 +1026,9 @@ let ActivityRideService = (() => {
1025
1026
  const user = this.getUserSettings().get('user', {});
1026
1027
  const { weight, ftp } = user;
1027
1028
  const uuid = this.getUserSettings().get('uuid', undefined);
1028
- let selectedRoute = this.getRouteList().getSelected()?.clone();
1029
+ let selectedRoute = this.getRouteList().getRideRoute();
1029
1030
  const startSettings = this.getRouteList().getStartSettings();
1031
+ const smoothingLevel = this.getRouteList().getAppliedSmoothingLevel();
1030
1032
  this.tsStart = Date.now();
1031
1033
  const startTime = new Date(this.tsStart).toISOString();
1032
1034
  this.current = { deviceData: {}, dataState: {} };
@@ -1109,7 +1111,7 @@ let ActivityRideService = (() => {
1109
1111
  distance: 0,
1110
1112
  totalElevation: 0,
1111
1113
  logs: [],
1112
- startPos, endPos, segment, realityFactor,
1114
+ startPos, endPos, segment, realityFactor, smoothingLevel,
1113
1115
  fileName,
1114
1116
  sport
1115
1117
  };
@@ -321,8 +321,9 @@ let PairingPageStateMachine = (() => {
321
321
  this.performCheck();
322
322
  }
323
323
  else if (this.state === 'Pairing') {
324
- this.getDevicePairing().stopPairing();
325
- this.selectionTimeout = setTimeout(this.onDeviceSelectionTimeout.bind(this), 1000);
324
+ await this.getDevicePairing().stopPairing();
325
+ this._selectState = 'Active';
326
+ this.performCheck();
326
327
  }
327
328
  }
328
329
  resetTimeouts() {
@@ -308,6 +308,24 @@ let RideDisplayService = (() => {
308
308
  toggleRightSideView() {
309
309
  this.toggleOverlay('sv-right');
310
310
  }
311
+ isOverlayPinned(overlay) {
312
+ try {
313
+ return this.getUserSettings().get(`preferences.rideOverlays.${overlay}.pinned`, false);
314
+ }
315
+ catch (err) {
316
+ this.logError(err, 'isOverlayPinned');
317
+ return false;
318
+ }
319
+ }
320
+ setOverlayPinned(overlay, pinned) {
321
+ try {
322
+ this.getUserSettings().set(`preferences.rideOverlays.${overlay}.pinned`, pinned);
323
+ this.observer?.emit('overlay-update', this.getDisplayProperties());
324
+ }
325
+ catch (err) {
326
+ this.logError(err, 'setOverlayPinned');
327
+ }
328
+ }
311
329
  onArrowKey(event) {
312
330
  try {
313
331
  const { key, shiftKey } = event;
@@ -371,7 +389,8 @@ let RideDisplayService = (() => {
371
389
  hideAll: this.hideAll,
372
390
  showShiftingButtons,
373
391
  showDashboard,
374
- showWorkout
392
+ showWorkout,
393
+ shiftingPinned: this.isOverlayPinned('shifting')
375
394
  };
376
395
  const childProps = this.getRideModeService()?.getDisplayProperties(props) ?? {};
377
396
  return { ...props, ...childProps };
@@ -81,7 +81,7 @@ let RLVDisplayService = (() => {
81
81
  this.currentVideo = undefined;
82
82
  this.videos = [];
83
83
  delete this.startTime;
84
- this.addVideo(this.getOriginalRoute(), true).then(() => {
84
+ this.addVideo(this.getRouteList().getRideRoute(), true).then(() => {
85
85
  this.videosInitialized = true;
86
86
  this.emit('state-update');
87
87
  });
@@ -559,7 +559,7 @@ let RLVDisplayService = (() => {
559
559
  }
560
560
  }
561
561
  const current = this.videos.find(v => v.route?.details?.id === route.details.id);
562
- const nextRoute = this.getRouteList().getRoute(nextId);
562
+ const nextRoute = this.getRouteList().getRideRoute(nextId);
563
563
  if (!nextRoute) {
564
564
  return false;
565
565
  }
@@ -97,7 +97,7 @@ let RouteDisplayService = (() => {
97
97
  }
98
98
  getDeviceStartSettings() {
99
99
  const startSettings = this.getRouteList().getStartSettings();
100
- const route = this.getRouteList().getSelected();
100
+ const route = this.getRouteList().getRideRoute();
101
101
  const { realityFactor = 100, startPos = 0 } = startSettings;
102
102
  return { realityFactor, startPos, route };
103
103
  }
@@ -253,7 +253,7 @@ let RouteDisplayService = (() => {
253
253
  }
254
254
  }
255
255
  initRoute() {
256
- this.currentRoute = this.getRouteList().getSelected().clone();
256
+ this.currentRoute = this.getRouteList().getRideRoute();
257
257
  }
258
258
  initView() { }
259
259
  get route() {
@@ -207,6 +207,7 @@ class GPXParser extends xml_1.XMLParser {
207
207
  points: route.points,
208
208
  requiresDownload: false,
209
209
  hasGpx: route.points?.length > 0,
210
+ pointsSource: 'gpx',
210
211
  hasVideo: false,
211
212
  isDemo: false,
212
213
  isLocal: true,
@@ -14,6 +14,11 @@ class IncyclistXMLParser extends xml_1.XMLParser {
14
14
  super();
15
15
  this.logger = new gd_eventlog_1.EventLogger('IncyclistParser');
16
16
  }
17
+ async buildInfo(context) {
18
+ const info = await super.buildInfo(context);
19
+ info.pointsSource = 'gpx';
20
+ return info;
21
+ }
17
22
  async loadDescription(context) {
18
23
  await super.loadDescription(context);
19
24
  const title = context.data['title'];
@@ -159,12 +164,15 @@ class IncyclistXMLParser extends xml_1.XMLParser {
159
164
  if (data['cuts']) {
160
165
  this.processCuts(data, route);
161
166
  }
162
- if (data['elevation-shift'])
163
- this.processElevationShift(Number(data['elevation-shift']), route.points);
167
+ if (data['elevation-shift']) {
168
+ const applied = this.processElevationShift(Number(data['elevation-shift']), route.points);
169
+ if (applied)
170
+ route.elevationShifted = true;
171
+ }
164
172
  }
165
173
  processElevationShift(elevationShift, points) {
166
174
  if (!elevationShift || !points || points.length < 2 || points.length < elevationShift)
167
- return;
175
+ return false;
168
176
  const cuts = points.filter(p => p.isCut) || [];
169
177
  const lastPoints = [];
170
178
  cuts.forEach(cut => {
@@ -189,6 +197,7 @@ class IncyclistXMLParser extends xml_1.XMLParser {
189
197
  idx++;
190
198
  }
191
199
  });
200
+ return true;
192
201
  }
193
202
  }
194
203
  exports.IncyclistXMLParser = IncyclistXMLParser;
@@ -15,3 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./route"), exports);
18
+ __exportStar(require("./smoothing"), exports);
@@ -0,0 +1,332 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.analyseElevationNoise = exports.getSmoothingGradient = exports.getSteepestGradient = exports.isSmoothingEligible = exports.applySmoothing = exports.smoothElevation = exports.getSmoothingProfile = exports.MAX_SMOOTHING_LEVEL = void 0;
4
+ const route_1 = require("./route");
5
+ const NOISE_REFERENCE_LEVEL = 3;
6
+ const PROFILES = {
7
+ 1: { windowLength: 30, passes: 1, spikeThreshold: 5 },
8
+ 2: { windowLength: 60, passes: 1, spikeThreshold: 4 },
9
+ 3: { windowLength: 120, passes: 2, spikeThreshold: 3 },
10
+ 4: { windowLength: 250, passes: 2, spikeThreshold: 3 },
11
+ 5: { windowLength: 500, passes: 2, spikeThreshold: 2 },
12
+ };
13
+ exports.MAX_SMOOTHING_LEVEL = 5;
14
+ const NO_OP_PROFILE = {
15
+ windowLength: 0,
16
+ passes: 1,
17
+ spikeThreshold: Number.POSITIVE_INFINITY,
18
+ };
19
+ const getSmoothingProfile = (level) => {
20
+ if (!Number.isFinite(level))
21
+ return { ...NO_OP_PROFILE };
22
+ const rounded = Math.round(level);
23
+ if (rounded < 1)
24
+ return { ...NO_OP_PROFILE };
25
+ const clamped = Math.min(rounded, exports.MAX_SMOOTHING_LEVEL);
26
+ return { ...PROFILES[clamped] };
27
+ };
28
+ exports.getSmoothingProfile = getSmoothingProfile;
29
+ const median3 = (a, b, c) => Math.max(Math.min(a, b), Math.min(Math.max(a, b), c));
30
+ const getSegmentRanges = (points) => {
31
+ const ranges = [];
32
+ let start = 0;
33
+ for (let i = 1; i < points.length; i++) {
34
+ if (points[i]?.isCut === true) {
35
+ ranges.push([start, i]);
36
+ start = i;
37
+ }
38
+ }
39
+ ranges.push([start, points.length]);
40
+ return ranges;
41
+ };
42
+ const isUsable = (points, start, end) => {
43
+ for (let i = start; i < end; i++) {
44
+ const p = points[i];
45
+ if (!Number.isFinite(p?.elevation) || !Number.isFinite(p?.routeDistance))
46
+ return false;
47
+ }
48
+ return true;
49
+ };
50
+ const despikeRange = (points, start, end, threshold) => {
51
+ if (end - start < 3 || !Number.isFinite(threshold))
52
+ return;
53
+ const original = new Array(end - start);
54
+ for (let i = start; i < end; i++)
55
+ original[i - start] = points[i].elevation;
56
+ for (let i = start + 1; i < end - 1; i++) {
57
+ const m = median3(original[i - 1 - start], original[i - start], original[i + 1 - start]);
58
+ if (Math.abs(original[i - start] - m) > threshold)
59
+ points[i].elevation = m;
60
+ }
61
+ };
62
+ const boxFilterRange = (points, start, end, windowLength) => {
63
+ const n = end - start;
64
+ if (n < 3 || windowLength <= 0)
65
+ return;
66
+ const d = new Array(n);
67
+ const e = new Array(n);
68
+ for (let i = 0; i < n; i++) {
69
+ d[i] = points[start + i].routeDistance - points[start].routeDistance;
70
+ e[i] = points[start + i].elevation;
71
+ }
72
+ const total = d[n - 1];
73
+ if (total <= 0)
74
+ return;
75
+ const cum = new Array(n);
76
+ cum[0] = 0;
77
+ for (let i = 1; i < n; i++)
78
+ cum[i] = cum[i - 1] + ((e[i - 1] + e[i]) / 2) * (d[i] - d[i - 1]);
79
+ const cursor = { lower: 0, upper: 0 };
80
+ const integralAt = (x, which) => {
81
+ let j = cursor[which];
82
+ while (j < n - 2 && d[j + 1] < x)
83
+ j++;
84
+ cursor[which] = j;
85
+ if (x <= d[j])
86
+ return cum[j];
87
+ if (x >= d[j + 1])
88
+ return cum[j + 1];
89
+ const span = d[j + 1] - d[j];
90
+ const t = span > 0 ? (x - d[j]) / span : 0;
91
+ const ex = e[j] + (e[j + 1] - e[j]) * t;
92
+ return cum[j] + ((e[j] + ex) / 2) * (x - d[j]);
93
+ };
94
+ const smoothed = new Array(n);
95
+ for (let i = 0; i < n; i++) {
96
+ const w = Math.min(windowLength, 2 * d[i], 2 * (total - d[i]));
97
+ if (w <= 0) {
98
+ smoothed[i] = e[i];
99
+ continue;
100
+ }
101
+ const value = (integralAt(d[i] + w / 2, 'upper') - integralAt(d[i] - w / 2, 'lower')) / w;
102
+ smoothed[i] = Number.isFinite(value) ? value : e[i];
103
+ }
104
+ for (let i = 0; i < n; i++)
105
+ points[start + i].elevation = smoothed[i];
106
+ };
107
+ const LAG_SHIFT_RATIO = 0.15;
108
+ const getLagShiftDistance = (opts) => opts.passes === 2 ? opts.windowLength * LAG_SHIFT_RATIO : 0;
109
+ const buildElevationLookup = (d, e) => {
110
+ const n = d.length;
111
+ let cursor = 0;
112
+ return (x) => {
113
+ if (x >= d[n - 1]) {
114
+ const span = d[n - 1] - d[n - 2];
115
+ const slope = span > 0 ? (e[n - 1] - e[n - 2]) / span : 0;
116
+ return e[n - 1] + slope * (x - d[n - 1]);
117
+ }
118
+ while (cursor < n - 2 && d[cursor + 1] < x)
119
+ cursor++;
120
+ if (x <= d[cursor])
121
+ return e[cursor];
122
+ const span = d[cursor + 1] - d[cursor];
123
+ const t = span > 0 ? (x - d[cursor]) / span : 0;
124
+ return e[cursor] + (e[cursor + 1] - e[cursor]) * t;
125
+ };
126
+ };
127
+ const applyLagShift = (points, start, end, shiftDistance) => {
128
+ const n = end - start;
129
+ if (n < 2 || shiftDistance <= 0)
130
+ return;
131
+ const d = new Array(n);
132
+ const e = new Array(n);
133
+ for (let i = 0; i < n; i++) {
134
+ d[i] = points[start + i].routeDistance - points[start].routeDistance;
135
+ e[i] = points[start + i].elevation;
136
+ }
137
+ if (d[n - 1] <= 0)
138
+ return;
139
+ const elevationAt = buildElevationLookup(d, e);
140
+ const reference = elevationAt(shiftDistance);
141
+ for (let i = 0; i < n; i++) {
142
+ points[start + i].elevation = e[0] + elevationAt(d[i] + shiftDistance) - reference;
143
+ }
144
+ };
145
+ const LOOP_PADDING_DISTANCE = 800;
146
+ const padForLoop = (points, padDistance) => {
147
+ const total = points.at(-1)?.routeDistance ?? 0;
148
+ if (total <= 0)
149
+ return { padded: points, headCount: 0 };
150
+ const tail = points.filter((p) => total - p.routeDistance <= padDistance);
151
+ const head = points.filter((p) => p.routeDistance <= padDistance);
152
+ const before = tail.map((p) => ({ ...p, routeDistance: p.routeDistance - total, isCut: false }));
153
+ const after = head.map((p) => ({ ...p, routeDistance: p.routeDistance + total, isCut: false }));
154
+ return { padded: [...before, ...points, ...after], headCount: before.length };
155
+ };
156
+ const smoothElevation = (points, opts) => {
157
+ if (!points?.length)
158
+ return [];
159
+ const result = points.map((p) => ({ ...p }));
160
+ if (!opts)
161
+ return result;
162
+ const passes = opts.passes === 2 ? 2 : 1;
163
+ const lagShiftDistance = getLagShiftDistance(opts);
164
+ getSegmentRanges(result).forEach(([start, end]) => {
165
+ if (!isUsable(result, start, end))
166
+ return;
167
+ despikeRange(result, start, end, opts.spikeThreshold);
168
+ for (let pass = 0; pass < passes; pass++)
169
+ boxFilterRange(result, start, end, opts.windowLength);
170
+ applyLagShift(result, start, end, lagShiftDistance);
171
+ });
172
+ return result;
173
+ };
174
+ exports.smoothElevation = smoothElevation;
175
+ const applySmoothing = (route, level) => {
176
+ const smoothed = route?.clone();
177
+ if (!smoothed)
178
+ return smoothed;
179
+ if (!Number.isFinite(level) || level < 1)
180
+ return smoothed;
181
+ const source = smoothed.details?.points ?? smoothed.description?.points;
182
+ if (!source?.length)
183
+ return smoothed;
184
+ const profile = (0, exports.getSmoothingProfile)(level);
185
+ let points;
186
+ if ((0, route_1.checkIsLoop)(source)) {
187
+ const { padded, headCount } = padForLoop(source, LOOP_PADDING_DISTANCE);
188
+ points = (0, exports.smoothElevation)(padded, profile).slice(headCount, headCount + source.length);
189
+ }
190
+ else {
191
+ points = (0, exports.smoothElevation)(source, profile);
192
+ }
193
+ if (smoothed.details?.points)
194
+ smoothed.details.points = points;
195
+ if (smoothed.description?.points)
196
+ smoothed.description.points = points;
197
+ points.forEach((p) => {
198
+ delete p.slope;
199
+ delete p.elevationGain;
200
+ });
201
+ if (points.some((p) => !Number.isFinite(p.cnt)))
202
+ (0, route_1.updateCnt)(points);
203
+ (0, route_1.updateSlopes)(points, false);
204
+ (0, route_1.updateElevationGain)(points);
205
+ const elevation = points.at(-1)?.elevationGain;
206
+ if (Number.isFinite(elevation)) {
207
+ if (smoothed.description)
208
+ smoothed.description.elevation = elevation;
209
+ if (smoothed.details)
210
+ smoothed.details.elevation = elevation;
211
+ }
212
+ return smoothed;
213
+ };
214
+ exports.applySmoothing = applySmoothing;
215
+ const MIN_ELIGIBLE_POINTS = 8;
216
+ const getRoutePoints = (route) => {
217
+ const points = route?.points ?? route?.details?.points ?? route?.description?.points;
218
+ return Array.isArray(points) ? points : [];
219
+ };
220
+ const isVideoRoute = (route) => route?.description?.hasVideo === true || route?.details?.video !== undefined;
221
+ const isInScopeVideoRoute = (route) => {
222
+ if (route?.description?.pointsSource === 'gpx')
223
+ return true;
224
+ const points = route?.details?.points;
225
+ if (!Array.isArray(points))
226
+ return false;
227
+ return points.some((p) => p?.isCut === true) || points.some((p) => Number.isFinite(p?.time));
228
+ };
229
+ const isSmoothingEligible = (route) => {
230
+ try {
231
+ const points = getRoutePoints(route);
232
+ if (points.length < MIN_ELIGIBLE_POINTS)
233
+ return false;
234
+ if (!points.every((p) => Number.isFinite(p?.elevation)))
235
+ return false;
236
+ if (new Set(points.map((p) => Math.round(p.elevation))).size < 2)
237
+ return false;
238
+ if (!points.some((p) => p?.lat && p?.lng))
239
+ return false;
240
+ if (route?.description?.hasGpx === false)
241
+ return false;
242
+ if (route?.details?.gpxDisabled)
243
+ return false;
244
+ if (route?.details?.elevationShifted)
245
+ return false;
246
+ if (route?.details?.epp)
247
+ return false;
248
+ return !isVideoRoute(route) || isInScopeVideoRoute(route);
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ };
254
+ exports.isSmoothingEligible = isSmoothingEligible;
255
+ const getSteepestGradient = (points) => {
256
+ if (!Array.isArray(points))
257
+ return 0;
258
+ let steepest = 0;
259
+ for (const p of points) {
260
+ const slope = p?.slope;
261
+ if (!Number.isFinite(slope))
262
+ continue;
263
+ const magnitude = Math.abs(slope);
264
+ if (magnitude > steepest)
265
+ steepest = magnitude;
266
+ }
267
+ return steepest;
268
+ };
269
+ exports.getSteepestGradient = getSteepestGradient;
270
+ const MIN_VISIBLE_GRADIENT_DELTA = 0.5;
271
+ const MIN_VISIBLE_GAIN_RATIO = 0.02;
272
+ const getSmoothingGradient = (routePoints, smoothedPoints, routeGain, smoothedGain) => {
273
+ const routeSteepest = (0, exports.getSteepestGradient)(routePoints);
274
+ const smoothedSteepest = (0, exports.getSteepestGradient)(smoothedPoints);
275
+ const gradientDelta = Math.abs(routeSteepest - smoothedSteepest);
276
+ const gainRatio = Number.isFinite(routeGain) && routeGain > 0 && Number.isFinite(smoothedGain)
277
+ ? Math.abs(routeGain - smoothedGain) / routeGain
278
+ : 0;
279
+ const hasVisibleEffect = gradientDelta >= MIN_VISIBLE_GRADIENT_DELTA || gainRatio >= MIN_VISIBLE_GAIN_RATIO;
280
+ return { routeSteepest, smoothedSteepest, hasVisibleEffect };
281
+ };
282
+ exports.getSmoothingGradient = getSmoothingGradient;
283
+ const getElevationGain = (points) => {
284
+ let gain = 0;
285
+ for (let i = 1; i < points.length; i++) {
286
+ const delta = points[i].elevation - points[i - 1].elevation;
287
+ if (delta > 0)
288
+ gain += delta;
289
+ }
290
+ return gain;
291
+ };
292
+ const getMedian = (values) => {
293
+ if (!values.length)
294
+ return 0;
295
+ const sorted = [...values].sort((a, b) => a - b);
296
+ const mid = Math.floor(sorted.length / 2);
297
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
298
+ };
299
+ const analyseElevationNoise = (points) => {
300
+ const empty = { gainRatio: 1, reversalDensity: 0, pointCount: 0, medianSpacing: 0 };
301
+ if (!points?.length)
302
+ return empty;
303
+ const usable = points.filter((p) => Number.isFinite(p?.elevation) && Number.isFinite(p?.routeDistance));
304
+ if (usable.length < 2)
305
+ return { ...empty, pointCount: points.length };
306
+ const spacings = [];
307
+ let reversals = 0;
308
+ let prevSign = 0;
309
+ for (let i = 1; i < usable.length; i++) {
310
+ const dd = usable[i].routeDistance - usable[i - 1].routeDistance;
311
+ if (dd > 0)
312
+ spacings.push(dd);
313
+ const de = usable[i].elevation - usable[i - 1].elevation;
314
+ const sign = Math.sign(de);
315
+ if (sign !== 0) {
316
+ if (prevSign !== 0 && sign !== prevSign)
317
+ reversals++;
318
+ prevSign = sign;
319
+ }
320
+ }
321
+ const distanceKm = (usable.at(-1).routeDistance - usable[0].routeDistance) / 1000;
322
+ const rawGain = getElevationGain(usable);
323
+ const smoothedGain = getElevationGain((0, exports.smoothElevation)(usable, (0, exports.getSmoothingProfile)(NOISE_REFERENCE_LEVEL)));
324
+ const gainRatio = smoothedGain > 0 ? rawGain / smoothedGain : 1;
325
+ return {
326
+ gainRatio,
327
+ reversalDensity: distanceKm > 0 ? reversals / distanceKm : 0,
328
+ pointCount: points.length,
329
+ medianSpacing: getMedian(spacings),
330
+ };
331
+ };
332
+ exports.analyseElevationNoise = analyseElevationNoise;
@@ -47,6 +47,7 @@ const utils_1 = require("../../../utils");
47
47
  const service_2 = require("../../download/service");
48
48
  const gd_eventlog_1 = require("gd-eventlog");
49
49
  const route_1 = require("../../base/utils/route");
50
+ const smoothing_1 = require("../../base/utils/smoothing");
50
51
  const workouts_1 = require("../../../workouts");
51
52
  const utils_2 = require("../utils");
52
53
  const monitoring_1 = require("../../../monitoring");
@@ -103,6 +104,7 @@ let RouteCard = (() => {
103
104
  ready;
104
105
  logger;
105
106
  cntActive = 0;
107
+ smoothingPreview;
106
108
  constructor(route, props) {
107
109
  super();
108
110
  const { list } = props || {};
@@ -412,8 +414,11 @@ let RouteCard = (() => {
412
414
  const totalElevation = { value: C(this.route.description.elevation, 'elevation', { digits: 0 }), unit: uEl };
413
415
  const xScale = { value: C(1, 'distance'), unit: uDist };
414
416
  const yScale = { value: C(1, 'elevation'), unit: uEl };
417
+ const smoothingAvailable = this.isSmoothingAvailable();
418
+ const { smoothedPoints, smoothedElevation, smoothedGradient } = this.getSmoothingPreview(uiSettings.smoothingLevel);
415
419
  return { settings: uiSettings, totalDistance, totalElevation, showLoopOverwrite, showNextOverwrite, hasWorkout, showWorkoutOption, canStart, videoChecking, videoMissing, detailsAvailable,
416
420
  xScale, yScale,
421
+ smoothingAvailable, smoothingMaxLevel: smoothing_1.MAX_SMOOTHING_LEVEL, smoothedPoints, smoothedElevation, smoothedGradient,
417
422
  updateStartPos: this.updateStartPos.bind(this),
418
423
  updateMarkers: this.updateMarkers.bind(this)
419
424
  };
@@ -446,19 +451,21 @@ let RouteCard = (() => {
446
451
  delete props.prevRides;
447
452
  if (isUI) {
448
453
  const uiProps = props;
449
- const { realityFactor, segment, showPrev, loopOverwrite, nextOverwrite } = uiProps;
454
+ const { realityFactor, segment, showPrev, loopOverwrite, nextOverwrite, smoothingLevel } = uiProps;
450
455
  const startPos = C(uiProps.startPos.value, 'distance', { from: U('distance'), to: 'm' }) ?? 0;
451
456
  const endPos = uiProps.endPos === undefined ? undefined : C(uiProps.endPos.value, 'distance', { from: U('distance'), to: 'm' });
452
457
  this.startSettings = {
453
458
  ...this.startSettings,
454
- startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite
459
+ startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite,
460
+ smoothingLevel: smoothingLevel ?? this.startSettings?.smoothingLevel
455
461
  };
456
462
  }
457
463
  else {
458
- const { startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite } = props;
464
+ const { startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite, smoothingLevel } = props;
459
465
  this.startSettings = {
460
466
  ...this.startSettings,
461
- startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite
467
+ startPos, endPos, realityFactor, segment, showPrev, loopOverwrite, nextOverwrite,
468
+ smoothingLevel: smoothingLevel ?? this.startSettings?.smoothingLevel
462
469
  };
463
470
  }
464
471
  const userSettings = (0, settings_1.useUserSettings)();
@@ -470,6 +477,59 @@ let RouteCard = (() => {
470
477
  this.logError(err, 'changeSettings');
471
478
  }
472
479
  }
480
+ getSmoothingPreview(level) {
481
+ const none = {};
482
+ try {
483
+ if (!Number.isFinite(level) || level < 1)
484
+ return none;
485
+ if (!this.isSmoothingAvailable())
486
+ return none;
487
+ const key = `${this.getRouteDescription()?.id}:${Math.round(level)}`;
488
+ if (this.smoothingPreview?.key !== key) {
489
+ const smoothed = (0, smoothing_1.applySmoothing)(this.route, level);
490
+ const points = smoothed?.points;
491
+ if (!points?.length)
492
+ return none;
493
+ const elevation = smoothed.description?.elevation ?? smoothed.details?.elevation;
494
+ const routeElevation = this.route.description?.elevation ?? this.route.details?.elevation;
495
+ this.smoothingPreview = {
496
+ key,
497
+ points,
498
+ elevation,
499
+ gradient: (0, smoothing_1.getSmoothingGradient)(this.route.points, points, routeElevation, elevation)
500
+ };
501
+ }
502
+ const { points: smoothedPoints, elevation, gradient } = this.smoothingPreview;
503
+ const [C, U] = (0, i18n_1.getUnitConversionShortcuts)();
504
+ return {
505
+ smoothedPoints,
506
+ smoothedElevation: Number.isFinite(elevation)
507
+ ? { value: C(elevation, 'elevation', { digits: 0 }), unit: U('elevation') }
508
+ : undefined,
509
+ smoothedGradient: gradient
510
+ };
511
+ }
512
+ catch (err) {
513
+ this.logError(err, 'getSmoothingPreview');
514
+ return none;
515
+ }
516
+ }
517
+ isSmoothingAvailable() {
518
+ return (0, smoothing_1.isSmoothingEligible)(this.route);
519
+ }
520
+ getPrevRidesFilter(settings) {
521
+ const routeHash = this.getRouteDescription()?.routeHash;
522
+ const routeId = routeHash ? undefined : this.getRouteDescription()?.id;
523
+ const smoothingLevel = this.isSmoothingAvailable() ? (settings.smoothingLevel ?? 0) : 0;
524
+ return {
525
+ routeHash,
526
+ routeId,
527
+ startPos: settings.startPos,
528
+ endPos: settings.endPos,
529
+ realityFactor: settings.realityFactor,
530
+ smoothingLevel,
531
+ };
532
+ }
473
533
  adjustStartPosAvi(settings) {
474
534
  try {
475
535
  if (this.route.description.videoFormat === 'avi') {