sparser data transfers

This commit is contained in:
vcoppe committed 2026-10-18 12:35:32 +02:00
1 parent 48325e6016
commit 4583874e7e
23 files changed
+509 -229

No files matched your search

@@ -24,14 +24,15 @@ import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from '$lib/geo/coordinates';
import {
engine,
NO_TIME,
EMPTY_SELECTION_STATISTICS,
type SelectionPoint,
type SelectionStatistics,
type StatisticsMetric,
type StatisticsRequest,
} from '$lib/engine';
import type { SlicedStatistics } from '$lib/state/selection-statistics';
import { mode } from 'mode-watcher';
import { categoryValues } from '$lib/engine/trackpoint-categories';
import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
@@ -44,20 +45,13 @@ function optional(value: number, convert: (value: number) => number = (v) => v)
return Number.isNaN(value) ? undefined : convert(value);
}
/**
* A point of the chart. The rest of the data of the trackpoint is only read when it is hovered
* (see `SelectionStatistics.point`), and the categories of the fill from their intervals.
*/
interface ElevationProfilePoint {
x: number;
y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
surface?: string;
highway?: string;
sacScale?: string;
mtbScale?: string;
coordinates: Coordinates;
index: number;
}
@@ -69,6 +63,14 @@ export class ElevationProfile {
private _panning = false;
private _statistics: Readable<SelectionStatistics>;
/** The statistics that the chart shows, which the fills read for each of its segments. */
private _data: SelectionStatistics = EMPTY_SELECTION_STATISTICS;
/**
* The hovered trackpoint, and its data once it is read from the engine (`undefined` if the
* statistics changed in the meantime).
*/
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
private _loadingHovered = false;
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>;
@@ -200,7 +202,7 @@ export class ElevationProfile {
if (this._dragging) {
this._hoveredPoint.set(null);
} else {
this._hoveredPoint.set(point.coordinates);
this.hover(point.index);
}
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
@@ -219,28 +221,40 @@ export class ElevationProfile {
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
let point = context[0].raw as ElevationProfilePoint;
let slope = {
at: point.slope.at.toFixed(1),
segment: point.slope.segment.toFixed(1),
length: getDistanceWithUnits(point.slope.length),
};
let surface = point.surface ?? 'unknown';
let highway = point.highway ?? 'unknown';
let sacScale = point.sacScale;
let mtbScale = point.mtbScale;
// the rest of the data is there once it is read (see `hover`)
const hovered = this._hovered;
const details =
hovered?.index === point.index ? hovered.point : undefined;
const elevationFill = get(this._elevationFill);
let slope = `${this._data.slope[point.index]?.toFixed(1)} %`;
if (
elevationFill === 'slope' &&
details?.slopeSegmentSlope !== undefined &&
details.slopeSegmentDistance !== undefined
) {
slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`;
}
let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
` ${i18n._('quantities.slope')}: ${slope}`,
];
if (get(this._elevationFill) === 'surface') {
if (!details) {
return labels;
}
let surface = details.surface ?? 'unknown';
let highway = details.highway ?? 'unknown';
let sacScale = details.sacScale;
let mtbScale = details.mtbScale;
if (elevationFill === 'surface') {
labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
);
}
if (get(this._elevationFill) === 'highway') {
if (elevationFill === 'highway') {
labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale
@@ -255,9 +269,9 @@ export class ElevationProfile {
}
}
if (point.time) {
if (details.time !== undefined) {
labels.push(
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}`
);
}
@@ -339,6 +353,7 @@ export class ElevationProfile {
events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') {
this._hovered = null;
this._hoveredPoint.set(null);
}
},
@@ -430,6 +445,9 @@ export class ElevationProfile {
return;
}
const data = get(this._statistics);
this._data = data;
// the indices of the trackpoints are the ones of other statistics
this._hovered = null;
const units = {
distance: get(distanceUnits),
velocity: get(velocityUnits),
@@ -438,28 +456,11 @@ export class ElevationProfile {
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
const { global } = data;
const surfaces = categoryValues(data.surface, data.length);
const highways = categoryValues(data.highway, data.length);
const sacScales = categoryValues(data.sacScale, data.length);
const mtbScales = categoryValues(data.mtbScale, data.length);
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] ?? NO_TIME;
datasets[0].push({
x,
y: getConvertedElevation(ele, units.distance),
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
slope: {
at: data.slope[index],
segment: data.slopeSegmentSlope?.[index] ?? NaN,
length: data.slopeSegmentDistance?.[index] ?? NaN,
},
surface: surfaces[index],
highway: highways[index],
sacScale: sacScales[index],
mtbScale: mtbScales[index],
coordinates: { lat: data.lat[index], lng: data.lng[index] },
y: getConvertedElevation(data.ele[index], units.distance),
index,
});
if (data.speed && (global.totalTime ?? 0) > 0) {
@@ -643,21 +644,71 @@ export class ElevationProfile {
}
}
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSlopeColor(point.slope.segment);
/**
* Shows the hovered trackpoint on the map, and reads the rest of its data from the engine for
* the tooltip. One trackpoint is read at a time, the last one hovered.
*/
hover(index: number) {
this._hoveredPoint.set({ lat: this._data.lat[index], lng: this._data.lng[index] });
if (this._hovered?.index === index) {
return;
}
this._hovered = { index, loaded: false };
this.loadHovered();
}
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getSurfaceColor(point.surface ?? '');
private async loadHovered() {
if (this._loadingHovered) {
return; // the one being read is followed by the last one hovered
}
this._loadingHovered = true;
try {
while (this._hovered && !this._hovered.loaded) {
const hovered = this._hovered;
const point = await this._data.point(hovered.index);
if (this._hovered !== hovered) {
continue; // another trackpoint is hovered, or none
}
hovered.loaded = true;
hovered.point = point;
const chart = this._chart;
if (chart?.tooltip && chart.tooltip.getActiveElements().length > 0) {
// the tooltip asks its callbacks again (the method is not in the types)
(chart.tooltip as unknown as { update(changed: boolean): void }).update(true);
chart.draw();
}
}
} finally {
this._loadingHovered = false;
}
}
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
const point = context.p0.raw as ElevationProfilePoint;
return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale);
/** The trackpoint that a segment of the chart starts at. */
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return (context.p0.raw as ElevationProfilePoint).index;
}
slopeFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const segments = this._data.slopeSegments;
const index = ElevationProfile.segmentStart(context);
const segment = segments ? intervalAt(segments.starts, index) : -1;
return getSlopeColor(segment >= 0 ? segments!.slopes[segment] : NaN);
};
surfaceFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getSurfaceColor(categoryValue(this._data.surface, index) ?? '');
};
highwayFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getHighwayColor(
categoryValue(this._data.highway, index) ?? '',
categoryValue(this._data.sacScale, index),
categoryValue(this._data.mtbScale, index)
);
};
destroy() {
this._request.release();
if (this._chart) {
@@ -26,7 +26,11 @@ get along, so we shut typescript up by casting `value` to `never`.
)}
{...restProps}
>
{#snippet children({ thumbItems })}
<!--
The thumbs are the values: the snippet does not read the items that bits-ui gives it, which
it computes again at each change of the value, with one tick per step of the slider.
-->
{#snippet children()}
<span
data-slot="slider-track"
data-orientation={orientation}
@@ -41,10 +45,10 @@ get along, so we shut typescript up by casting `value` to `never`.
)}
/>
</span>
{#each thumbItems as thumb (thumb)}
{#each Array.isArray(value) ? value : [value] as _, index (index)}
<SliderPrimitive.Thumb
data-slot="slider-thumb"
index={thumb.index}
{index}
class="border-ring ring-ring/50 relative size-4 rounded-full border bg-white transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
+15 -54
View File
@@ -174,61 +174,22 @@ export const exampleData = {
1.5840695473562374, 0.1214109154598013, -4.214883885805801, -4.182735032776085,
-5.523076373263165, -10.644434966065772,
],
slopeSegmentSlope: [
7.077037465314024, 7.077037465314024, 7.077037465314024, 7.077037465314024,
0.5753520337401837, 0.5753520337401837, 0.5753520337401837, 0.5753520337401837,
0.5753520337401837, 5.143330470981779, -5.289467529413177, 3.1409086334150653,
3.1409086334150653, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, -0.553863318953831, -0.553863318953831,
-0.553863318953831, -0.553863318953831, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 6.771412441716311, 6.771412441716311,
6.771412441716311, 6.771412441716311, 1.246024238407464, 1.246024238407464,
5.604351892140715, 5.604351892140715, 5.604351892140715, 2.2941711127147597,
2.2941711127147597, 2.2941711127147597, 2.2941711127147597, 2.2941711127147597,
2.2941711127147597, 10.861290836693483, 10.861290836693483, 10.861290836693483,
10.861290836693483, -4.414487617055606, -4.414487617055606, 6.94339810974512,
-3.539036752039, -3.539036752039, -3.539036752039, -13.990509017835178, -13.990509017835178,
-13.990509017835178, -13.990509017835178, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, -2.033792785924166, -2.033792785924166,
-2.033792785924166, -2.033792785924166, 0.45298474033593794, 0.45298474033593794,
0.45298474033593794, 0.45298474033593794, 0.45298474033593794, 0.45298474033593794,
0.45298474033593794, 0.45298474033593794, -7.692745586787598, -7.692745586787598,
-0.4142590757867528, -0.4142590757867528, -0.4142590757867528, -0.4142590757867528,
-0.4142590757867528, -5.154482373206842, -5.154482373206842, -5.154482373206842,
-5.154482373206842,
slopeSegmentStarts: [
0, 4, 9, 10, 11, 13, 30, 42, 44, 47, 53, 57, 59, 60, 63, 67, 83, 91, 93, 98,
],
slopeSegmentDistance: [
1.0682436029275066, 1.0682436029275066, 1.0682436029275066, 1.0682436029275066,
1.8771116406407027, 1.8771116406407027, 1.8771116406407027, 1.8771116406407027,
1.8771116406407027, 0.6571617396684242, 0.38189099162956897, 1.2607816597626882,
1.2607816597626882, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.622078972183025, 4.622078972183025,
4.622078972183025, 4.622078972183025, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 4.040515953724009, 4.040515953724009,
4.040515953724009, 4.040515953724009, 0.802552606262374, 0.802552606262374,
3.3545359680865605, 3.3545359680865605, 3.3545359680865605, 2.9030092668715675,
2.9030092668715675, 2.9030092668715675, 2.9030092668715675, 2.9030092668715675,
2.9030092668715675, 0.8857142437895185, 0.8857142437895185, 0.8857142437895185,
0.8857142437895185, 0.8200272181110968, 0.8200272181110968, 0.5818476682663238,
1.3280449820963618, 1.3280449820963618, 1.3280449820963618, 1.255136605652762,
1.255136605652762, 1.255136605652762, 1.255136605652762, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 4.7694141050816405,
4.7694141050816405, 4.7694141050816405, 4.7694141050816405, 1.7660639062735584,
1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 1.7660639062735584,
1.7660639062735584, 1.7660639062735584, 1.7660639062735584, 0.610965220021356,
0.610965220021356, 1.738042790330141, 1.738042790330141, 1.738042790330141,
1.738042790330141, 1.738042790330141, 1.2493980061849328, 1.2493980061849328,
1.2493980061849328, 1.2493980061849328,
slopeSegmentSlopes: [
7.077037465314024, 0.5753520337401837, 5.143330470981779, -5.289467529413177,
3.1409086334150653, -0.553863318953831, 6.771412441716311, 1.246024238407464,
5.604351892140715, 2.2941711127147597, 10.861290836693483, -4.414487617055606,
6.94339810974512, -3.539036752039, -13.990509017835178, -2.033792785924166,
0.45298474033593794, -7.692745586787598, -0.4142590757867528, -5.154482373206842,
],
slopeSegmentDistances: [
1.0682436029275066, 1.8771116406407027, 0.6571617396684242, 0.38189099162956897,
1.2607816597626882, 4.622078972183025, 4.040515953724009, 0.802552606262374,
3.3545359680865605, 2.9030092668715675, 0.8857142437895185, 0.8200272181110968,
0.5818476682663238, 1.3280449820963618, 1.255136605652762, 4.7694141050816405,
1.7660639062735584, 0.610965220021356, 1.738042790330141, 1.2493980061849328,
],
lng: [
-66.658624, -66.658127, -66.658472, -66.660451, -66.650629, -66.646183, -66.646388,
+22 -4
View File
@@ -4,7 +4,8 @@
* and the documentation. They are the ones that the engine computes for `example.gpx`, kept as
* they are (see `example-data.ts`) so that the example does not need the engine.
*/
import type { GlobalStatistics, SelectionStatistics } from '$lib/engine';
import type { GlobalStatistics, SelectionPoint, SelectionStatistics } from '$lib/engine';
import { intervalAt } from '$lib/engine/trackpoint-categories';
import { exampleData as data } from '$lib/data/example/example-data';
const length = data.lng.length;
@@ -46,6 +47,21 @@ async function slice(start: number, end: number): Promise<GlobalStatistics | und
};
}
const slopeSegmentStarts = Uint32Array.from(data.slopeSegmentStarts);
/** The data of the trackpoint at `index`, as the engine gives it (the example has no OSM data). */
async function point(index: number): Promise<SelectionPoint | undefined> {
if (!(index >= 0 && index < length)) {
return undefined;
}
const segment = intervalAt(slopeSegmentStarts, index);
return {
time: data.timestamps[index],
slopeSegmentSlope: data.slopeSegmentSlopes[segment],
slopeSegmentDistance: data.slopeSegmentDistances[segment],
};
}
export const exampleStatistics: SelectionStatistics = {
global: data.global,
length,
@@ -54,10 +70,12 @@ export const exampleStatistics: SelectionStatistics = {
lng: Float64Array.from(data.lng),
lat: Float64Array.from(data.lat),
ele: Float64Array.from(data.ele),
timestamps: BigInt64Array.from(data.timestamps, (time) => BigInt(time)),
speed: Float64Array.from(data.speed),
atemp: Float64Array.from(data.atemp),
slopeSegmentSlope: Float64Array.from(data.slopeSegmentSlope),
slopeSegmentDistance: Float64Array.from(data.slopeSegmentDistance),
slopeSegments: {
starts: slopeSegmentStarts,
slopes: Float64Array.from(data.slopeSegmentSlopes),
},
slice,
point,
};
+3 -3
View File
@@ -182,14 +182,14 @@ export async function createTauriBackend(): Promise<Backend> {
lng: () => f64('lng'),
lat: () => f64('lat'),
ele: () => f64('ele'),
timestamps: () => optionalArray('timestamps', BigInt64Array),
speed: () => f64Optional('speed'),
hr: () => f64Optional('hr'),
cad: () => f64Optional('cad'),
atemp: () => f64Optional('atemp'),
power: () => f64Optional('power'),
slope_segment_slope: () => f64('slope_segment_slope'),
slope_segment_distance: () => f64('slope_segment_distance'),
slope_segment_starts: () => u32('slope_segment_starts'),
slope_segment_slopes: () => f64('slope_segment_slopes'),
selection_point: async (index) => (await invoke('selection_point', { index })) ?? undefined,
surface_starts: () => u32('surface_starts'),
surface_values: () => u8('surface_values'),
surfaces: () => invoke('surfaces'),
+4 -3
View File
@@ -6,6 +6,7 @@ import type {
Outcome,
RouteAttributes,
Selection,
SelectionPoint,
StorageOpened,
TrackpointDetails,
WaypointDetails,
@@ -277,14 +278,14 @@ export interface Backend {
lng(): Promise<Float64Array>;
lat(): Promise<Float64Array>;
ele(): Promise<Float64Array>;
timestamps(): Promise<BigInt64Array | undefined>;
speed(): Promise<Float64Array | undefined>;
hr(): Promise<Float64Array | undefined>;
cad(): Promise<Float64Array | undefined>;
atemp(): Promise<Float64Array | undefined>;
power(): Promise<Float64Array | undefined>;
slope_segment_slope(): Promise<Float64Array>;
slope_segment_distance(): Promise<Float64Array>;
slope_segment_starts(): Promise<Uint32Array>;
slope_segment_slopes(): Promise<Float64Array>;
selection_point(index: number): Promise<SelectionPoint | undefined>;
surface_starts(): Promise<Uint32Array>;
surface_values(): Promise<Uint8Array>;
surfaces(): Promise<string[]>;
+3 -3
View File
@@ -135,14 +135,14 @@ export async function createWasmBackend(): Promise<Backend> {
lng: lift(w.lng),
lat: lift(w.lat),
ele: lift(w.ele),
timestamps: lift(w.timestamps),
speed: lift(w.speed),
hr: lift(w.hr),
cad: lift(w.cad),
atemp: lift(w.atemp),
power: lift(w.power),
slope_segment_slope: lift(w.slope_segment_slope),
slope_segment_distance: lift(w.slope_segment_distance),
slope_segment_starts: lift(w.slope_segment_starts),
slope_segment_slopes: lift(w.slope_segment_slopes),
selection_point: lift(w.selection_point),
surface_starts: lift(w.surface_starts),
surface_values: lift(w.surface_values),
surfaces: lift(w.surfaces),
+20 -6
View File
@@ -9,7 +9,6 @@ import {
ALL_EXPORT_OPTIONS,
engine,
FileStateCollectionObserver,
NO_TIME,
type ExportOptions,
type FileState,
} from '$lib/engine';
@@ -195,7 +194,7 @@ describe('statistics', () => {
assert.equal(statistics.totalDistance[0], 0);
assert.ok(statistics.global.totalDistance > 0);
assert.equal(statistics.global.totalDistance, file.statistics.totalDistance);
assert.equal(statistics.timestamps, undefined);
assert.equal((await statistics.point(0))!.time, undefined);
assert.ok(statistics.global.bounds!.west <= statistics.global.bounds!.east);
await engine.select([]);
@@ -206,9 +205,7 @@ describe('statistics', () => {
it('has timestamps when the file has some', async () => {
await load('with_time');
const statistics = get(engine.statistics);
assert.ok(statistics.timestamps);
assert.equal(statistics.timestamps!.length, statistics.length);
assert.ok(statistics.timestamps!.some((t) => t !== NO_TIME));
assert.ok((await statistics.point(0))!.time! > 0);
assert.ok(statistics.global.totalTime! > 0);
assert.ok(statistics.global.movingSpeed === undefined || statistics.global.movingSpeed > 0);
});
@@ -218,8 +215,13 @@ describe('statistics', () => {
assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics();
await request.set(['hr', 'anchors']);
await request.set(['hr', 'anchors', 'slopeSegment']);
const statistics = get(engine.statistics);
assert.equal(statistics.slopeSegments!.starts[0], 0);
assert.equal(
statistics.slopeSegments!.starts.length,
statistics.slopeSegments!.slopes.length
);
assert.equal(statistics.hr!.length, statistics.length);
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
assert.equal(statistics.cad, undefined);
@@ -248,6 +250,18 @@ describe('statistics', () => {
await engine.select([]);
assert.equal(await statistics.slice(2, 5), undefined);
});
it('gives the data of a trackpoint of the selection', async () => {
await load('simple');
const statistics = get(engine.statistics);
const point = (await statistics.point(3))!;
assert.ok(point.slopeSegmentDistance! > 0);
assert.equal(await statistics.point(statistics.length), undefined);
// only for the selection it was read for
await engine.select([]);
assert.equal(await statistics.point(3), undefined);
});
});
describe('file state', () => {
+12 -13
View File
@@ -46,15 +46,16 @@ export class StatisticsReader {
private async read(backend: Backend): Promise<SelectionStatistics> {
const version = ++this._version;
this._loaded.clear();
const [global, totalDistance, slope, lng, lat, ele, timestamps] = await Promise.all([
const [global, totalDistance, slope, lng, lat, ele] = await Promise.all([
backend.selection_statistics(),
backend.total_distance(),
backend.slope(),
backend.lng(),
backend.lat(),
backend.ele(),
backend.timestamps(),
]);
const current = <T>(read: () => Promise<T | undefined>) =>
this.enqueue(async () => (version === this._version ? read() : undefined));
const statistics: SelectionStatistics = {
global: global ?? EMPTY_STATISTICS,
length: totalDistance.length,
@@ -63,11 +64,8 @@ export class StatisticsReader {
lng,
lat,
ele,
timestamps,
slice: (start, end) =>
this.enqueue(async () =>
version === this._version ? backend.slice_statistics(start, end) : undefined
),
slice: (start, end) => current(() => backend.slice_statistics(start, end)),
point: (index) => current(() => backend.selection_point(index)),
};
await this.readMetrics(backend, statistics, this.requestedMetrics());
return statistics;
@@ -114,13 +112,14 @@ export class StatisticsReader {
case 'power':
statistics.power = await backend.power();
break;
case 'slopeSegment':
[statistics.slopeSegmentSlope, statistics.slopeSegmentDistance] =
await Promise.all([
backend.slope_segment_slope(),
backend.slope_segment_distance(),
]);
case 'slopeSegment': {
const [starts, slopes] = await Promise.all([
backend.slope_segment_starts(),
backend.slope_segment_slopes(),
]);
statistics.slopeSegments = { starts, slopes };
break;
}
case 'surface':
statistics.surface = await categories(
backend.surface_starts(),
@@ -2,7 +2,9 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
categoryIntervals,
categoryValue,
categoryValues,
intervalAt,
type CategoryIntervals,
} from '$lib/engine/trackpoint-categories';
@@ -68,3 +70,37 @@ describe('categoryIntervals', () => {
}
});
});
describe('intervalAt', () => {
const starts = new Uint32Array([0, 2, 3, 6]);
it('is the interval that the trackpoint is in', () => {
const intervals = [0, 1, 2, 3, 4, 5, 6, 9].map((index) => intervalAt(starts, index));
assert.deepEqual(intervals, [0, 0, 1, 2, 2, 2, 3, 3]);
});
it('is -1 when there is no interval', () => {
assert.equal(intervalAt(new Uint32Array(), 0), -1);
});
});
describe('categoryValue', () => {
const intervals: CategoryIntervals = {
starts: new Uint32Array([0, 2, 3, 6]),
values: new Uint8Array([1, 0, 2, 0]),
names: ['asphalt', 'gravel'],
};
it('is the value of each trackpoint, as categoryValues', () => {
const values = categoryValues(intervals, 7);
values.forEach((value, index) => assert.equal(categoryValue(intervals, index), value));
});
it('is unknown without intervals', () => {
assert.equal(categoryValue(undefined, 3), undefined);
assert.equal(
categoryValue({ starts: new Uint32Array(), values: new Uint8Array(), names: [] }, 0),
undefined
);
});
});
@@ -36,6 +36,34 @@ export function categoryValues(
return result;
}
/** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */
export function intervalAt(starts: Uint32Array, index: number): number {
let low = 0;
let high = starts.length;
// the first interval that starts after the trackpoint
while (low < high) {
const middle = (low + high) >>> 1;
if (starts[middle] <= index) {
low = middle + 1;
} else {
high = middle;
}
}
return low - 1;
}
/** The value of the trackpoint `index` for a category, `undefined` when it is unknown. */
export function categoryValue(
intervals: CategoryIntervals | undefined,
index: number
): string | undefined {
if (!intervals) {
return undefined;
}
const value = intervals.values[intervalAt(intervals.starts, index)];
return value ? intervals.names[value - 1] : undefined;
}
/**
* The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined`
* when unknown): the inverse of `categoryValues`. The names are in the order of their first
+26 -15
View File
@@ -2,7 +2,7 @@ import type { Readable } from 'svelte/store';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { Visibility } from '$lib/engine/file-visibility';
import type { CategoryIntervals } from '$lib/engine/trackpoint-categories';
import type { FileStructure, GlobalStatistics } from 'gpx-wasm';
import type { FileStructure, GlobalStatistics, SelectionPoint } from 'gpx-wasm';
export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './backend/types';
@@ -12,6 +12,7 @@ export type {
TrackpointDetails,
MoveTarget,
GlobalStatistics,
SelectionPoint,
FileStructure,
FilesUpdate,
Outcome,
@@ -136,12 +137,6 @@ export const ALL_EXPORT_OPTIONS: ExportOptions = {
asRoute: false,
};
/**
* 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);
/**
* Statistics of the selection that are only read from the engine when something asks for them
* (see `Engine.requestStatistics`). A metric can bring several arrays.
@@ -152,7 +147,7 @@ export type StatisticsMetric =
| 'cad'
| 'atemp'
| 'power'
/** `slopeSegmentSlope` and `slopeSegmentDistance`. */
/** `slopeSegments`. */
| 'slopeSegment'
| 'surface'
/** `highway`, `sacScale` and `mtbScale`. */
@@ -185,17 +180,29 @@ export type SelectionAnchors = {
revision: number;
};
/**
* The smoothed elevation segments of the selection, over which the slope is averaged, as the
* intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
* `starts[i]` and lasts until the next start (or the last trackpoint), with a slope of
* `slopes[i]` (%). Their length is in `SelectionStatistics.point`.
*/
export type SlopeSegments = {
starts: Uint32Array;
slopes: Float64Array;
};
/**
* 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.
* trackpoints (all the selected segments, one after the other). The arrays are copies. What is
* only needed for one trackpoint at a time (its time, its OSM data...) is read with `point`.
*
* Distances 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 %.
*
* The values of the trackpoints that the selection does not have at all (for example the heart
* rates, when no trackpoint has any) are `undefined`, and so are the ones that were not requested
* (see `StatisticsMetric`). Otherwise they have one entry per trackpoint: missing timestamps are
* `NO_TIME`, missing measures are NaN.
* (see `StatisticsMetric`). Otherwise they have one entry per trackpoint, missing measures are
* NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
@@ -207,7 +214,6 @@ export type SelectionStatistics = {
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps?: BigInt64Array;
// the following need to be requested
speed?: Float64Array;
@@ -215,9 +221,7 @@ export type SelectionStatistics = {
cad?: Float64Array;
atemp?: Float64Array;
power?: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope?: Float64Array;
slopeSegmentDistance?: Float64Array;
slopeSegments?: SlopeSegments;
surface?: CategoryIntervals;
highway?: CategoryIntervals;
/** SAC hiking scale. */
@@ -232,6 +236,12 @@ export type SelectionStatistics = {
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): Promise<GlobalStatistics | undefined>;
/**
* All the data of the trackpoint at `index`, for example the one hovered on the elevation
* profile. `undefined` if it is not in the selection, or if the selection is not the current
* one anymore.
*/
point(index: number): Promise<SelectionPoint | undefined>;
};
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
@@ -243,6 +253,7 @@ export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
lat: new Float64Array(),
ele: new Float64Array(),
slice: async () => undefined,
point: async () => undefined,
};
export type StatisticsRequest = {
+11 -4
View File
@@ -48,8 +48,11 @@ export async function computeExampleData() {
engine.load_files(new Uint8Array(data), Uint32Array.from([data.length]), ['example']);
// numbers are enough for the times, which are milliseconds
const numbers = (values: Values) =>
const numbers = (values: Values | Uint32Array) =>
values && Array.from(values as ArrayLike<number | bigint>, (value) => Number(value));
const length = engine.total_distance().length;
const points = Array.from({ length }, (_, index) => engine.selection_point(index)!);
const slopeSegmentStarts = Array.from(engine.slope_segment_starts());
return {
global: engine.selection_statistics(),
totalDistance: numbers(engine.total_distance()),
@@ -59,12 +62,16 @@ export async function computeExampleData() {
elevationGain: numbers(engine.elevation_gain()),
elevationLoss: numbers(engine.elevation_loss()),
slope: numbers(engine.slope()),
slopeSegmentSlope: numbers(engine.slope_segment_slope()),
slopeSegmentDistance: numbers(engine.slope_segment_distance()),
slopeSegmentStarts,
slopeSegmentSlopes: numbers(engine.slope_segment_slopes()),
// the length of each segment, which is the one of its trackpoints
slopeSegmentDistances: slopeSegmentStarts.map(
(start) => points[start].slopeSegmentDistance!
),
lng: numbers(engine.lng()),
lat: numbers(engine.lat()),
ele: numbers(engine.ele()),
timestamps: numbers(engine.timestamps()),
timestamps: points.map((point) => point.time!),
speed: numbers(engine.speed()),
atemp: numbers(engine.atemp()),
};