From 74b674e8e8a9c4a9f7cd765037ab6b9359d1749a Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 08:21:39 +0200 Subject: [PATCH] progress --- .../src/engine/derived/statistics_cache.rs | 25 ++- gpx-rs/engine/src/engine/engine.rs | 8 +- gpx-rs/wasm/src/lib.rs | 58 +++++++ website/src/lib/engine.ts | 161 ++++++++++++++++-- 4 files changed, 227 insertions(+), 25 deletions(-) diff --git a/gpx-rs/engine/src/engine/derived/statistics_cache.rs b/gpx-rs/engine/src/engine/derived/statistics_cache.rs index 829749275..2ab10cbeb 100644 --- a/gpx-rs/engine/src/engine/derived/statistics_cache.rs +++ b/gpx-rs/engine/src/engine/derived/statistics_cache.rs @@ -1,9 +1,9 @@ -use std::{ - collections::{HashMap, HashSet, hash_map::Entry}, - rc::Rc, -}; +use std::collections::{HashMap, HashSet, hash_map::Entry}; -use crate::{File, Selection, StackEntry, Statistics, Track, TrackSegment, TrackSegmentRevisionId}; +use crate::{ + File, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment, + TrackSegmentRevisionId, +}; #[derive(Debug, Default)] pub struct StatisticsCache { @@ -95,7 +95,18 @@ impl StatisticsCache { stats } - fn add_file_stats<'a>(&'a self, file: &Rc, stats: &mut Vec<&'a Statistics>) { + /// Global statistics of a file: its segments merged, in order. + pub fn file_global(&self, file: &File) -> GlobalStatistics { + let mut stats = vec![]; + self.add_file_stats(file, &mut stats); + let mut global = GlobalStatistics::default(); + for s in stats { + global.merge(&s.global); + } + global + } + + fn add_file_stats<'a>(&'a self, file: &File, stats: &mut Vec<&'a Statistics>) { for trk in file.trk.iter() { self.add_track_stats(trk, stats); } @@ -116,7 +127,7 @@ impl StatisticsCache { #[cfg(test)] mod tests { - use std::collections::HashSet; + use std::{collections::HashSet, rc::Rc}; use crate::{FileId, parse}; diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index b96c7117a..593b3908e 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -2,7 +2,7 @@ use crate::{ Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, - FileStructureCache, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, + FileStructureCache, GlobalStatistics, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, }; #[derive(Debug, Default)] @@ -34,6 +34,12 @@ impl Engine { self.structure_cache.get(id) } + /// Global statistics (distance, time, elevation, bounds...) of a file. + pub fn file_statistics(&self, id: &FileId) -> Option { + let file = self.stack.current()?.get(id)?; + Some(self.statistics_cache.file_global(file)) + } + /// Which files the last action added, removed or modified. pub fn last_diff(&self) -> Option<&Diff> { self.diff.as_ref() diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 675183bd9..aab38bd18 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -353,6 +353,25 @@ export interface FileStructure { /** Changes when the waypoints of the file change: refetch their coordinates. */ waypointsRev: string; } +/** Global statistics of a file (optional fields are absent when the file has no timestamps). */ +export interface FileStatistics { + /** km */ + totalDistance: number; + movingDistance?: number; + /** seconds */ + totalTime?: number; + movingTime?: number; + elevationGain: number; + elevationLoss: number; + /** ms since epoch */ + startTime?: number; + endTime?: number; + /** km/h */ + totalSpeed?: number; + movingSpeed?: number; + /** Absent when the file has no trackpoints. */ + bounds?: { west: number; south: number; east: number; north: number }; +} export interface TrackNode { id: string; name?: string; @@ -379,6 +398,8 @@ extern "C" { pub type FilesUpdate; #[wasm_bindgen(typescript_type = "FileStructure | undefined")] pub type FileStructure; + #[wasm_bindgen(typescript_type = "FileStatistics | undefined")] + pub type FileStatistics; #[wasm_bindgen(typescript_type = "string[]")] pub type FileOrder; } @@ -458,6 +479,43 @@ pub fn file_structure(file_id: &str) -> FileStructure { structure.unchecked_into() } +/// Global statistics of a file, `undefined` if the id is unknown. +#[wasm_bindgen] +pub fn file_statistics(file_id: &str) -> FileStatistics { + let stats = uuid::Uuid::parse_str(file_id) + .ok() + .and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten()) + .map_or(JsValue::UNDEFINED, |stats| { + let object = Object::new(); + let optional = |key: &str, value: Option| { + if let Some(value) = value { + set(&object, key, value); + } + }; + set(&object, "totalDistance", stats.total_distance); + optional("movingDistance", stats.moving_distance); + optional("totalTime", stats.total_time.map(f64::from)); + optional("movingTime", stats.moving_time.map(f64::from)); + set(&object, "elevationGain", stats.elevation_gain); + set(&object, "elevationLoss", stats.elevation_loss); + optional("startTime", stats.start_time.map(|t| t as f64)); + optional("endTime", stats.end_time.map(|t| t as f64)); + optional("totalSpeed", stats.total_speed()); + optional("movingSpeed", stats.moving_speed()); + let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne); + if sw.lng <= ne.lng && sw.lat <= ne.lat { + let bounds = Object::new(); + set(&bounds, "west", sw.lng); + set(&bounds, "south", sw.lat); + set(&bounds, "east", ne.lng); + set(&bounds, "north", ne.lat); + set(&object, "bounds", bounds); + } + object.into() + }); + stats.unchecked_into() +} + fn uuids(items: impl IntoIterator, uuid: impl Fn(T) -> uuid::Uuid) -> Array { items .into_iter() diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 3e41c7a09..92585b49b 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -1,8 +1,10 @@ import { browser } from '$app/environment'; -import { writable, type Readable } from 'svelte/store'; -import type { FileStructure, Selection } from 'gpx-rs'; +import { get, writable, type Readable, type Writable } from 'svelte/store'; +import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; +import type { FileStatistics, FileStructure, Selection } from 'gpx-rs'; export type { + FileStatistics, FileStructure, FilesUpdate, Selection, @@ -13,6 +15,44 @@ export type { type Wasm = typeof import('gpx-rs'); +export type SegmentProperties = { + fileId: string; + trackId: string; + segmentId: string; + trackIndex: number; + segmentIndex: number; + /** Changes when the trackpoints of the segment change. */ + rev: string; +}; + +export type WaypointProperties = { + fileId: string; + waypointId: string; + index: number; + name?: string; +}; + +/** Everything the UI knows about one file. */ +export type FileState = { + structure: FileStructure; + /** Global statistics of the file. */ + statistics: FileStatistics; + /** One LineString feature per track segment, in file order. */ + segments: FeatureCollection; + /** One Point feature per waypoint, in file order. */ + waypoints: FeatureCollection; +}; + +const EMPTY_STATISTICS: FileStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 }; + +function toPositions(flat: Float64Array): [number, number][] { + const positions: [number, number][] = new Array(flat.length / 2); + for (let i = 0; i < positions.length; i++) { + positions[i] = [flat[2 * i], flat[2 * i + 1]]; + } + return positions; +} + /** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */ function idsToBytes(ids: string[]): Uint8Array { const bytes = new Uint8Array(ids.length * 16); @@ -29,8 +69,9 @@ function idsToBytes(ids: string[]): Uint8Array { * Thin wrapper around the WASM engine. * * The engine owns the files; this class mirrors what the UI needs in stores. After each action, - * only what the engine reports as changed is read again: the order of the files, and the - * structure of the files that were added or modified. + * only what the engine reports as changed is read again: the order of the files, and the state + * of the files that were added or modified. Within a modified file, the GeoJSON features of the + * segments and waypoints that did not change are reused. */ class Engine { private wasm: Wasm | null = null; @@ -38,16 +79,19 @@ class Engine { readonly ready: Promise; private _order = writable([]); - private _structures = writable>(new Map()); + private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); /** Ids of the files in display order. */ readonly order: Readable = { subscribe: this._order.subscribe }; /** What is currently selected. */ readonly selection: Readable = { subscribe: this._selection.subscribe }; - /** Structure (names, tracks, segments, waypoints and their ids) of each file. */ - readonly structures: Readable> = { - subscribe: this._structures.subscribe, + /** + * The state of each file, in its own store. The map store only notifies when files are added + * or removed; editing a file only notifies the subscribers of that file's store. + */ + readonly files: Readable>> = { + subscribe: this._files.subscribe, }; constructor() { @@ -134,19 +178,102 @@ class Engine { if (update.orderChanged) { this._order.set(wasm.file_order()); } - if (update.added.length + update.modified.length + update.removed.length > 0) { - this._structures.update((structures) => { - const next = new Map(structures); + const added: [string, FileState][] = []; + for (const id of update.added) { + const state = this.readFile(wasm, id); + if (state) { + added.push([id, state]); + } + } + if (added.length > 0 || update.removed.length > 0) { + this._files.update((files) => { + const next = new Map(files); update.removed.forEach((id) => next.delete(id)); - [...update.added, ...update.modified].forEach((id) => { - const structure = wasm.file_structure(id); - if (structure) { - next.set(id, structure); - } - }); + added.forEach(([id, state]) => next.set(id, writable(state))); return next; }); } + if (update.modified.length > 0) { + const files = get(this._files); + for (const id of update.modified) { + const store = files.get(id); + const state = store && this.readFile(wasm, id, get(store)); + if (store && state) { + store.set(state); + } + } + } + } + + /** Reads the state of a file, reusing from `previous` what did not change. */ + private readFile(wasm: Wasm, id: string, previous?: FileState): FileState | null { + const structure = wasm.file_structure(id); + if (!structure) { + return null; + } + + const previousSegments = new Map( + previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? [] + ); + const segments: Feature[] = []; + structure.tracks.forEach((track, trackIndex) => { + track.segments.forEach((segment, segmentIndex) => { + const old = previousSegments.get(segment.id); + const unchanged = + old?.properties.rev === segment.rev && + old.properties.trackId === track.id && + old.properties.trackIndex === trackIndex && + old.properties.segmentIndex === segmentIndex; + segments.push( + unchanged + ? old + : { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: toPositions(wasm.segment_coordinates(segment.id)), + }, + properties: { + fileId: id, + trackId: track.id, + segmentId: segment.id, + trackIndex, + segmentIndex, + rev: segment.rev, + }, + } + ); + }); + }); + + const previousStructure = previous?.structure; + const waypointsUnchanged = + previousStructure !== undefined && + previousStructure.waypointsRev === structure.waypointsRev && + previousStructure.waypoints.length === structure.waypoints.length && + previousStructure.waypoints.every( + (w, i) => + w.id === structure.waypoints[i].id && w.name === structure.waypoints[i].name + ); + let waypoints = previous?.waypoints; + if (!waypoints || !waypointsUnchanged) { + const coordinates = toPositions(wasm.waypoint_coordinates(id)); + waypoints = { + type: 'FeatureCollection', + features: structure.waypoints.map((waypoint, index) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: coordinates[index] }, + properties: { fileId: id, waypointId: waypoint.id, index, name: waypoint.name }, + })), + }; + } + + return { + structure, + statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS, + segments: { type: 'FeatureCollection', features: segments }, + waypoints, + }; } }