time tool

This commit is contained in:
vcoppe committed 2026-10-08 08:12:44 +02:00
1 parent 82d2f79da0
commit 63ba8596a7
7 files changed
+371 -111

No files matched your search

+34 -2
View File
@@ -82,6 +82,38 @@ pub fn replace_trackpoints(
trkpt.splice(start..end, points); trkpt.splice(start..end, points);
} }
/// How long each interval between two consecutive trackpoints of a segment should last, relative
/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the
/// longer. `slopes` has the slope (in %) at each trackpoint.
pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec<f64> {
points
.windows(2)
.zip(slopes)
.map(|(pair, slope)| {
let km = distance(pair[0].coordinates, pair[1].coordinates);
km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp()))
})
.collect()
}
/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the
/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]).
pub fn with_artificial_timestamps(
mut points: Vec<Trackpoint>,
weights: &[f64],
ms_per_weight: f64,
start: i64,
) -> Vec<Trackpoint> {
let mut elapsed = 0.0;
for (i, point) in points.iter_mut().enumerate() {
if i > 0 {
elapsed += weights[i - 1] * ms_per_weight;
}
point.time = Some(start + elapsed as i64);
}
points
}
fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool { fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool {
a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat
} }
@@ -111,7 +143,7 @@ fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option<f64>) -> Option
/// ///
/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy /// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy
/// used here. /// used here.
fn with_timestamps( pub fn with_timestamps(
points: Vec<Trackpoint>, points: Vec<Trackpoint>,
speed: Option<f64>, speed: Option<f64>,
last: Option<&Trackpoint>, last: Option<&Trackpoint>,
@@ -141,7 +173,7 @@ fn with_timestamps(
/// `points` with their timestamps moved so that the first one follows `last`, and their durations /// `points` with their timestamps moved so that the first one follows `last`, and their durations
/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in /// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in
/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known. /// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known.
fn shifted_and_compressed( pub fn shifted_and_compressed(
points: Vec<Trackpoint>, points: Vec<Trackpoint>,
speed: Option<f64>, speed: Option<f64>,
ratio: f64, ratio: f64,
+249 -5
View File
@@ -1,11 +1,255 @@
use crate::{Apply, CommandError, State}; use std::rc::Rc;
use crate::{
Apply, CommandError, File, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, artificial_weights,
shifted_and_compressed, with_artificial_timestamps, with_timestamps,
};
/// How the timestamps of the selection are set.
#[derive(Debug, Clone, Copy)]
pub enum TimeKind {
/// Starts at `Time::start_time`. The trackpoints that have timestamps keep their durations
/// multiplied by `ratio`, and the ones that have none follow the previous one at `speed`
/// (km/h).
Change { speed: f64, ratio: f64 },
/// Makes timestamps up for the whole selection, which lasts `total_time` seconds: the longer
/// and the steeper an interval, the more time it takes.
Artificial { total_time: f64 },
}
/// Sets the timestamps of the selected segments, one after the other: each one goes on from the
/// end of the previous one, the first one starts at `start_time` (in milliseconds).
#[derive(Debug)] #[derive(Debug)]
pub struct Time; pub struct Time {
pub start_time: i64,
pub kind: TimeKind,
}
impl Apply for Time { impl Apply for Time {
fn apply(self, _state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
// TODO match self.kind {
Err(CommandError::NotImplemented("time")) TimeKind::Change { speed, ratio } => {
if !(speed.is_finite() && speed > 0.0 && ratio.is_finite() && ratio > 0.0) {
return Err(CommandError::InvalidData(format!(
"speed {speed} and ratio {ratio} should be positive"
)));
}
}
TimeKind::Artificial { total_time } => {
if !(total_time.is_finite() && total_time >= 0.0) {
return Err(CommandError::InvalidData(format!(
"total time {total_time} should not be negative"
)));
}
}
}
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let segment_at = |location: &SegmentLocation| -> &TrackSegment {
&state.files[&location.file_id].trk[location.trk].trkseg[location.seg]
};
if locations.iter().all(|location| segment_at(location).is_empty()) {
return Err(CommandError::NothingToDo);
}
// the new timestamps of each segment
let mut times: Vec<Vec<Option<i64>>> = Vec::with_capacity(locations.len());
match self.kind {
TimeKind::Change { speed, ratio } => {
let mut last: Option<Trackpoint> = None;
for location in &locations {
let points: Vec<Trackpoint> = segment_at(location).iter().cloned().collect();
let Some(first) = points.first() else {
times.push(vec![]);
continue;
};
let start = last.clone().unwrap_or_else(|| {
let mut start = first.clone();
start.time = Some(self.start_time);
start
});
let points = if first.time.is_none() {
with_timestamps(points, Some(speed), Some(&start), Some(self.start_time))
} else {
shifted_and_compressed(points, Some(speed), ratio, &start)
};
last = points.last().cloned();
times.push(points.iter().map(|point| point.time).collect());
}
}
TimeKind::Artificial { total_time } => {
let mut weights: Vec<Vec<f64>> = locations
.iter()
.map(|location| {
let segment = segment_at(location);
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
let stats = Statistics::compute(segment);
let slopes: Vec<f64> = stats.local.iter().map(|l| l.slope).collect();
artificial_weights(&points, &slopes)
})
.collect();
let mut total: f64 = weights.iter().flatten().sum();
if !(total > 0.0) {
// no distance to share the time by: every interval takes as long
for weights in &mut weights {
weights.iter_mut().for_each(|w| *w = 1.0);
}
total = weights.iter().map(Vec::len).sum::<usize>() as f64;
}
let ms_per_weight = if total > 0.0 {
total_time * 1000.0 / total
} else {
0.0
};
let mut start = self.start_time;
for (location, weights) in locations.iter().zip(&weights) {
let points: Vec<Trackpoint> = segment_at(location).iter().cloned().collect();
if points.is_empty() {
times.push(vec![]);
continue;
}
let points = with_artificial_timestamps(points, weights, ms_per_weight, start);
// the next segment goes on from the end of this one
start = points.last().and_then(|point| point.time).unwrap_or(start);
times.push(points.iter().map(|point| point.time).collect());
}
}
}
// the segments of a file are consecutive
let mut times = times.into_iter();
for group in locations.chunk_by(|a, b| a.file_id == b.file_id) {
let file_id = group[0].file_id;
let mut file: File = (*state.files[&file_id]).clone();
for location in group {
let times = times.next().unwrap_or_default();
let segment = &mut file.trk[location.trk].trkseg[location.seg];
segment.update_all(|i, pt| pt.time = times.get(i).copied().flatten());
segment.rev_id = Default::default();
}
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn all_times(fx: &Fixture, id: FileId) -> Vec<Option<i64>> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| trk.trkseg.iter().flat_map(|seg| seg.iter().map(|pt| pt.time)))
.collect()
}
#[test]
fn test_artificial_timestamps_last_the_total_time() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
Time {
start_time: 1_000_000,
kind: TimeKind::Artificial { total_time: 3600.0 },
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 1_000_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!((times.last().unwrap() - 1_000_000 - 3_600_000).abs() <= times.len() as i64);
}
#[test]
fn test_change_starts_at_the_start_time_and_follows_the_speed() {
let (mut fx, id) = loaded("data/simple.gpx");
Time {
start_time: 5_000,
kind: TimeKind::Change {
speed: 10.0,
ratio: 1.0,
},
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 5_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!(times.last() > times.first());
}
#[test]
fn test_change_shifts_existing_timestamps_and_scales_them() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 10.0,
ratio: 2.0,
},
}
.apply(&mut fx.state())
.unwrap();
let after: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(after[0], 0);
let (d_before, d_after) = (before[1] - before[0], after[1] - after[0]);
assert!((d_after - 2 * d_before).abs() <= 1);
}
#[test]
fn test_invalid_input_and_empty_selection() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = all_times(&fx, id);
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 0.0,
ratio: 1.0
}
}
.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(all_times(&fx, id), before);
fx.selection = Selection::Empty;
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Artificial { total_time: 10.0 }
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
} }
} }
+19 -3
View File
@@ -613,9 +613,25 @@ pub fn split(revision: u32, at: u32, split_type: SplitType) -> bool {
})) }))
} }
#[wasm_bindgen] /// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
pub fn time() -> bool { /// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
edit(Command::Time(engine::Time)) /// previous one at `speed` (km/h).
#[wasm_bindgen(js_name = changeTimestamps)]
pub fn change_timestamps(start_time: f64, speed: f64, ratio: f64) -> bool {
edit(Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Change { speed, ratio },
}))
}
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
/// lasts `total_time` seconds.
#[wasm_bindgen(js_name = createArtificialTimestamps)]
pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> bool {
edit(Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Artificial { total_time },
}))
} }
/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts /// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts
-1
View File
@@ -63,7 +63,6 @@
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { allHidden } from '$lib/all-hidden'; import { allHidden } from '$lib/all-hidden';
import { selectionSize } from '$lib/selection-helpers'; import { selectionSize } from '$lib/selection-helpers';
// TODO centering on the selection still works on the previous implementation
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -33,7 +33,6 @@
let opacityChanged = $state(false); let opacityChanged = $state(false);
let widthChanged = $state(false); let widthChanged = $state(false);
// TODO the inputs used to come from the last selected item, they now come from this node
function setStyleInputs() { function setStyleInputs() {
opacity = $defaultOpacity; opacity = $defaultOpacity;
width = $defaultWidth; width = $defaultWidth;
@@ -15,23 +15,19 @@
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte'; import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import {
ListFileItem,
ListRootItem,
ListTrackItem,
ListTrackSegmentItem,
} from '$lib/components/file-list/file-list';
import Help from '$lib/components/Help.svelte'; import Help from '$lib/components/Help.svelte';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { fileActionManager } from '$lib/logic/file-action-manager'; import { engine } from '$lib/engine';
import { gpxStatistics } from '$lib/logic/statistics';
let props: { let props: {
class?: string; class?: string;
} = $props(); } = $props();
const selection = engine.selection;
const statistics = engine.statistics;
let global = $derived($statistics.global);
let startDate: DateValue | undefined = $state(undefined); let startDate: DateValue | undefined = $state(undefined);
let startTime: string | undefined = $state(undefined); let startTime: string | undefined = $state(undefined);
let endDate: DateValue | undefined = $state(undefined); let endDate: DateValue | undefined = $state(undefined);
@@ -59,26 +55,28 @@
} }
function setGPXData() { function setGPXData() {
if ($gpxStatistics.global.time.start) { if (global.startTime !== undefined) {
startDate = toCalendarDate($gpxStatistics.global.time.start); const start = new Date(global.startTime);
startTime = toTimeString($gpxStatistics.global.time.start); startDate = toCalendarDate(start);
startTime = toTimeString(start);
} else { } else {
startDate = undefined; startDate = undefined;
startTime = undefined; startTime = undefined;
} }
if ($gpxStatistics.global.time.end) { if (global.endTime !== undefined) {
endDate = toCalendarDate($gpxStatistics.global.time.end); const end = new Date(global.endTime);
endTime = toTimeString($gpxStatistics.global.time.end); endDate = toCalendarDate(end);
endTime = toTimeString(end);
} else { } else {
endDate = undefined; endDate = undefined;
endTime = undefined; endTime = undefined;
} }
if ($gpxStatistics.global.time.moving && $gpxStatistics.global.speed.moving) { if (global.movingTime && global.movingSpeed) {
movingTime = $gpxStatistics.global.time.moving; movingTime = global.movingTime;
setSpeed($gpxStatistics.global.speed.moving); setSpeed(global.movingSpeed);
} else if ($gpxStatistics.global.time.total && $gpxStatistics.global.speed.total) { } else if (global.totalTime && global.totalSpeed) {
movingTime = $gpxStatistics.global.time.total; movingTime = global.totalTime;
setSpeed($gpxStatistics.global.speed.total); setSpeed(global.totalSpeed);
} else { } else {
movingTime = undefined; movingTime = undefined;
speed = undefined; speed = undefined;
@@ -86,7 +84,7 @@
} }
$effect(() => { $effect(() => {
if ($gpxStatistics && $velocityUnits && $distanceUnits) { if ($statistics && $velocityUnits && $distanceUnits) {
untrack(() => setGPXData()); untrack(() => setGPXData());
} }
}); });
@@ -102,16 +100,23 @@
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds); return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
} }
/** Total time over moving time. */
function timeRatio() {
return global.movingTime && global.totalTime ? global.totalTime / global.movingTime : 1;
}
/** The distance to cover at the speed: the moving one if there is one. */
function distance() {
return global.movingDistance ? global.movingDistance : global.totalDistance;
}
function updateEnd() { function updateEnd() {
if (startDate && movingTime !== undefined) { if (startDate && movingTime !== undefined) {
if (startTime === undefined) { if (startTime === undefined) {
startTime = '00:00:00'; startTime = '00:00:00';
} }
let start = getDate(startDate, startTime); let start = getDate(startDate, startTime);
let ratio = let ratio = timeRatio();
$gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1;
let end = new Date(start.getTime() + ratio * movingTime * 1000); let end = new Date(start.getTime() + ratio * movingTime * 1000);
endDate = toCalendarDate(end); endDate = toCalendarDate(end);
endTime = toTimeString(end); endTime = toTimeString(end);
@@ -124,10 +129,7 @@
endTime = '00:00:00'; endTime = '00:00:00';
} }
let end = getDate(endDate, endTime); let end = getDate(endDate, endTime);
let ratio = let ratio = timeRatio();
$gpxStatistics.global.time.moving > 0
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
: 1;
let start = new Date(end.getTime() - ratio * movingTime * 1000); let start = new Date(end.getTime() - ratio * movingTime * 1000);
startDate = toCalendarDate(start); startDate = toCalendarDate(start);
startTime = toTimeString(start); startTime = toTimeString(start);
@@ -157,11 +159,7 @@
return; return;
} }
let distance = movingTime = (distance() / speedValue) * 3600;
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
movingTime = (distance / speedValue) * 3600;
updateEnd(); updateEnd();
} }
@@ -170,16 +168,15 @@
if (movingTime === undefined) { if (movingTime === undefined) {
return; return;
} }
let distance = setSpeed(distance() / (movingTime / 3600));
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
setSpeed(distance / (movingTime / 3600));
updateEnd(); updateEnd();
} }
// a single file, track or segment
let canUpdate = $derived( let canUpdate = $derived(
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) ($selection.type === 'file' && $selection.fileIds.length === 1) ||
($selection.type === 'track' && $selection.trackIds.length === 1) ||
($selection.type === 'segment' && $selection.segmentIds.length === 1)
); );
</script> </script>
@@ -309,7 +306,7 @@
/> />
</div> </div>
</div> </div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} {#if !global.movingTime}
<div class="mt-0.5 flex flex-row gap-1 items-center"> <div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} /> <Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time"> <Label for="artificial-time">
@@ -324,78 +321,33 @@
disabled={!canUpdate} disabled={!canUpdate}
class="grow shrink whitespace-normal h-fit min-h-8 py-1" class="grow shrink whitespace-normal h-fit min-h-8 py-1"
onclick={() => { onclick={() => {
let effectiveSpeed = getSpeed(); const speedValue = getSpeed();
if ( if (
startDate === undefined || startDate === undefined ||
startTime === undefined || startTime === undefined ||
effectiveSpeed === undefined || speedValue === undefined ||
movingTime === undefined movingTime === undefined
) { ) {
return; return;
} }
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) { let effectiveSpeed: number = speedValue;
effectiveSpeed = $gpxStatistics.global.speed.moving; const movingSpeed = global.movingSpeed ?? 0;
if (Math.abs(effectiveSpeed - movingSpeed) < 0.01) {
effectiveSpeed = movingSpeed;
} }
let ratio = 1; let ratio = 1;
if ( if (movingSpeed > 0 && movingSpeed !== effectiveSpeed) {
$gpxStatistics.global.speed.moving > 0 && ratio = movingSpeed / effectiveSpeed;
$gpxStatistics.global.speed.moving !== effectiveSpeed
) {
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
} }
let item = $selection.getSelected()[0]; const start = getDate(startDate, startTime);
let fileId = item.getFileId(); if (artificial && !global.movingTime) {
fileActionManager.applyToFile(fileId, (file) => { engine.createArtificialTimestamps(start, movingTime);
if (item instanceof ListFileItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!
);
} else { } else {
file.changeTimestamps( engine.changeTimestamps(start, effectiveSpeed, ratio);
getDate(startDate!, startTime!),
effectiveSpeed,
ratio
);
} }
} else if (item instanceof ListTrackItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
item.getTrackIndex()
);
} else {
file.changeTimestamps(
getDate(startDate!, startTime!),
effectiveSpeed,
ratio,
item.getTrackIndex()
);
}
} else if (item instanceof ListTrackSegmentItem) {
if (artificial && !$gpxStatistics.global.time.moving) {
file.createArtificialTimestamps(
getDate(startDate!, startTime!),
movingTime!,
item.getTrackIndex(),
item.getSegmentIndex()
);
} else {
file.changeTimestamps(
getDate(startDate!, startTime!),
effectiveSpeed,
ratio,
item.getTrackIndex(),
item.getSegmentIndex()
);
}
}
});
}} }}
> >
<CalendarClock size="16" class="shrink-0" /> <CalendarClock size="16" class="shrink-0" />
+18
View File
@@ -672,6 +672,24 @@ class Engine {
return this.run((w) => w.elevation(ele)); return this.run((w) => w.elevation(ele));
} }
/**
* Sets the timestamps of the selection, which starts at `startTime`: the trackpoints that
* have some keep their durations multiplied by `ratio`, the others follow the previous one at
* `speed` (km/h). Resolves to `false` when there is nothing to set, or the values are invalid.
*/
changeTimestamps(startTime: Date, speed: number, ratio: number) {
return this.run((w) => w.changeTimestamps(startTime.getTime(), speed, ratio));
}
/**
* Makes up timestamps for the selection, which starts at `startTime` and lasts `totalTime`
* seconds, longer and steeper intervals taking more time. Resolves to `false` when there is
* nothing to set.
*/
createArtificialTimestamps(startTime: Date, totalTime: number) {
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
}
/** /**
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With * Merges the selected files, tracks or segments into the first of them (see `MergeType`). With
* `removeGaps`, the parts that are connected get closer in time if they have timestamps. * `removeGaps`, the parts that are connected get closer in time if they have timestamps.