diff --git a/gpx-rs/engine/src/core/algorithm/timestamps.rs b/gpx-rs/engine/src/core/algorithm/timestamps.rs index 08c0e1bd5..f18e743e1 100644 --- a/gpx-rs/engine/src/core/algorithm/timestamps.rs +++ b/gpx-rs/engine/src/core/algorithm/timestamps.rs @@ -82,6 +82,38 @@ pub fn replace_trackpoints( 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 { + 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, + weights: &[f64], + ms_per_weight: f64, + start: i64, +) -> Vec { + 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 { 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) -> Option /// /// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy /// used here. -fn with_timestamps( +pub fn with_timestamps( points: Vec, speed: Option, 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 /// 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. -fn shifted_and_compressed( +pub fn shifted_and_compressed( points: Vec, speed: Option, ratio: f64, diff --git a/gpx-rs/engine/src/engine/command/tools/time.rs b/gpx-rs/engine/src/engine/command/tools/time.rs index 722072dba..fe9b73547 100644 --- a/gpx-rs/engine/src/engine/command/tools/time.rs +++ b/gpx-rs/engine/src/engine/command/tools/time.rs @@ -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)] -pub struct Time; +pub struct Time { + pub start_time: i64, + pub kind: TimeKind, +} impl Apply for Time { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("time")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + match self.kind { + 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::with_capacity(locations.len()); + match self.kind { + TimeKind::Change { speed, ratio } => { + let mut last: Option = None; + for location in &locations { + let points: Vec = 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> = locations + .iter() + .map(|location| { + let segment = segment_at(location); + let points: Vec = segment.iter().cloned().collect(); + let stats = Statistics::compute(segment); + let slopes: Vec = 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::() 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 = 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> { + 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 = 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 = 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 = 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 = 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) + )); } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 7651011e6..f0e35c976 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -613,9 +613,25 @@ pub fn split(revision: u32, at: u32, split_type: SplitType) -> bool { })) } -#[wasm_bindgen] -pub fn time() -> bool { - edit(Command::Time(engine::Time)) +/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the +/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the +/// 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 diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index a39f0561c..afcfc2d53 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -63,7 +63,6 @@ import { engine } from '$lib/engine'; import { allHidden } from '$lib/all-hidden'; import { selectionSize } from '$lib/selection-helpers'; - // TODO centering on the selection still works on the previous implementation import { boundsManager } from '$lib/logic/bounds'; import { onMount } from 'svelte'; diff --git a/website/src/lib/components/file-list/style/StyleDialog.svelte b/website/src/lib/components/file-list/style/StyleDialog.svelte index 5f886ca92..89866be0d 100644 --- a/website/src/lib/components/file-list/style/StyleDialog.svelte +++ b/website/src/lib/components/file-list/style/StyleDialog.svelte @@ -33,7 +33,6 @@ let opacityChanged = $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() { opacity = $defaultOpacity; width = $defaultWidth; diff --git a/website/src/lib/components/toolbar/tools/Time.svelte b/website/src/lib/components/toolbar/tools/Time.svelte index 1bf2d5797..a5ea5ce86 100644 --- a/website/src/lib/components/toolbar/tools/Time.svelte +++ b/website/src/lib/components/toolbar/tools/Time.svelte @@ -15,23 +15,19 @@ import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte'; import { untrack } from '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 { getURLForLanguage } from '$lib/utils'; - import { selection } from '$lib/logic/selection'; import { settings } from '$lib/logic/settings'; - import { fileActionManager } from '$lib/logic/file-action-manager'; - import { gpxStatistics } from '$lib/logic/statistics'; + import { engine } from '$lib/engine'; let props: { class?: string; } = $props(); + const selection = engine.selection; + const statistics = engine.statistics; + let global = $derived($statistics.global); + let startDate: DateValue | undefined = $state(undefined); let startTime: string | undefined = $state(undefined); let endDate: DateValue | undefined = $state(undefined); @@ -59,26 +55,28 @@ } function setGPXData() { - if ($gpxStatistics.global.time.start) { - startDate = toCalendarDate($gpxStatistics.global.time.start); - startTime = toTimeString($gpxStatistics.global.time.start); + if (global.startTime !== undefined) { + const start = new Date(global.startTime); + startDate = toCalendarDate(start); + startTime = toTimeString(start); } else { startDate = undefined; startTime = undefined; } - if ($gpxStatistics.global.time.end) { - endDate = toCalendarDate($gpxStatistics.global.time.end); - endTime = toTimeString($gpxStatistics.global.time.end); + if (global.endTime !== undefined) { + const end = new Date(global.endTime); + endDate = toCalendarDate(end); + endTime = toTimeString(end); } else { endDate = undefined; endTime = undefined; } - if ($gpxStatistics.global.time.moving && $gpxStatistics.global.speed.moving) { - movingTime = $gpxStatistics.global.time.moving; - setSpeed($gpxStatistics.global.speed.moving); - } else if ($gpxStatistics.global.time.total && $gpxStatistics.global.speed.total) { - movingTime = $gpxStatistics.global.time.total; - setSpeed($gpxStatistics.global.speed.total); + if (global.movingTime && global.movingSpeed) { + movingTime = global.movingTime; + setSpeed(global.movingSpeed); + } else if (global.totalTime && global.totalSpeed) { + movingTime = global.totalTime; + setSpeed(global.totalSpeed); } else { movingTime = undefined; speed = undefined; @@ -86,7 +84,7 @@ } $effect(() => { - if ($gpxStatistics && $velocityUnits && $distanceUnits) { + if ($statistics && $velocityUnits && $distanceUnits) { untrack(() => setGPXData()); } }); @@ -102,16 +100,23 @@ 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() { if (startDate && movingTime !== undefined) { if (startTime === undefined) { startTime = '00:00:00'; } let start = getDate(startDate, startTime); - let ratio = - $gpxStatistics.global.time.moving > 0 - ? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving - : 1; + let ratio = timeRatio(); let end = new Date(start.getTime() + ratio * movingTime * 1000); endDate = toCalendarDate(end); endTime = toTimeString(end); @@ -124,10 +129,7 @@ endTime = '00:00:00'; } let end = getDate(endDate, endTime); - let ratio = - $gpxStatistics.global.time.moving > 0 - ? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving - : 1; + let ratio = timeRatio(); let start = new Date(end.getTime() - ratio * movingTime * 1000); startDate = toCalendarDate(start); startTime = toTimeString(start); @@ -157,11 +159,7 @@ return; } - let distance = - $gpxStatistics.global.distance.moving > 0 - ? $gpxStatistics.global.distance.moving - : $gpxStatistics.global.distance.total; - movingTime = (distance / speedValue) * 3600; + movingTime = (distance() / speedValue) * 3600; updateEnd(); } @@ -170,16 +168,15 @@ if (movingTime === undefined) { return; } - let distance = - $gpxStatistics.global.distance.moving > 0 - ? $gpxStatistics.global.distance.moving - : $gpxStatistics.global.distance.total; - setSpeed(distance / (movingTime / 3600)); + setSpeed(distance() / (movingTime / 3600)); updateEnd(); } + // a single file, track or segment 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) ); @@ -309,7 +306,7 @@ /> - {#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined} + {#if !global.movingTime}