Files
gpx.studio/website/src/lib/components/elevation-profile/elevation-profile.ts
T

760 lines
29 KiB
TypeScript
Raw Normal View History

2026-10-11 11:18:36 +02:00
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
2025-10-22 19:05:20 +02:00
import {
getCadenceWithUnits,
getConvertedDistance,
getConvertedElevation,
getConvertedTemperature,
getConvertedVelocity,
getDistanceUnits,
getDistanceWithUnits,
getElevationWithUnits,
getHeartRateWithUnits,
getPowerWithUnits,
getTemperatureWithUnits,
getVelocityWithUnits,
2026-10-11 11:18:36 +02:00
} from '$lib/units/units';
2025-12-24 10:11:43 +01:00
import Chart, {
type ChartEvent,
type ChartOptions,
type ScriptableLineSegmentContext,
type TooltipItem,
} from 'chart.js/auto';
2025-10-22 19:05:20 +02:00
import { get, type Readable, type Writable } from 'svelte/store';
2026-10-11 11:18:36 +02:00
import type { Coordinates } from '$lib/geo/coordinates';
2026-10-07 08:45:50 +02:00
import {
engine,
2026-10-18 12:35:32 +02:00
EMPTY_SELECTION_STATISTICS,
type SelectionPoint,
2026-10-07 08:45:50 +02:00
type SelectionStatistics,
type StatisticsMetric,
type StatisticsRequest,
} from '$lib/engine';
2026-10-11 11:18:36 +02:00
import type { SlicedStatistics } from '$lib/state/selection-statistics';
2025-10-22 19:05:20 +02:00
import { mode } from 'mode-watcher';
2026-10-18 12:35:32 +02:00
import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories';
2026-10-11 11:18:36 +02:00
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
2025-10-22 19:05:20 +02:00
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family =
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
2026-10-07 09:35:27 +02:00
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
function optional(value: number, convert: (value: number) => number = (v) => v) {
return Number.isNaN(value) ? undefined : convert(value);
}
2026-10-18 12:35:32 +02:00
/**
* 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.
*/
2025-12-24 10:11:43 +01:00
interface ElevationProfilePoint {
x: number;
y: number;
index: number;
}
2025-10-22 19:05:20 +02:00
export class ElevationProfile {
private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement;
private _overlay: HTMLCanvasElement;
private _dragging = false;
private _panning = false;
2026-10-04 11:52:46 +02:00
private _statistics: Readable<SelectionStatistics>;
2026-10-18 12:35:32 +02:00
/** 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;
2026-10-18 16:27:51 +02:00
/** The last move over the chart, held until the data of its trackpoint is read. */
private _heldMove: MouseEvent | null = null;
2026-10-04 11:52:46 +02:00
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
2026-02-01 17:18:17 +01:00
private _hoveredPoint: Writable<Coordinates | null>;
2025-10-22 19:05:20 +02:00
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
2026-10-07 08:45:50 +02:00
/** The statistics that are only read from the engine while the profile shows them. */
private _request: StatisticsRequest;
2025-10-22 19:05:20 +02:00
constructor(
2026-10-04 11:52:46 +02:00
statistics: Readable<SelectionStatistics>,
slicedStatistics: Writable<SlicedStatistics | undefined>,
2026-02-01 17:18:17 +01:00
hoveredPoint: Writable<Coordinates | null>,
2025-10-22 19:05:20 +02:00
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
) {
2026-10-04 11:52:46 +02:00
this._statistics = statistics;
this._slicedStatistics = slicedStatistics;
2026-02-01 17:18:17 +01:00
this._hoveredPoint = hoveredPoint;
2025-10-22 19:05:20 +02:00
this._additionalDatasets = additionalDatasets;
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
2026-10-07 08:45:50 +02:00
this._request = engine.requestStatistics();
this.updateRequest();
2025-10-22 19:05:20 +02:00
import('chartjs-plugin-zoom').then((module) => {
Chart.register(module.default);
this.initialize();
2026-10-04 11:52:46 +02:00
this._statistics.subscribe(() => {
2025-10-22 19:05:20 +02:00
this.updateData();
});
2026-10-04 11:52:46 +02:00
this._slicedStatistics.subscribe(() => {
2025-10-22 19:05:20 +02:00
this.updateOverlay();
});
distanceUnits.subscribe(() => {
this.updateData();
});
velocityUnits.subscribe(() => {
this.updateData();
});
temperatureUnits.subscribe(() => {
this.updateData();
});
this._additionalDatasets.subscribe(() => {
2026-10-07 08:45:50 +02:00
// requesting a missing metric updates the data, and then the visibility
this.updateRequest();
2025-10-22 19:05:20 +02:00
this.updateDataVisibility();
});
this._elevationFill.subscribe(() => {
2026-10-07 08:45:50 +02:00
this.updateRequest();
2025-10-22 19:05:20 +02:00
this.updateFill();
});
});
}
2026-10-07 08:45:50 +02:00
/** Asks the engine for what the profile currently shows, and nothing more. */
updateRequest() {
const metrics = new Set<StatisticsMetric>();
for (const dataset of get(this._additionalDatasets)) {
if (['speed', 'hr', 'cad', 'atemp', 'power'].includes(dataset)) {
metrics.add(dataset as StatisticsMetric);
}
}
const elevationFill = get(this._elevationFill);
if (elevationFill === 'slope') {
metrics.add('slopeSegment');
} else if (elevationFill) {
metrics.add(elevationFill);
}
this._request.set(metrics);
}
2025-10-22 19:05:20 +02:00
initialize() {
2025-12-24 10:11:43 +01:00
let options: ChartOptions<'line'> = {
2025-10-22 19:05:20 +02:00
animation: false,
parsing: false,
maintainAspectRatio: false,
scales: {
x: {
type: 'linear',
ticks: {
2025-12-24 10:11:43 +01:00
callback: function (value: number | string) {
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
2025-10-22 19:05:20 +02:00
},
align: 'inner',
maxRotation: 0,
},
},
y: {
type: 'linear',
ticks: {
2025-12-24 10:11:43 +01:00
callback: function (value: number | string) {
return getElevationWithUnits(value as number, false);
2025-10-22 19:05:20 +02:00
},
},
},
},
datasets: {
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2,
cubicInterpolationMode: 'monotone',
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
plugins: {
legend: {
display: false,
},
decimation: {
enabled: true,
},
tooltip: {
enabled: () => !this._dragging && !this._panning,
callbacks: {
title: () => {
return '';
},
2025-12-24 10:11:43 +01:00
label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint;
2025-10-22 19:05:20 +02:00
if (context.datasetIndex === 0) {
2026-10-18 16:27:51 +02:00
this._hoveredPoint.set(
this._dragging
? null
: {
lat: this._data.lat[point.index],
lng: this._data.lng[point.index],
}
);
2025-10-22 19:05:20 +02:00
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 2) {
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
} else if (context.datasetIndex === 3) {
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
} else if (context.datasetIndex === 4) {
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 5) {
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
}
},
2025-12-24 10:11:43 +01:00
afterBody: (contexts: TooltipItem<'line'>[]) => {
2025-10-22 19:05:20 +02:00
let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return;
2025-12-24 10:11:43 +01:00
let point = context[0].raw as ElevationProfilePoint;
2026-10-18 12:35:32 +02:00
// 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);
2025-10-22 19:05:20 +02:00
let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
];
2026-10-18 12:35:32 +02:00
if (!details) {
return labels;
}
2026-10-18 16:20:32 +02:00
if (details.slope !== undefined) {
let slope = `${details.slope.toFixed(1)} %`;
if (
elevationFill === 'slope' &&
details.slopeSegmentSlope !== undefined &&
details.slopeSegmentDistance !== undefined
) {
slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`;
}
labels.push(` ${i18n._('quantities.slope')}: ${slope}`);
}
2026-10-18 12:35:32 +02:00
let surface = details.surface ?? 'unknown';
let highway = details.highway ?? 'unknown';
let sacScale = details.sacScale;
let mtbScale = details.mtbScale;
if (elevationFill === 'surface') {
2025-10-22 19:05:20 +02:00
labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
);
}
2026-10-18 12:35:32 +02:00
if (elevationFill === 'highway') {
2025-10-22 19:05:20 +02:00
labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
: ''
}`
);
if (mtbScale) {
labels.push(
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
);
}
}
2026-10-18 12:35:32 +02:00
if (details.time !== undefined) {
2025-10-22 19:05:20 +02:00
labels.push(
2026-10-18 12:35:32 +02:00
` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}`
2025-10-22 19:05:20 +02:00
);
}
return labels;
},
},
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: () => {
this._panning = true;
2026-10-04 11:52:46 +02:00
this._slicedStatistics.set(undefined);
2025-12-24 10:11:43 +01:00
return true;
2025-10-22 19:05:20 +02:00
},
onPanComplete: () => {
this._panning = false;
},
},
zoom: {
wheel: {
enabled: true,
},
mode: 'x',
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
2025-12-24 10:11:43 +01:00
if (!this._chart) {
return false;
}
const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0;
2025-10-22 19:05:20 +02:00
if (
event.deltaY < 0 &&
2025-12-24 10:11:43 +01:00
Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01
2025-10-22 19:05:20 +02:00
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
2026-10-04 11:52:46 +02:00
this._slicedStatistics.set(undefined);
2025-10-22 19:05:20 +02:00
},
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1,
},
},
},
},
onResize: () => {
this.updateOverlay();
},
};
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
datasets.forEach((id) => {
2025-12-24 10:11:43 +01:00
options.scales![`y${id}`] = {
2025-10-22 19:05:20 +02:00
type: 'linear',
position: 'right',
grid: {
display: false,
},
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
display: false,
};
});
this._chart = new Chart(this._canvas, {
type: 'line',
data: {
datasets: [],
},
options,
plugins: [
{
id: 'toggleMarker',
events: ['mouseout'],
2025-12-24 10:11:43 +01:00
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
2025-10-22 19:05:20 +02:00
if (args.event.type === 'mouseout') {
2026-10-18 12:35:32 +02:00
this._hovered = null;
2026-10-18 16:27:51 +02:00
this._heldMove = null;
2026-02-01 17:18:17 +01:00
this._hoveredPoint.set(null);
2025-10-22 19:05:20 +02:00
}
},
},
2026-10-18 16:27:51 +02:00
{
// The tooltip only shows a trackpoint once all its data is read: a move to
// another one is held until then, and replayed (see `loadHovered`).
id: 'waitForTrackpoint',
events: ['mousemove'],
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
const index = this.indexAt(args.event);
if (index === undefined) {
return;
}
const hovered = this._hovered;
if (hovered?.index === index && hovered.loaded) {
this._heldMove = null;
return;
}
this._heldMove = args.event.native as MouseEvent;
this.hover(index);
return false;
},
},
2025-10-22 19:05:20 +02:00
],
});
let startIndex = 0;
let endIndex = 0;
2025-12-24 10:11:43 +01:00
const getIndex = (evt: PointerEvent) => {
2025-10-22 19:05:20 +02:00
if (!this._chart) {
return undefined;
}
const points = this._chart.getElementsAtEventForMode(
evt,
'x',
{
intersect: false,
},
true
);
if (points.length === 0) {
const rect = this._canvas.getBoundingClientRect();
if (evt.x - rect.left <= this._chart.chartArea.left) {
return 0;
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
2026-01-11 19:48:48 +01:00
return this._chart.data.datasets[0].data.length - 1;
2025-10-22 19:05:20 +02:00
} else {
return undefined;
}
}
2025-12-24 10:11:43 +01:00
const point = points.find((point) => (point.element as any).raw);
2025-10-22 19:05:20 +02:00
if (point) {
2025-12-24 10:11:43 +01:00
return (point.element as any).raw.index;
2025-10-22 19:05:20 +02:00
} else {
return points[0].index;
}
};
let dragStarted = false;
2025-12-24 10:11:43 +01:00
const onMouseDown = (evt: PointerEvent) => {
2025-10-22 19:05:20 +02:00
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
this._canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
};
2025-12-24 10:11:43 +01:00
const onMouseMove = (evt: PointerEvent) => {
2025-10-22 19:05:20 +02:00
if (dragStarted) {
this._dragging = true;
endIndex = getIndex(evt);
if (endIndex !== undefined) {
if (startIndex === undefined) {
startIndex = endIndex;
} else if (startIndex !== endIndex) {
2026-10-04 11:52:46 +02:00
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
2026-10-14 08:51:38 +02:00
get(this._statistics)
.slice(start, end)
.then((global) => {
if (global) {
this._slicedStatistics.set({ global, start, end });
}
});
2025-10-22 19:05:20 +02:00
}
}
}
};
2025-12-24 10:11:43 +01:00
const onMouseUp = (evt: PointerEvent) => {
2025-10-22 19:05:20 +02:00
dragStarted = false;
this._dragging = false;
this._canvas.style.cursor = '';
endIndex = getIndex(evt);
if (startIndex === endIndex) {
2026-10-04 11:52:46 +02:00
this._slicedStatistics.set(undefined);
2025-10-22 19:05:20 +02:00
}
};
this._canvas.addEventListener('pointerdown', onMouseDown);
this._canvas.addEventListener('pointermove', onMouseMove);
this._canvas.addEventListener('pointerup', onMouseUp);
}
updateData() {
if (!this._chart) {
return;
}
2026-10-04 11:52:46 +02:00
const data = get(this._statistics);
2026-10-18 12:35:32 +02:00
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),
temperature: get(temperatureUnits),
};
2026-01-11 19:48:48 +01:00
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
2026-10-04 11:52:46 +02:00
const { global } = data;
for (let index = 0; index < data.length; index++) {
const x = getConvertedDistance(data.totalDistance[index], units.distance);
2026-01-11 19:48:48 +01:00
datasets[0].push({
2026-10-04 11:52:46 +02:00
x,
2026-10-18 12:35:32 +02:00
y: getConvertedElevation(data.ele[index], units.distance),
2026-10-04 11:52:46 +02:00
index,
2026-01-11 19:48:48 +01:00
});
2026-10-07 08:45:50 +02:00
if (data.speed && (global.totalTime ?? 0) > 0) {
2026-01-11 19:48:48 +01:00
datasets[1].push({
2026-10-04 11:52:46 +02:00
x,
2026-10-07 09:35:27 +02:00
y: optional(data.speed[index], (value) =>
getConvertedVelocity(value, units.velocity, units.distance)
),
2026-10-04 11:52:46 +02:00
index,
2026-01-11 19:48:48 +01:00
});
}
2026-10-07 08:45:50 +02:00
if (data.hr) {
2026-10-07 09:35:27 +02:00
datasets[2].push({ x, y: optional(data.hr[index]), index });
2026-01-11 19:48:48 +01:00
}
2026-10-07 08:45:50 +02:00
if (data.cad) {
2026-10-07 09:35:27 +02:00
datasets[3].push({ x, y: optional(data.cad[index]), index });
2026-01-11 19:48:48 +01:00
}
2026-10-07 08:45:50 +02:00
if (data.atemp) {
2026-01-11 19:48:48 +01:00
datasets[4].push({
2026-10-04 11:52:46 +02:00
x,
2026-10-07 09:35:27 +02:00
y: optional(data.atemp[index], (value) =>
getConvertedTemperature(value, units.temperature)
),
2026-10-04 11:52:46 +02:00
index,
2026-01-11 19:48:48 +01:00
});
}
2026-10-07 08:45:50 +02:00
if (data.power) {
2026-10-07 09:35:27 +02:00
datasets[5].push({ x, y: optional(data.power[index]), index });
2026-01-11 19:48:48 +01:00
}
2026-10-04 11:52:46 +02:00
}
2026-01-11 19:48:48 +01:00
2025-10-22 19:05:20 +02:00
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
2026-01-11 19:48:48 +01:00
data: datasets[0],
2025-10-22 19:05:20 +02:00
normalized: true,
fill: 'start',
order: 1,
segment: {},
};
this._chart.data.datasets[1] = {
2026-01-11 19:48:48 +01:00
data: datasets[1],
2025-10-22 19:05:20 +02:00
normalized: true,
yAxisID: 'yspeed',
};
this._chart.data.datasets[2] = {
2026-01-11 19:48:48 +01:00
data: datasets[2],
2025-10-22 19:05:20 +02:00
normalized: true,
yAxisID: 'yhr',
};
this._chart.data.datasets[3] = {
2026-01-11 19:48:48 +01:00
data: datasets[3],
2025-10-22 19:05:20 +02:00
normalized: true,
yAxisID: 'ycad',
};
this._chart.data.datasets[4] = {
2026-01-11 19:48:48 +01:00
data: datasets[4],
2025-10-22 19:05:20 +02:00
normalized: true,
yAxisID: 'yatemp',
};
this._chart.data.datasets[5] = {
2026-01-11 19:48:48 +01:00
data: datasets[5],
2025-10-22 19:05:20 +02:00
normalized: true,
yAxisID: 'ypower',
};
2026-01-11 19:48:48 +01:00
2025-12-24 10:11:43 +01:00
this._chart.options.scales!.x!['min'] = 0;
this._chart.options.scales!.x!['max'] = getConvertedDistance(
2026-10-04 11:52:46 +02:00
global.totalDistance,
units.distance
);
2025-10-22 19:05:20 +02:00
this.setVisibility();
this.setFill();
this._chart.update();
}
updateDataVisibility() {
if (!this._chart) {
return;
}
this.setVisibility();
this._chart.update();
}
setVisibility() {
if (!this._chart) {
return;
}
const additionalDatasets = get(this._additionalDatasets);
let includeSpeed = additionalDatasets.includes('speed');
let includeHeartRate = additionalDatasets.includes('hr');
let includeCadence = additionalDatasets.includes('cad');
let includeTemperature = additionalDatasets.includes('atemp');
let includePower = additionalDatasets.includes('power');
if (this._chart.data.datasets.length == 6) {
this._chart.data.datasets[1].hidden = !includeSpeed;
this._chart.data.datasets[2].hidden = !includeHeartRate;
this._chart.data.datasets[3].hidden = !includeCadence;
this._chart.data.datasets[4].hidden = !includeTemperature;
this._chart.data.datasets[5].hidden = !includePower;
}
}
updateFill() {
if (!this._chart) {
return;
}
this.setFill();
this._chart.update();
}
setFill() {
if (!this._chart) {
return;
}
const elevationFill = get(this._elevationFill);
2025-12-24 10:11:43 +01:00
const dataset = this._chart.data.datasets[0];
let segment: any = {};
2025-10-22 19:05:20 +02:00
if (elevationFill === 'slope') {
2025-12-24 10:11:43 +01:00
segment = {
2025-10-22 19:05:20 +02:00
backgroundColor: this.slopeFillCallback,
};
} else if (elevationFill === 'surface') {
2025-12-24 10:11:43 +01:00
segment = {
2025-10-22 19:05:20 +02:00
backgroundColor: this.surfaceFillCallback,
};
} else if (elevationFill === 'highway') {
2025-12-24 10:11:43 +01:00
segment = {
2025-10-22 19:05:20 +02:00
backgroundColor: this.highwayFillCallback,
};
} else {
2025-12-24 10:11:43 +01:00
segment = {};
2025-10-22 19:05:20 +02:00
}
2025-12-24 10:11:43 +01:00
Object.assign(dataset, { segment });
2025-10-22 19:05:20 +02:00
}
updateOverlay() {
if (!this._chart) {
return;
}
this._overlay.width = this._canvas.width / window.devicePixelRatio;
this._overlay.height = this._canvas.height / window.devicePixelRatio;
this._overlay.style.width = `${this._overlay.width}px`;
this._overlay.style.height = `${this._overlay.height}px`;
2026-10-04 11:52:46 +02:00
const slicedStatistics = get(this._slicedStatistics);
if (slicedStatistics) {
let startIndex = slicedStatistics.start;
let endIndex = slicedStatistics.end;
2025-10-22 19:05:20 +02:00
// Draw selection rectangle
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
2026-10-04 11:52:46 +02:00
const statistics = get(this._statistics);
2025-10-22 19:05:20 +02:00
let startPixel = this._chart.scales.x.getPixelForValue(
2026-10-04 11:52:46 +02:00
getConvertedDistance(statistics.totalDistance[startIndex] ?? 0)
2025-10-22 19:05:20 +02:00
);
let endPixel = this._chart.scales.x.getPixelForValue(
2026-10-04 11:52:46 +02:00
getConvertedDistance(statistics.totalDistance[endIndex] ?? 0)
2025-10-22 19:05:20 +02:00
);
selectionContext.fillRect(
startPixel,
this._chart.chartArea.top,
endPixel - startPixel,
this._chart.chartArea.height
);
}
} else if (this._overlay) {
let selectionContext = this._overlay.getContext('2d');
if (selectionContext) {
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
}
}
}
2026-10-18 16:27:51 +02:00
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
private indexAt(event: ChartEvent): number | undefined {
const elements = this._chart?.getElementsAtEventForMode(
event as unknown as Event,
'nearest',
{ axis: 'x', intersect: false },
false
);
const element = elements?.find((element) => element.datasetIndex === 0);
// the data of the chart can be decimated: its points are the ones of the elements
const point = element && this._chart?.data.datasets[0].data[element.index];
return (point as ElevationProfilePoint | undefined)?.index;
}
2026-10-18 12:35:32 +02:00
/**
2026-10-18 16:27:51 +02:00
* Reads the data of the hovered trackpoint from the engine, for the tooltip. One trackpoint is
* read at a time, the last one hovered.
2026-10-18 12:35:32 +02:00
*/
hover(index: number) {
if (this._hovered?.index === index) {
return;
}
this._hovered = { index, loaded: false };
this.loadHovered();
2025-10-22 19:05:20 +02:00
}
2026-10-18 12:35:32 +02:00
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;
2026-10-18 16:27:51 +02:00
// the move that was held can now show the tooltip
const move = this._heldMove;
this._heldMove = null;
if (move) {
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
2026-10-18 12:35:32 +02:00
}
}
} finally {
this._loadingHovered = false;
}
2025-10-22 19:05:20 +02:00
}
2026-10-18 12:35:32 +02:00
/** 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;
2025-10-22 19:05:20 +02:00
}
2026-10-18 12:35:32 +02:00
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)
);
};
2025-10-22 19:05:20 +02:00
destroy() {
2026-10-07 08:45:50 +02:00
this._request.release();
2025-10-22 19:05:20 +02:00
if (this._chart) {
this._chart.destroy();
2025-11-09 18:03:27 +01:00
this._chart = null;
2025-10-22 19:05:20 +02:00
}
}
}