This commit is contained in:
vcoppe
2026-10-04 11:52:46 +02:00
parent 9015da4876
commit b7ca462f58
15 changed files with 859 additions and 217 deletions
+19 -15
View File
@@ -6,8 +6,9 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Readable } from 'svelte/store';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings';
const { velocityUnits } = settings;
@@ -16,18 +17,17 @@
let panelWidth: number = $state(0);
let {
gpxStatistics,
slicedGPXStatistics,
statistics: selectionStatistics,
slicedStatistics,
orientation,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Readable<SlicedStatistics | undefined>;
orientation: 'horizontal' | 'vertical';
} = $props();
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
);
// the highlighted part of the selection, or all of it
let statistics = $derived($slicedStatistics?.global ?? $selectionStatistics.global);
</script>
<Card.Root
@@ -46,15 +46,15 @@
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.distance.total} type="distance" />
<WithUnits value={statistics.totalDistance} type="distance" />
</span>
</Tooltip>
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
<span class="flex flex-row items-center">
<MoveUpRight size="16" class="mr-1" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<WithUnits value={statistics.elevationGain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
<WithUnits value={statistics.elevationLoss} type="elevation" />
</span>
</Tooltip>
{#if panelHeight > 120 || (orientation === 'horizontal' && panelWidth > 450)}
@@ -67,9 +67,13 @@
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<WithUnits
value={statistics.movingSpeed ?? 0}
type="speed"
showUnits={false}
/>
<span class="mx-1">/</span>
<WithUnits value={statistics.speed.total} type="speed" />
<WithUnits value={statistics.totalSpeed ?? 0} type="speed" />
</span>
</Tooltip>
{/if}
@@ -81,9 +85,9 @@
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.time.moving} type="time" />
<WithUnits value={statistics.movingTime ?? 0} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.time.total} type="time" />
<WithUnits value={statistics.totalTime ?? 0} type="time" />
</span>
</Tooltip>
{/if}
@@ -18,7 +18,9 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Coordinates } from 'gpx';
import type { SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -26,15 +28,15 @@
const { velocityUnits } = settings;
let {
gpxStatistics,
slicedGPXStatistics,
statistics,
slicedStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
statistics: Readable<SelectionStatistics>;
slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
@@ -47,8 +49,8 @@
onMount(() => {
elevationProfile = new ElevationProfile(
gpxStatistics,
slicedGPXStatistics,
statistics,
slicedStatistics,
hoveredPoint,
additionalDatasets,
elevationFill,
@@ -21,7 +21,9 @@ import Chart, {
type TooltipItem,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates, GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Coordinates } from 'gpx';
import { NO_TIME, type SelectionStatistics } from '$lib/engine';
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
@@ -51,23 +53,23 @@ export class ElevationProfile {
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _statistics: Readable<SelectionStatistics>;
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
statistics: Readable<SelectionStatistics>,
slicedStatistics: Writable<SlicedStatistics | undefined>,
hoveredPoint: Writable<Coordinates | null>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
) {
this._gpxStatistics = gpxStatistics;
this._slicedGPXStatistics = slicedGPXStatistics;
this._statistics = statistics;
this._slicedStatistics = slicedStatistics;
this._hoveredPoint = hoveredPoint;
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
@@ -78,10 +80,10 @@ export class ElevationProfile {
Chart.register(module.default);
this.initialize();
this._gpxStatistics.subscribe(() => {
this._statistics.subscribe(() => {
this.updateData();
});
this._slicedGPXStatistics.subscribe(() => {
this._slicedStatistics.subscribe(() => {
this.updateOverlay();
});
distanceUnits.subscribe(() => {
@@ -235,7 +237,7 @@ export class ElevationProfile {
modifierKey: 'shift',
onPanStart: () => {
this._panning = true;
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
return true;
},
onPanComplete: () => {
@@ -260,7 +262,7 @@ export class ElevationProfile {
return false;
}
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
},
},
limits: {
@@ -361,14 +363,12 @@ export class ElevationProfile {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).sliced(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex),
]);
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const global = get(this._statistics).slice(start, end);
if (global) {
this._slicedStatistics.set({ global, start, end });
}
}
}
}
@@ -379,7 +379,7 @@ export class ElevationProfile {
this._canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
this._slicedGPXStatistics.set(undefined);
this._slicedStatistics.set(undefined);
}
};
this._canvas.addEventListener('pointerdown', onMouseDown);
@@ -391,7 +391,7 @@ export class ElevationProfile {
if (!this._chart) {
return;
}
const data = get(this._gpxStatistics);
const data = get(this._statistics);
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
@@ -399,52 +399,49 @@ export class ElevationProfile {
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, index) => {
const { global } = data;
for (let index = 0; index < data.length; index++) {
const x = getConvertedDistance(data.totalDistance[index], units.distance);
const ele = data.ele[index];
const timestamp = data.timestamps[index];
datasets[0].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0,
time: trkpt.time,
slope: slope,
extensions: trkpt.getExtensions(),
coordinates: trkpt.getCoordinates(),
index: index,
x,
y: ele ? getConvertedElevation(ele, units.distance) : 0,
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
slope: {
at: data.slope[index],
segment: data.slopeSegmentSlope[index],
length: data.slopeSegmentDistance[index],
},
// TODO the engine does not store the extensions (surface, highway...) yet
extensions: data.extensions[index] ?? {},
coordinates: { lat: data.lat[index], lon: data.lng[index] },
index,
});
if (data.global.time.total > 0) {
if ((global.totalTime ?? 0) > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
x,
y: getConvertedVelocity(data.speed[index], units.velocity, units.distance),
index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
if (global.hr) {
datasets[2].push({ x, y: data.hr[index], index });
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
if (global.cad) {
datasets[3].push({ x, y: data.cad[index], index });
}
if (data.global.atemp.count > 0) {
if (global.atemp) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
x,
y: getConvertedTemperature(data.atemp[index], units.temperature),
index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
if (global.power) {
datasets[5].push({ x, y: data.power[index], index });
}
});
}
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
@@ -482,7 +479,7 @@ export class ElevationProfile {
this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total,
global.totalDistance,
units.distance
);
@@ -563,10 +560,10 @@ export class ElevationProfile {
this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`;
const slicedGPXStatistics = get(this._slicedGPXStatistics);
if (slicedGPXStatistics) {
let startIndex = slicedGPXStatistics[1];
let endIndex = slicedGPXStatistics[2];
const slicedStatistics = get(this._slicedStatistics);
if (slicedStatistics) {
let startIndex = slicedStatistics.start;
let endIndex = slicedStatistics.end;
// Draw selection rectangle
let selectionContext = this._overlay.getContext('2d');
@@ -575,14 +572,12 @@ export class ElevationProfile {
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
const gpxStatistics = get(this._gpxStatistics);
const statistics = get(this._statistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
);
selectionContext.fillRect(
@@ -16,11 +16,13 @@
import { setMode } from 'mode-watcher';
import { settings } from '$lib/logic/settings';
import { fileStateCollection } from '$lib/logic/file-state';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import { loadFile } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
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';
let {
useHash = true,
@@ -31,6 +33,7 @@
let additionalDatasets = writable<string[]>([]);
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
const { statistics } = engine;
const {
currentBasemap,
selectedBasemapTree,
@@ -122,14 +125,14 @@
style={options.elevation.show ? `height: ${options.elevation.height}px` : ''}
>
<GPXStatistics
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
orientation={options.elevation.show ? 'vertical' : 'horizontal'}
/>
{#if options.elevation.show}
<ElevationProfile
{gpxStatistics}
{slicedGPXStatistics}
{statistics}
{slicedStatistics}
{hoveredPoint}
{additionalDatasets}
{elevationFill}
+127 -4
View File
@@ -4,10 +4,10 @@ import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { FileStatistics, FileStructure, Selection } from 'gpx-rs';
import type { GlobalStatistics, FileStructure, Selection } from 'gpx-rs';
export type {
FileStatistics,
GlobalStatistics,
FileStructure,
FilesUpdate,
Selection,
@@ -63,7 +63,7 @@ export type FileState = {
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: FileStatistics;
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */
@@ -75,7 +75,85 @@ export type FileState = {
visibility: Visibility;
};
const EMPTY_STATISTICS: FileStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
*/
export const NO_TIME = -(2n ** 63n);
/**
* The statistics of the selection: its global statistics, and the values at each of its
* trackpoints (all the selected segments, one after the other). The arrays are copies.
*
* Distances, times, moving values and elevation gain / loss are cumulative over the whole
* selection. Distances are in km, speeds in km/h, times in ms (timestamps since the epoch, as
* 64-bit integers), elevations in m and slopes in %. Missing timestamps are `NO_TIME`, missing
* measures are NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
/** Number of trackpoints. */
length: number;
totalDistance: Float64Array;
movingDistance: Float64Array;
/** Duration since the start of the selection, in ms. */
totalTime: BigInt64Array;
movingTime: BigInt64Array;
speed: Float64Array;
elevationGain: Float64Array;
elevationLoss: Float64Array;
/** Slope at the trackpoint. */
slope: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope: Float64Array;
slopeSegmentDistance: Float64Array;
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps: BigInt64Array;
hr: Float64Array;
cad: Float64Array;
atemp: Float64Array;
power: Float64Array;
/**
* Extensions of the trackpoints (surface, highway...), or an empty array when they are not
* known. TODO the engine does not store them yet.
*/
extensions: Record<string, string>[];
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
* the part of the elevation profile that was dragged over. `undefined` if the range is not
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
};
const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
global: EMPTY_STATISTICS,
length: 0,
totalDistance: new Float64Array(),
movingDistance: new Float64Array(),
totalTime: new BigInt64Array(),
movingTime: new BigInt64Array(),
speed: new Float64Array(),
elevationGain: new Float64Array(),
elevationLoss: new Float64Array(),
slope: new Float64Array(),
slopeSegmentSlope: new Float64Array(),
slopeSegmentDistance: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
timestamps: new BigInt64Array(),
hr: new Float64Array(),
cad: new Float64Array(),
atemp: new Float64Array(),
power: new Float64Array(),
extensions: [],
slice: () => undefined,
};
function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
@@ -119,9 +197,14 @@ class Engine {
private _visibility = new Map<string, Visibility>();
private _files = writable<Map<string, Writable<FileState>>>(new Map());
private _selection = writable<Selection>({ type: 'empty' });
private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
/** Identifies the statistics currently in the engine's buffers. */
private _statisticsVersion = 0;
/** Ids of the files in display order. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
/** What is currently selected. */
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe };
/**
@@ -365,6 +448,46 @@ class Engine {
}
}
}
// the statistics depend on the selection, on its order, and on the files
if (
update.selectionChanged ||
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.set(this.readStatistics(wasm));
}
}
/** Copies the statistics buffers of the engine (they are overwritten by the next action). */
private readStatistics(wasm: Wasm): SelectionStatistics {
const version = ++this._statisticsVersion;
const totalDistance = wasm.total_distance().slice();
return {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
movingDistance: wasm.moving_distance().slice(),
totalTime: wasm.total_time().slice(),
movingTime: wasm.moving_time().slice(),
speed: wasm.speed().slice(),
elevationGain: wasm.elevation_gain().slice(),
elevationLoss: wasm.elevation_loss().slice(),
slope: wasm.slope().slice(),
slopeSegmentSlope: wasm.slope_segment_slope().slice(),
slopeSegmentDistance: wasm.slope_segment_distance().slice(),
lng: wasm.lng().slice(),
lat: wasm.lat().slice(),
ele: wasm.ele().slice(),
timestamps: wasm.timestamps().slice(),
hr: wasm.hr().slice(),
cad: wasm.cad().slice(),
atemp: wasm.atemp().slice(),
power: wasm.power().slice(),
extensions: [],
slice: (start, end) =>
version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined,
};
}
/** Reads the state of a file, reusing from `previous` what did not change. */
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
import { engine, type GlobalStatistics } from '$lib/engine';
/** The part of the selection that is highlighted in the elevation profile. */
export type SlicedStatistics = {
/** Statistics of the part. */
global: GlobalStatistics;
/** Indices of its first and last trackpoints in the statistics of the selection. */
start: number;
end: number;
};
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));