show hovered point on elevation profile

This commit is contained in:
vcoppe committed 2026-11-08 15:06:03 +01:00
1 parent b80fa7d942
commit 21e27a4416
15 files changed
+260 -106

No files matched your search

@@ -31,6 +31,7 @@
statistics,
slicedStatistics,
hoveredPoint,
hoveredTrackpoint,
additionalDatasets,
elevationFill,
showControls = true,
@@ -38,6 +39,7 @@
statistics: Readable<SelectionStatistics>;
slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>;
hoveredTrackpoint?: Readable<number | null>;
additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean;
@@ -55,7 +57,8 @@
additionalDatasets,
elevationFill,
canvas,
overlay
overlay,
hoveredTrackpoint
);
});
@@ -15,10 +15,15 @@ import {
getVelocityWithUnits,
} from '$lib/units/units';
import Chart, {
Tooltip,
type ActiveDataPoint,
type ChartEvent,
type ChartOptions,
type ChartType,
type Point,
type ScriptableLineSegmentContext,
type TooltipItem,
type TooltipPositionerFunction,
} from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from '$lib/geo/coordinates';
@@ -40,6 +45,22 @@ 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
declare module 'chart.js' {
interface TooltipPositionerMap {
trackpoint: TooltipPositionerFunction<ChartType>;
}
}
/**
* Like the default for the events of the chart. A trackpoint that is shown without one is given by
* its position, as it can be between the points of the chart (see `activateTrackpoint`).
*/
Tooltip.positioners.trackpoint = function (items, position) {
return 'native' in position
? Tooltip.positioners.average.call(this, items, position)
: position;
};
/** `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);
@@ -55,6 +76,21 @@ interface ElevationProfilePoint {
index: number;
}
/** The index of the point the closest to `x`, in points sorted by `x`. */
function nearestPointIndex(points: { x: number }[], x: number): number {
let low = 0;
let high = points.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (points[middle].x < x) {
low = middle + 1;
} else {
high = middle;
}
}
return low > 0 && x - points[low - 1].x < points[low].x - x ? low - 1 : low;
}
export class ElevationProfile {
private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement;
@@ -75,6 +111,12 @@ export class ElevationProfile {
private _heldMove: MouseEvent | null = null;
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>;
/** All the points of each dataset, including the ones that the chart decimated. */
private _points: ElevationProfilePoint[][] = [];
/** The trackpoint hovered somewhere else, that the tooltip shows instead of its elements. */
private _shownTrackpoint: number | null = null;
private _hoveredTrackpoint: Readable<number | null> | undefined;
private _unsubscribeHoveredTrackpoint: (() => void) | undefined;
private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
/** The statistics that are only read from the engine while the profile shows them. */
@@ -87,7 +129,8 @@ export class ElevationProfile {
additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement
overlay: HTMLCanvasElement,
hoveredTrackpoint?: Readable<number | null>
) {
this._statistics = statistics;
this._slicedStatistics = slicedStatistics;
@@ -96,6 +139,7 @@ export class ElevationProfile {
this._elevationFill = elevationFill;
this._canvas = canvas;
this._overlay = overlay;
this._hoveredTrackpoint = hoveredTrackpoint;
this._request = engine.requestStatistics();
this.updateRequest();
@@ -127,6 +171,9 @@ export class ElevationProfile {
this.updateRequest();
this.updateFill();
});
this._unsubscribeHoveredTrackpoint = this._hoveredTrackpoint?.subscribe((index) => {
this.showTrackpoint(index);
});
});
}
@@ -195,20 +242,16 @@ export class ElevationProfile {
},
tooltip: {
enabled: () => !this._dragging && !this._panning,
position: 'trackpoint',
callbacks: {
title: () => {
return '';
},
label: (context: TooltipItem<'line'>) => {
const point = context.raw as ElevationProfilePoint;
const point = this.pointOf(context);
if (context.datasetIndex === 0) {
this._hoveredPoint.set(
this._dragging
? null
: {
lat: this._data.lat[point.index],
lng: this._data.lng[point.index],
}
this._dragging ? null : this.coordinatesOf(point.index)
);
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) {
@@ -228,7 +271,7 @@ export class ElevationProfile {
(context) => context.datasetIndex === 0
);
if (context.length === 0) return;
const point = context[0].raw as ElevationProfilePoint;
const point = this.pointOf(context[0]);
// the rest of the data is there once it is read (see `hover`)
const hovered = this._hovered;
const details =
@@ -478,6 +521,7 @@ export class ElevationProfile {
}
const data = get(this._statistics);
this._data = data;
const shownTrackpoint = this._shownTrackpoint;
// the indices of the trackpoints are the ones of other statistics
this._hovered = null;
const units = {
@@ -524,6 +568,7 @@ export class ElevationProfile {
}
}
this._points = datasets;
this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'),
data: datasets[0],
@@ -570,6 +615,10 @@ export class ElevationProfile {
this.setFill();
this._chart.update();
if (shownTrackpoint !== null) {
this.showTrackpoint(shownTrackpoint);
}
}
updateDataVisibility() {
@@ -678,6 +727,73 @@ export class ElevationProfile {
}
}
/** The point that a line of the tooltip is about. */
private pointOf(context: TooltipItem<'line'>): ElevationProfilePoint {
return this._shownTrackpoint === null
? (context.raw as ElevationProfilePoint)
: this._points[context.datasetIndex][this._shownTrackpoint];
}
private coordinatesOf(index: number): Coordinates {
return { lat: this._data.lat[index], lng: this._data.lng[index] };
}
/** Shows a trackpoint in the tooltip like when hovering it, or nothing anymore with `null`. */
private showTrackpoint(index: number | null) {
const shown = this._shownTrackpoint;
this._shownTrackpoint = index;
if (index !== null) {
this.hover(index);
if (this._hovered?.loaded) {
this.activateTrackpoint(index);
}
} else if (shown !== null) {
this._hovered = null;
this.setActiveElements([], { x: 0, y: 0 });
this._hoveredPoint.set(null);
}
}
/**
* Opens the tooltip on a trackpoint, once its data is read. The chart can only activate the
* points that it kept after decimating its data, which are different for each dataset and might
* not include the trackpoint: it activates the nearest ones, within a pixel of it, and the
* tooltip reads the values of the trackpoint (see `pointOf`).
*/
private activateTrackpoint(index: number) {
const point = this._points[0]?.[index];
if (!this._chart || !point) {
return;
}
const { x: xScale, y: yScale } = this._chart.scales;
const elements: ActiveDataPoint[] = [];
// when the chart is zoomed, the trackpoint can be outside of it
if (xScale.min <= point.x && point.x <= xScale.max) {
for (const { index: datasetIndex } of this._chart.getSortedVisibleDatasetMetas()) {
if (this._points[datasetIndex]?.[index]?.y !== undefined) {
const data = this._chart.data.datasets[datasetIndex].data as { x: number }[];
elements.push({ datasetIndex, index: nearestPointIndex(data, point.x) });
}
}
}
if (elements.length === 0) {
// no tooltip to tell the map about the point
this._hoveredPoint.set(this.coordinatesOf(index));
}
this.setActiveElements(elements, {
x: xScale.getPixelForValue(point.x),
y: yScale.getPixelForValue(point.y),
});
}
private setActiveElements(elements: ActiveDataPoint[], position: Point) {
if (this._chart) {
this._chart.tooltip?.setActiveElements(elements, position);
this._chart.setActiveElements(elements);
this._chart.render();
}
}
/** 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(
@@ -718,6 +834,9 @@ export class ElevationProfile {
}
hovered.loaded = true;
hovered.point = point;
if (this._shownTrackpoint === hovered.index) {
this.activateTrackpoint(hovered.index);
}
// the move that was held can now show the tooltip
const move = this._heldMove;
this._heldMove = null;
@@ -758,6 +877,7 @@ export class ElevationProfile {
destroy() {
this._request.release();
this._unsubscribeHoveredTrackpoint?.();
if (this._chart) {
this._chart.destroy();
this._chart = null;
@@ -18,7 +18,11 @@
import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import {
hoveredPoint,
hoveredTrackpoint,
slicedStatistics,
} from '$lib/state/selection-statistics';
let {
useHash = true,
@@ -119,6 +123,7 @@
{statistics}
{slicedStatistics}
{hoveredPoint}
{hoveredTrackpoint}
{additionalDatasets}
{elevationFill}
showControls={options.elevation.controls}
@@ -5,6 +5,7 @@
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { map } from '$lib/components/map/map';
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers;
@@ -14,6 +15,11 @@
startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers();
createPopups(map_);
// the layers do not see the pointer when it leaves the map or when it drags something
const stopHovering = () => hoveredTrackpoint.set(null);
map_.on('mouseout', stopHovering);
map_.on('mousedown', stopHovering);
map_.on('movestart', stopHovering);
});
onDestroy(() => {
@@ -2,17 +2,15 @@
import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
import * as Card from '$lib/components/ui/card';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
import { Compass, Earth } from '@lucide/svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup';
import type { TrackpointDetails } from '$lib/engine';
import { map } from '$lib/components/map/map';
let {
trackpoint,
}: {
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
trackpoint: PopupItem<{ lng: number; lat: number }>;
} = $props();
</script>
@@ -22,33 +20,19 @@
<Compass size="14" />
{trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time !== undefined}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{i18n.df.format(new Date(trackpoint.item.time))}
</div>
{/if}
<CopyCoordinates
coordinates={trackpoint.item}
onCopy={() => trackpoint.hide?.()}
class="mt-0.5"
/>
{#if trackpoint.fileId === undefined}
<Button
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
{i18n._('menu.edit_osm')}
</Button>
{/if}
<Button
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
target="_blank"
>
<Earth size="14" />
{i18n._('menu.edit_osm')}
</Button>
</Card.Content>
</Card.Root>
@@ -7,8 +7,8 @@ import {
Point,
} from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import { closestPointIndex } from '$lib/geo/closest-point';
import { waypointPopup } from './gpx-layer-popup';
import { closestPointIndexIn } from '$lib/geo/closest-point';
import { getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static';
@@ -21,6 +21,7 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
@@ -63,6 +64,7 @@ export class GPXLayer {
currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null;
draggingStartingPosition: Point = new Point(0, 0);
hoversTrackpoint: boolean = false;
unsubscribe: (() => void)[] = [];
updateBinded: () => void = this.update.bind(this);
@@ -400,25 +402,50 @@ export class GPXLayer {
layerOnMouseLeave() {
mapCursor.notify(MapCursorState.SCISSORS, false);
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
this.stopHoveringTrackpoint();
}
async layerOnMouseMove(e: MapLayerMouseEvent) {
if (e.originalEvent.shiftKey) {
const { segmentId } = e.features![0].properties;
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
const trackpoint =
index === undefined
? undefined
: await engine.trackpoint(this.fileId, segmentId, index);
if (trackpoint) {
trackpointPopup?.setItem({
item: trackpoint,
kind: 'trackpoint',
fileId: this.fileId,
});
layerOnMouseMove(e: MapLayerMouseEvent) {
const index = this.selectedTrackpointAt(e);
if (index === undefined) {
this.stopHoveringTrackpoint();
} else {
this.hoversTrackpoint = true;
hoveredTrackpoint.set(index);
}
}
stopHoveringTrackpoint() {
if (this.hoversTrackpoint) {
this.hoversTrackpoint = false;
hoveredTrackpoint.set(null);
}
}
/**
* The trackpoint of the statistics of the selection that is the closest to the pointer, in the
* first segment under it that is part of the selection.
*/
selectedTrackpointAt(e: MapLayerMouseEvent): number | undefined {
const statistics = get(engine.statistics);
if (!statistics.segments) {
return undefined;
}
const { starts, ids } = statistics.segments;
for (const { properties } of e.features!) {
const segment = ids.indexOf(properties.segmentId);
if (segment >= 0) {
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
return closestPointIndexIn(
statistics.lng,
statistics.lat,
starts[segment],
end,
e.lngLat
);
}
}
return undefined;
}
layerOnClick(e: MapLayerMouseEvent) {
@@ -1,4 +1,4 @@
import { FileStateCollectionObserver } from '$lib/engine';
import { engine, FileStateCollectionObserver } from '$lib/engine';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer';
@@ -14,6 +14,7 @@ export class GPXLayerCollection {
if (this._fileStateCollectionObserver) {
return;
}
engine.requestStatistics().set(['segments']);
this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileStore, fileId) => {
+1 -1
View File
@@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = any> = {
/** Has a position (`lng` and `lat`). */
item: T;
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
/** What the item is: a `WaypointDetails`, the coordinates of a point or an Overpass element. */
kind: 'waypoint' | 'trackpoint' | 'overpass';
fileId?: string;
hide?: () => void;