improve grouping statistics performance

This commit is contained in:
vcoppe
2026-01-11 19:48:48 +01:00
parent 9019317e5c
commit f24956c58d
16 changed files with 668 additions and 591 deletions

View File

@@ -6,7 +6,7 @@
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { GPXStatistics } from 'gpx';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import type { Readable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
@@ -18,14 +18,14 @@
orientation,
panelSize,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
orientation: 'horizontal' | 'vertical';
panelSize: number;
} = $props();
let statistics = $derived(
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
);
</script>
@@ -42,15 +42,15 @@
<Tooltip label={i18n._('quantities.distance')}>
<span class="flex flex-row items-center">
<Ruler size="16" class="mr-1" />
<WithUnits value={statistics.global.distance.total} type="distance" />
<WithUnits value={statistics.distance.total} 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.global.elevation.gain} type="elevation" />
<WithUnits value={statistics.elevation.gain} type="elevation" />
<MoveDownRight size="16" class="mx-1" />
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
<WithUnits value={statistics.elevation.loss} type="elevation" />
</span>
</Tooltip>
{#if panelSize > 120 || orientation === 'horizontal'}
@@ -64,13 +64,9 @@
>
<span class="flex flex-row items-center">
<Zap size="16" class="mr-1" />
<WithUnits
value={statistics.global.speed.moving}
type="speed"
showUnits={false}
/>
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.speed.total} type="speed" />
<WithUnits value={statistics.speed.total} type="speed" />
</span>
</Tooltip>
{/if}
@@ -83,9 +79,9 @@
>
<span class="flex flex-row items-center">
<Timer size="16" class="mr-1" />
<WithUnits value={statistics.global.time.moving} type="time" />
<WithUnits value={statistics.time.moving} type="time" />
<span class="mx-1">/</span>
<WithUnits value={statistics.global.time.total} type="time" />
<WithUnits value={statistics.time.total} type="time" />
</span>
</Tooltip>
{/if}

View File

@@ -18,7 +18,7 @@
Construction,
} from '@lucide/svelte';
import type { Readable, Writable } from 'svelte/store';
import type { GPXStatistics } from 'gpx';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
@@ -32,8 +32,8 @@
elevationFill,
showControls = true,
}: {
gpxStatistics: Readable<GPXStatistics>;
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
gpxStatistics: Readable<GPXStatisticsGroup>;
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;

View File

@@ -23,7 +23,7 @@ import Chart, {
import mapboxgl from 'mapbox-gl';
import { get, type Readable, type Writable } from 'svelte/store';
import { map } from '$lib/components/map/map';
import type { GPXStatistics } from 'gpx';
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { mode } from 'mode-watcher';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
@@ -54,14 +54,14 @@ export class ElevationProfile {
private _dragging = false;
private _panning = false;
private _gpxStatistics: Readable<GPXStatistics>;
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
private _gpxStatistics: Readable<GPXStatisticsGroup>;
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
constructor(
gpxStatistics: Readable<GPXStatistics>,
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
gpxStatistics: Readable<GPXStatisticsGroup>,
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
@@ -342,7 +342,7 @@ export class ElevationProfile {
if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
return get(this._gpxStatistics).local.points.length - 1;
return this._chart.data.datasets[0].data.length - 1;
} else {
return undefined;
}
@@ -375,7 +375,7 @@ export class ElevationProfile {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
this._slicedGPXStatistics.set([
get(this._gpxStatistics).slice(
get(this._gpxStatistics).sliced(
Math.min(startIndex, endIndex),
Math.max(startIndex, endIndex)
),
@@ -410,117 +410,89 @@ export class ElevationProfile {
velocity: get(velocityUnits),
temperature: get(temperatureUnits),
};
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
data.forEachTrackPoint((trkpt, distance, speed, slope, 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,
});
if (data.global.time.total > 0) {
datasets[1].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedVelocity(speed, units.velocity, units.distance),
index: index,
});
}
if (data.global.hr.count > 0) {
datasets[2].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getHeartRate(),
index: index,
});
}
if (data.global.cad.count > 0) {
datasets[3].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getCadence(),
index: index,
});
}
if (data.global.atemp.count > 0) {
datasets[4].push({
x: getConvertedDistance(distance, units.distance),
y: getConvertedTemperature(trkpt.getTemperature(), units.temperature),
index: index,
});
}
if (data.global.power.count > 0) {
datasets[5].push({
x: getConvertedDistance(distance, units.distance),
y: trkpt.getPower(),
index: index,
});
}
});
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
data: data.local.points.map((point, index) => {
return {
x: getConvertedDistance(data.local.distance.total[index], units.distance),
y: point.ele ? getConvertedElevation(point.ele, units.distance) : 0,
time: point.time,
slope: {
at: data.local.slope.at[index],
segment: data.local.slope.segment[index],
length: data.local.slope.length[index],
},
extensions: point.getExtensions(),
coordinates: point.getCoordinates(),
index: index,
};
}),
data: datasets[0],
normalized: true,
fill: 'start',
order: 1,
segment: {},
};
this._chart.data.datasets[1] = {
data:
data.global.time.total > 0
? data.local.points.map((point, index) => {
return {
x: getConvertedDistance(
data.local.distance.total[index],
units.distance
),
y: getConvertedVelocity(
data.local.speed[index],
units.velocity,
units.distance
),
index: index,
};
})
: [],
data: datasets[1],
normalized: true,
yAxisID: 'yspeed',
};
this._chart.data.datasets[2] = {
data:
data.global.hr.count > 0
? data.local.points.map((point, index) => {
return {
x: getConvertedDistance(
data.local.distance.total[index],
units.distance
),
y: point.getHeartRate(),
index: index,
};
})
: [],
data: datasets[2],
normalized: true,
yAxisID: 'yhr',
};
this._chart.data.datasets[3] = {
data:
data.global.cad.count > 0
? data.local.points.map((point, index) => {
return {
x: getConvertedDistance(
data.local.distance.total[index],
units.distance
),
y: point.getCadence(),
index: index,
};
})
: [],
data: datasets[3],
normalized: true,
yAxisID: 'ycad',
};
this._chart.data.datasets[4] = {
data:
data.global.atemp.count > 0
? data.local.points.map((point, index) => {
return {
x: getConvertedDistance(
data.local.distance.total[index],
units.distance
),
y: getConvertedTemperature(point.getTemperature(), units.temperature),
index: index,
};
})
: [],
data: datasets[4],
normalized: true,
yAxisID: 'yatemp',
};
this._chart.data.datasets[5] = {
data:
data.global.power.count > 0
? data.local.points.map((point, index) => {
return {
x: getConvertedDistance(
data.local.distance.total[index],
units.distance
),
y: point.getPower(),
index: index,
};
})
: [],
data: datasets[5],
normalized: true,
yAxisID: 'ypower',
};
this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance(
data.global.distance.total,
@@ -618,10 +590,12 @@ export class ElevationProfile {
const gpxStatistics = get(this._gpxStatistics);
let startPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
getConvertedDistance(
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
)
);
let endPixel = this._chart.scales.x.getPixelForValue(
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
);
selectionContext.fillRect(

View File

@@ -21,7 +21,7 @@
SquareActivity,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { GPXStatistics } from 'gpx';
import { GPXGlobalStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
@@ -48,24 +48,24 @@
extensions: false,
};
} else {
let statistics = $gpxStatistics;
let statistics = $gpxStatistics.global;
if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
}
return acc;
}, new GPXStatistics());
}, new GPXGlobalStatistics());
}
return {
time: statistics.global.time.total === 0,
hr: statistics.global.hr.count === 0,
cad: statistics.global.cad.count === 0,
atemp: statistics.global.atemp.count === 0,
power: statistics.global.power.count === 0,
extensions: Object.keys(statistics.global.extensions).length === 0,
time: statistics.time.total === 0,
hr: statistics.hr.count === 0,
cad: statistics.cad.count === 0,
atemp: statistics.atemp.count === 0,
power: statistics.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0,
};
}
});

View File

@@ -72,17 +72,15 @@
}
let style = node.getStyle(defaultColor);
style.color.forEach((c) => {
if (!colors.includes(c)) {
colors.push(c);
}
});
colors = style.color;
} else if (node instanceof Track) {
let style = node.getStyle();
if (style) {
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
colors.push(style['gpx_style:color']);
}
if (
style &&
style['gpx_style:color'] &&
!colors.includes(style['gpx_style:color'])
) {
colors.push(style['gpx_style:color']);
}
if (colors.length === 0) {
let layer = gpxLayers.getLayer(item.getFileId());

View File

@@ -101,23 +101,17 @@ export class DistanceMarkers {
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics);
let features = [];
let features: GeoJSON.Feature[] = [];
let currentTargetDistance = 1;
for (let i = 0; i < statistics.local.distance.total.length; i++) {
if (
statistics.local.distance.total[i] >=
getConvertedDistanceToKilometers(currentTargetDistance)
) {
statistics.forEachTrackPoint((trkpt, dist) => {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
statistics.local.points[i].getLongitude(),
statistics.local.points[i].getLatitude(),
],
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
},
properties: {
distance,
@@ -126,7 +120,7 @@ export class DistanceMarkers {
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
}
});
return {
type: 'FeatureCollection',

View File

@@ -34,13 +34,20 @@ export class StartEndMarkers {
if (!map_) return;
const tool = get(currentTool);
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const hidden = get(allHidden);
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
this.start
.setLngLat(
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates()
)
.addTo(map_);
this.end
.setLngLat(
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates()
)
.addTo(map_);
} else {

View File

@@ -28,17 +28,15 @@ export class ReducedGPXLayer {
update() {
const file = this._fileState.file;
const stats = this._fileState.statistics;
if (!file || !stats) {
if (!file) {
return;
}
file.forEachSegment((segment, trackIndex, segmentIndex) => {
let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex);
let statistics = stats.getStatisticsFor(segmentItem);
this._updateSimplified(segmentItem.getFullId(), [
segmentItem,
statistics.local.points.length,
ramerDouglasPeucker(statistics.local.points, minTolerance),
segment.trkpt.length,
ramerDouglasPeucker(segment.trkpt, minTolerance),
]);
});
}

View File

@@ -793,24 +793,25 @@ export class RoutingControls {
replacingDistance +=
distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000;
}
let startAnchorStats = stats.getTrackPoint(anchors[0].point._data.index)!;
let endAnchorStats = stats.getTrackPoint(
anchors[anchors.length - 1].point._data.index
)!;
let replacedDistance =
stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] -
stats.local.distance.moving[anchors[0].point._data.index];
endAnchorStats.distance.moving - startAnchorStats.distance.moving;
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
let newTime = (newDistance / stats.global.speed.moving) * 3600;
let remainingTime =
stats.global.time.moving -
(stats.local.time.moving[anchors[anchors.length - 1].point._data.index] -
stats.local.time.moving[anchors[0].point._data.index]);
(endAnchorStats.time.moving - startAnchorStats.time.moving);
let replacingTime = newTime - remainingTime;
if (replacingTime <= 0) {
// Fallback to simple time difference
replacingTime =
stats.local.time.total[anchors[anchors.length - 1].point._data.index] -
stats.local.time.total[anchors[0].point._data.index];
replacingTime = endAnchorStats.time.total - startAnchorStats.time.total;
}
speed = (replacingDistance / replacingTime) * 3600;
@@ -820,9 +821,7 @@ export class RoutingControls {
let endIndex = anchors[anchors.length - 1].point._data.index;
startTime = new Date(
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
(replacingTime +
stats.local.time.total[endIndex] -
stats.local.time.moving[endIndex]) *
(replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) *
1000
);
}

View File

@@ -26,12 +26,10 @@
let validSelection = $derived(
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0
$gpxStatistics.global.length > 0
);
let maxSliderValue = $derived(
validSelection && $gpxStatistics.local.points.length > 0
? $gpxStatistics.local.points.length - 1
: 1
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
);
let sliderValues = $derived([0, maxSliderValue]);
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
@@ -45,7 +43,7 @@
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
$slicedGPXStatistics = [
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
sliderValues[0],
sliderValues[1],
];

View File

@@ -215,7 +215,7 @@ export const fileActions = {
reverseSelection: () => {
if (
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
get(gpxStatistics).local.points?.length <= 1
get(gpxStatistics).global.length <= 1
) {
return;
}
@@ -345,19 +345,20 @@ export const fileActions = {
let startTime: Date | undefined = undefined;
if (speed !== undefined) {
if (
statistics.local.points.length > 0 &&
statistics.local.points[0].time !== undefined
statistics.global.length > 0 &&
statistics.getTrackPoint(0)!.trkpt.time !== undefined
) {
startTime = statistics.local.points[0].time;
startTime = statistics.getTrackPoint(0)!.trkpt.time;
} else {
let index = statistics.local.points.findIndex(
(point) => point.time !== undefined
);
if (index !== -1 && statistics.local.points[index].time) {
startTime = new Date(
statistics.local.points[index].time.getTime() -
(1000 * 3600 * statistics.local.distance.total[index]) / speed
);
for (let i = 0; i < statistics.global.length; i++) {
const point = statistics.getTrackPoint(i)!;
if (point.trkpt.time !== undefined) {
startTime = new Date(
point.trkpt.time.getTime() -
(1000 * 3600 * point.distance.total) / speed
);
break;
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { ListItem, ListLevel } from '$lib/components/file-list/file-list';
import { GPXFile, GPXStatistics, type Track } from 'gpx';
import { GPXFile, GPXStatistics, GPXStatisticsGroup, type Track } from 'gpx';
export class GPXStatisticsTree {
level: ListLevel;
@@ -21,35 +21,26 @@ export class GPXStatisticsTree {
}
}
getStatisticsFor(item: ListItem): GPXStatistics {
let statistics = [];
getStatisticsFor(item: ListItem): GPXStatisticsGroup {
let statistics = new GPXStatisticsGroup();
let id = item.getIdAtLevel(this.level);
if (id === undefined || id === 'waypoints') {
Object.keys(this.statistics).forEach((key) => {
if (this.statistics[key] instanceof GPXStatistics) {
statistics.push(this.statistics[key]);
statistics.add(this.statistics[key]);
} else {
statistics.push(this.statistics[key].getStatisticsFor(item));
statistics.add(this.statistics[key].getStatisticsFor(item));
}
});
} else {
let child = this.statistics[id];
if (child instanceof GPXStatistics) {
statistics.push(child);
statistics.add(child);
} else if (child !== undefined) {
statistics.push(child.getStatisticsFor(item));
statistics.add(child.getStatisticsFor(item));
}
}
if (statistics.length === 0) {
return new GPXStatistics();
} else if (statistics.length === 1) {
return statistics[0];
} else {
return statistics.reduce((acc, curr) => {
acc.mergeWith(curr);
return acc;
}, new GPXStatistics());
}
return statistics;
}
}
export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree };

View File

@@ -1,5 +1,5 @@
import { selection } from '$lib/logic/selection';
import { GPXStatistics } from 'gpx';
import { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
import { fileStateCollection, GPXFileState } from '$lib/logic/file-state';
import {
ListFileItem,
@@ -12,7 +12,7 @@ import { settings } from '$lib/logic/settings';
const { fileOrder } = settings;
export class SelectedGPXStatistics {
private _statistics: Writable<GPXStatistics>;
private _statistics: Writable<GPXStatisticsGroup>;
private _files: Map<
string,
{
@@ -22,18 +22,21 @@ export class SelectedGPXStatistics {
>;
constructor() {
this._statistics = writable(new GPXStatistics());
this._statistics = writable(new GPXStatisticsGroup());
this._files = new Map();
selection.subscribe(() => this.update());
fileOrder.subscribe(() => this.update());
}
subscribe(run: (value: GPXStatistics) => void, invalidate?: (value?: GPXStatistics) => void) {
subscribe(
run: (value: GPXStatisticsGroup) => void,
invalidate?: (value?: GPXStatisticsGroup) => void
) {
return this._statistics.subscribe(run, invalidate);
}
update() {
let statistics = new GPXStatistics();
let statistics = new GPXStatisticsGroup();
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let stats = fileStateCollection.getStatistics(fileId);
if (stats) {
@@ -43,7 +46,7 @@ export class SelectedGPXStatistics {
!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) ||
first
) {
statistics.mergeWith(stats.getStatisticsFor(item));
statistics.add(stats.getStatisticsFor(item));
first = false;
}
});
@@ -76,7 +79,7 @@ export class SelectedGPXStatistics {
export const gpxStatistics = new SelectedGPXStatistics();
export const slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined> =
export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> =
writable(undefined);
gpxStatistics.subscribe(() => {