From 21e27a4416cabd51bcf0d46ad2d71d05562171fe Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 8 Nov 2026 15:06:03 +0100 Subject: [PATCH] show hovered point on elevation profile --- .../elevation-profile/ElevationProfile.svelte | 5 +- .../elevation-profile/elevation-profile.ts | 138 ++++++++++++++++-- .../lib/components/embedding/Embedding.svelte | 7 +- .../components/map/gpx-layer/GPXLayers.svelte | 6 + .../map/gpx-layer/TrackpointPopup.svelte | 38 ++--- .../lib/components/map/gpx-layer/gpx-layer.ts | 61 +++++--- .../components/map/gpx-layer/gpx-layers.ts | 3 +- website/src/lib/components/map/map-popup.ts | 2 +- website/src/lib/engine/engine.test.ts | 4 +- website/src/lib/engine/statistics.ts | 8 + website/src/lib/engine/types.ts | 14 +- website/src/lib/geo/closest-point.test.ts | 44 +++--- website/src/lib/geo/closest-point.ts | 23 +-- website/src/lib/state/selection-statistics.ts | 6 + .../src/routes/[[language]]/app/+page.svelte | 7 +- 15 files changed, 260 insertions(+), 106 deletions(-) diff --git a/website/src/lib/components/elevation-profile/ElevationProfile.svelte b/website/src/lib/components/elevation-profile/ElevationProfile.svelte index c1c1dd32a..a422263c6 100644 --- a/website/src/lib/components/elevation-profile/ElevationProfile.svelte +++ b/website/src/lib/components/elevation-profile/ElevationProfile.svelte @@ -31,6 +31,7 @@ statistics, slicedStatistics, hoveredPoint, + hoveredTrackpoint, additionalDatasets, elevationFill, showControls = true, @@ -38,6 +39,7 @@ statistics: Readable; slicedStatistics: Writable; hoveredPoint: Writable; + hoveredTrackpoint?: Readable; additionalDatasets: Writable; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; showControls?: boolean; @@ -55,7 +57,8 @@ additionalDatasets, elevationFill, canvas, - overlay + overlay, + hoveredTrackpoint ); }); diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index b6492767b..d9d884a0b 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -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; + } +} + +/** + * 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; private _hoveredPoint: Writable; + /** 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 | undefined; + private _unsubscribeHoveredTrackpoint: (() => void) | undefined; private _additionalDatasets: Readable; 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, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, canvas: HTMLCanvasElement, - overlay: HTMLCanvasElement + overlay: HTMLCanvasElement, + hoveredTrackpoint?: Readable ) { 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; diff --git a/website/src/lib/components/embedding/Embedding.svelte b/website/src/lib/components/embedding/Embedding.svelte index cc8b27978..d843976ed 100644 --- a/website/src/lib/components/embedding/Embedding.svelte +++ b/website/src/lib/components/embedding/Embedding.svelte @@ -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} diff --git a/website/src/lib/components/map/gpx-layer/GPXLayers.svelte b/website/src/lib/components/map/gpx-layer/GPXLayers.svelte index bb5d23734..6f93654b0 100644 --- a/website/src/lib/components/map/gpx-layer/GPXLayers.svelte +++ b/website/src/lib/components/map/gpx-layer/GPXLayers.svelte @@ -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(() => { diff --git a/website/src/lib/components/map/gpx-layer/TrackpointPopup.svelte b/website/src/lib/components/map/gpx-layer/TrackpointPopup.svelte index c69332505..50a76ca17 100644 --- a/website/src/lib/components/map/gpx-layer/TrackpointPopup.svelte +++ b/website/src/lib/components/map/gpx-layer/TrackpointPopup.svelte @@ -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 & Partial>; + trackpoint: PopupItem<{ lng: number; lat: number }>; } = $props(); @@ -22,33 +20,19 @@ {trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}° - {#if trackpoint.item.ele !== undefined} -
- - -
- {/if} - {#if trackpoint.item.time !== undefined} -
- - {i18n.df.format(new Date(trackpoint.item.time))} -
- {/if} trackpoint.hide?.()} class="mt-0.5" /> - {#if trackpoint.fileId === undefined} - - {/if} + diff --git a/website/src/lib/components/map/gpx-layer/gpx-layer.ts b/website/src/lib/components/map/gpx-layer/gpx-layer.ts index 1e0c3d302..e76ff407d 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layer.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layer.ts @@ -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) { diff --git a/website/src/lib/components/map/gpx-layer/gpx-layers.ts b/website/src/lib/components/map/gpx-layer/gpx-layers.ts index 2b39b4e72..b4eb46c7b 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layers.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layers.ts @@ -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) => { diff --git a/website/src/lib/components/map/map-popup.ts b/website/src/lib/components/map/map-popup.ts index 389359fdd..a74f5ca2c 100644 --- a/website/src/lib/components/map/map-popup.ts +++ b/website/src/lib/components/map/map-popup.ts @@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte'; export type PopupItem = { /** 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; diff --git a/website/src/lib/engine/engine.test.ts b/website/src/lib/engine/engine.test.ts index 1a4d13935..c5939db91 100644 --- a/website/src/lib/engine/engine.test.ts +++ b/website/src/lib/engine/engine.test.ts @@ -259,7 +259,7 @@ describe('statistics', () => { assert.equal(get(engine.statistics).hr, undefined); const request = engine.requestStatistics(); - await request.set(['hr', 'anchors', 'slopeSegment']); + await request.set(['hr', 'anchors', 'slopeSegment', 'segments']); const statistics = get(engine.statistics); assert.equal(statistics.slopeSegments!.starts[0], 0); assert.equal( @@ -270,6 +270,8 @@ describe('statistics', () => { assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr))); assert.equal(statistics.cad, undefined); assert.equal(statistics.anchors!.indices[0], 0); + assert.deepEqual([...statistics.segments!.starts], [...statistics.anchors!.segmentStarts]); + assert.deepEqual(statistics.segments!.ids, statistics.anchors!.segmentIds); assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1); // they are read again after a change, without being asked again diff --git a/website/src/lib/engine/statistics.ts b/website/src/lib/engine/statistics.ts index e1b673cee..d34658f29 100644 --- a/website/src/lib/engine/statistics.ts +++ b/website/src/lib/engine/statistics.ts @@ -143,6 +143,14 @@ export class StatisticsReader { }; break; } + case 'segments': { + const [starts, ids] = await Promise.all([ + backend.segment_starts(), + backend.segment_ids(), + ]); + statistics.segments = { starts, ids }; + break; + } case 'highway': [statistics.highway, statistics.sacScale, statistics.mtbScale] = await Promise.all([ diff --git a/website/src/lib/engine/types.ts b/website/src/lib/engine/types.ts index 9508245d9..8cd6459df 100644 --- a/website/src/lib/engine/types.ts +++ b/website/src/lib/engine/types.ts @@ -155,7 +155,9 @@ export type StatisticsMetric = /** `highway`, `sacScale` and `mtbScale`. */ | 'highway' /** `anchors`. */ - | 'anchors'; + | 'anchors' + /** `segments`. */ + | 'segments'; /** * The anchors of the routing tool among the trackpoints of the selection: the points that can be @@ -182,6 +184,15 @@ export type SelectionAnchors = { revision: number; }; +/** + * The segments of the selection that have trackpoints: segment `i` has the id `ids[i]`, and its + * trackpoints go from `starts[i]` until the next start (or the last trackpoint). + */ +export type SelectionSegments = { + starts: Uint32Array; + ids: string[]; +}; + /** * 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 @@ -229,6 +240,7 @@ export type SelectionStatistics = { /** Mountain biking scale. */ mtbScale?: CategoryIntervals; anchors?: SelectionAnchors; + segments?: SelectionSegments; /** * Global statistics of the trackpoints from `start` to `end` (both included), for example diff --git a/website/src/lib/geo/closest-point.test.ts b/website/src/lib/geo/closest-point.test.ts index f7678e2b6..ec855c0ea 100644 --- a/website/src/lib/geo/closest-point.test.ts +++ b/website/src/lib/geo/closest-point.test.ts @@ -1,32 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { closestPointIndex, closestPointIndexIn } from '$lib/geo/closest-point'; - -describe('closestPointIndex', () => { - const line = [0, 0, 1, 0, 2, 0, 2, 1]; - - it('has no point on an empty polyline', () => { - assert.equal(closestPointIndex([], { lng: 0, lat: 0 }), undefined); - }); - - it('is the only point of a polyline of one point', () => { - assert.equal(closestPointIndex([3, 4], { lng: 0, lat: 0 }), 0); - }); - - it('is the nearest end of the nearest segment', () => { - assert.equal(closestPointIndex(line, { lng: 0.2, lat: 0.1 }), 0); - assert.equal(closestPointIndex(line, { lng: 0.8, lat: 0.1 }), 1); - assert.equal(closestPointIndex(line, { lng: 1.6, lat: -0.1 }), 2); - assert.equal(closestPointIndex(line, { lng: 2.1, lat: 0.9 }), 3); - }); - - it('uses the distance to the segment, not to its ends', () => { - // far from every point but right next to the long segment - const coordinates = [0, 0, 10, 0, 10, 5]; - assert.equal(closestPointIndex(coordinates, { lng: 7, lat: 0.01 }), 1); - assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0); - }); -}); +import { closestPointIndexIn } from '$lib/geo/closest-point'; describe('closestPointIndexIn', () => { const lng = [0, 1, 2, 2, 5, 6]; @@ -39,6 +13,22 @@ describe('closestPointIndexIn', () => { assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5); }); + it('is the nearest end of the nearest segment', () => { + assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 0.2, lat: 0.1 }), 0); + assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 1.6, lat: -0.1 }), 2); + assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 2.1, lat: 0.9 }), 3); + }); + + it('uses the distance to the segment, not to its ends', () => { + // far from every point but right next to the long segment + const [x, y] = [ + [0, 10, 10], + [0, 0, 5], + ]; + assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 7, lat: 0.01 }), 1); + assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 3, lat: 0.01 }), 0); + }); + it('has no point in an empty range', () => { assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined); assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined); diff --git a/website/src/lib/geo/closest-point.ts b/website/src/lib/geo/closest-point.ts index a43d387c5..72f005151 100644 --- a/website/src/lib/geo/closest-point.ts +++ b/website/src/lib/geo/closest-point.ts @@ -1,25 +1,10 @@ const metersPerDegree = 111320; /** - * Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to - * `point`, among the two ends of the segment of the polyline that passes the closest to it. - * `undefined` for an empty polyline. Planar approximation, like the one used to simplify tracks. - */ -export function closestPointIndex( - coordinates: ArrayLike, - point: { lng: number; lat: number } -): number | undefined { - return closestIndex( - Math.floor(coordinates.length / 2), - (i) => coordinates[2 * i], - (i) => coordinates[2 * i + 1], - point - ); -} - -/** - * Like `closestPointIndex`, for the points `from` (included) to `to` (excluded) of a polyline - * given as the arrays of its longitudes and latitudes. The index is the one in the arrays. + * Index of the point, among the points `from` (included) to `to` (excluded) of a polyline given as + * the arrays of its longitudes and latitudes, that is the closest to `point`: one of the two ends + * of the segment of the polyline that passes the closest to it. The index is the one in the arrays, + * `undefined` for an empty range. Planar approximation, like the one used to simplify tracks. */ export function closestPointIndexIn( lng: ArrayLike, diff --git a/website/src/lib/state/selection-statistics.ts b/website/src/lib/state/selection-statistics.ts index 942293cb3..30802072d 100644 --- a/website/src/lib/state/selection-statistics.ts +++ b/website/src/lib/state/selection-statistics.ts @@ -18,3 +18,9 @@ engine.statistics.subscribe(() => slicedStatistics.set(undefined)); /** The point of the elevation profile that the pointer is over, shown on the map. */ export const hoveredPoint: Writable = writable(null); + +/** The trackpoint of the selection that the pointer is over on the map, shown on the profile. */ +export const hoveredTrackpoint: Writable = writable(null); + +// its index only makes sense for the selection it was found in +engine.statistics.subscribe(() => hoveredTrackpoint.set(null)); diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index 504e424d3..f85ec3c72 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -24,7 +24,11 @@ import { getURLForLanguage } from '$lib/utils'; import { inDesktopApp } from '$lib/engine/backend'; import { openLinksInBrowser } from '$lib/desktop/links'; - import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics'; + import { + hoveredPoint, + hoveredTrackpoint, + slicedStatistics, + } from '$lib/state/selection-statistics'; import { engine } from '$lib/engine'; import { boundsManager } from '$lib/state/bounds'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; @@ -195,6 +199,7 @@ statistics={engine.statistics} {slicedStatistics} {hoveredPoint} + {hoveredTrackpoint} {additionalDatasets} {elevationFill} />