mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
show hovered point on elevation profile
This commit is contained in:
1 parent
b80fa7d942
commit
21e27a4416
15 files changed
+260
-106
No files matched your search
@@ -31,6 +31,7 @@
|
|||||||
statistics,
|
statistics,
|
||||||
slicedStatistics,
|
slicedStatistics,
|
||||||
hoveredPoint,
|
hoveredPoint,
|
||||||
|
hoveredTrackpoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
showControls = true,
|
showControls = true,
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
statistics: Readable<SelectionStatistics>;
|
statistics: Readable<SelectionStatistics>;
|
||||||
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
hoveredPoint: Writable<Coordinates | null>;
|
hoveredPoint: Writable<Coordinates | null>;
|
||||||
|
hoveredTrackpoint?: Readable<number | null>;
|
||||||
additionalDatasets: Writable<string[]>;
|
additionalDatasets: Writable<string[]>;
|
||||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
@@ -55,7 +57,8 @@
|
|||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
canvas,
|
canvas,
|
||||||
overlay
|
overlay,
|
||||||
|
hoveredTrackpoint
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import {
|
|||||||
getVelocityWithUnits,
|
getVelocityWithUnits,
|
||||||
} from '$lib/units/units';
|
} from '$lib/units/units';
|
||||||
import Chart, {
|
import Chart, {
|
||||||
|
Tooltip,
|
||||||
|
type ActiveDataPoint,
|
||||||
type ChartEvent,
|
type ChartEvent,
|
||||||
type ChartOptions,
|
type ChartOptions,
|
||||||
|
type ChartType,
|
||||||
|
type Point,
|
||||||
type ScriptableLineSegmentContext,
|
type ScriptableLineSegmentContext,
|
||||||
type TooltipItem,
|
type TooltipItem,
|
||||||
|
type TooltipPositionerFunction,
|
||||||
} from 'chart.js/auto';
|
} from 'chart.js/auto';
|
||||||
import { get, type Readable, type Writable } from 'svelte/store';
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
import type { Coordinates } from '$lib/geo/coordinates';
|
import type { Coordinates } from '$lib/geo/coordinates';
|
||||||
@@ -40,6 +45,22 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
|||||||
Chart.defaults.font.family =
|
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
|
'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. */
|
/** `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) {
|
function optional(value: number, convert: (value: number) => number = (v) => v) {
|
||||||
return Number.isNaN(value) ? undefined : convert(value);
|
return Number.isNaN(value) ? undefined : convert(value);
|
||||||
@@ -55,6 +76,21 @@ interface ElevationProfilePoint {
|
|||||||
index: number;
|
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 {
|
export class ElevationProfile {
|
||||||
private _chart: Chart | null = null;
|
private _chart: Chart | null = null;
|
||||||
private _canvas: HTMLCanvasElement;
|
private _canvas: HTMLCanvasElement;
|
||||||
@@ -75,6 +111,12 @@ export class ElevationProfile {
|
|||||||
private _heldMove: MouseEvent | null = null;
|
private _heldMove: MouseEvent | null = null;
|
||||||
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
private _hoveredPoint: Writable<Coordinates | null>;
|
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 _additionalDatasets: Readable<string[]>;
|
||||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
/** The statistics that are only read from the engine while the profile shows them. */
|
/** The statistics that are only read from the engine while the profile shows them. */
|
||||||
@@ -87,7 +129,8 @@ export class ElevationProfile {
|
|||||||
additionalDatasets: Readable<string[]>,
|
additionalDatasets: Readable<string[]>,
|
||||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
overlay: HTMLCanvasElement
|
overlay: HTMLCanvasElement,
|
||||||
|
hoveredTrackpoint?: Readable<number | null>
|
||||||
) {
|
) {
|
||||||
this._statistics = statistics;
|
this._statistics = statistics;
|
||||||
this._slicedStatistics = slicedStatistics;
|
this._slicedStatistics = slicedStatistics;
|
||||||
@@ -96,6 +139,7 @@ export class ElevationProfile {
|
|||||||
this._elevationFill = elevationFill;
|
this._elevationFill = elevationFill;
|
||||||
this._canvas = canvas;
|
this._canvas = canvas;
|
||||||
this._overlay = overlay;
|
this._overlay = overlay;
|
||||||
|
this._hoveredTrackpoint = hoveredTrackpoint;
|
||||||
this._request = engine.requestStatistics();
|
this._request = engine.requestStatistics();
|
||||||
this.updateRequest();
|
this.updateRequest();
|
||||||
|
|
||||||
@@ -127,6 +171,9 @@ export class ElevationProfile {
|
|||||||
this.updateRequest();
|
this.updateRequest();
|
||||||
this.updateFill();
|
this.updateFill();
|
||||||
});
|
});
|
||||||
|
this._unsubscribeHoveredTrackpoint = this._hoveredTrackpoint?.subscribe((index) => {
|
||||||
|
this.showTrackpoint(index);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,20 +242,16 @@ export class ElevationProfile {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: () => !this._dragging && !this._panning,
|
enabled: () => !this._dragging && !this._panning,
|
||||||
|
position: 'trackpoint',
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: () => {
|
title: () => {
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
label: (context: TooltipItem<'line'>) => {
|
label: (context: TooltipItem<'line'>) => {
|
||||||
const point = context.raw as ElevationProfilePoint;
|
const point = this.pointOf(context);
|
||||||
if (context.datasetIndex === 0) {
|
if (context.datasetIndex === 0) {
|
||||||
this._hoveredPoint.set(
|
this._hoveredPoint.set(
|
||||||
this._dragging
|
this._dragging ? null : this.coordinatesOf(point.index)
|
||||||
? null
|
|
||||||
: {
|
|
||||||
lat: this._data.lat[point.index],
|
|
||||||
lng: this._data.lng[point.index],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 1) {
|
} else if (context.datasetIndex === 1) {
|
||||||
@@ -228,7 +271,7 @@ export class ElevationProfile {
|
|||||||
(context) => context.datasetIndex === 0
|
(context) => context.datasetIndex === 0
|
||||||
);
|
);
|
||||||
if (context.length === 0) return;
|
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`)
|
// the rest of the data is there once it is read (see `hover`)
|
||||||
const hovered = this._hovered;
|
const hovered = this._hovered;
|
||||||
const details =
|
const details =
|
||||||
@@ -478,6 +521,7 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
const data = get(this._statistics);
|
const data = get(this._statistics);
|
||||||
this._data = data;
|
this._data = data;
|
||||||
|
const shownTrackpoint = this._shownTrackpoint;
|
||||||
// the indices of the trackpoints are the ones of other statistics
|
// the indices of the trackpoints are the ones of other statistics
|
||||||
this._hovered = null;
|
this._hovered = null;
|
||||||
const units = {
|
const units = {
|
||||||
@@ -524,6 +568,7 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._points = datasets;
|
||||||
this._chart.data.datasets[0] = {
|
this._chart.data.datasets[0] = {
|
||||||
label: i18n._('quantities.elevation'),
|
label: i18n._('quantities.elevation'),
|
||||||
data: datasets[0],
|
data: datasets[0],
|
||||||
@@ -570,6 +615,10 @@ export class ElevationProfile {
|
|||||||
this.setFill();
|
this.setFill();
|
||||||
|
|
||||||
this._chart.update();
|
this._chart.update();
|
||||||
|
|
||||||
|
if (shownTrackpoint !== null) {
|
||||||
|
this.showTrackpoint(shownTrackpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDataVisibility() {
|
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. */
|
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
|
||||||
private indexAt(event: ChartEvent): number | undefined {
|
private indexAt(event: ChartEvent): number | undefined {
|
||||||
const elements = this._chart?.getElementsAtEventForMode(
|
const elements = this._chart?.getElementsAtEventForMode(
|
||||||
@@ -718,6 +834,9 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
hovered.loaded = true;
|
hovered.loaded = true;
|
||||||
hovered.point = point;
|
hovered.point = point;
|
||||||
|
if (this._shownTrackpoint === hovered.index) {
|
||||||
|
this.activateTrackpoint(hovered.index);
|
||||||
|
}
|
||||||
// the move that was held can now show the tooltip
|
// the move that was held can now show the tooltip
|
||||||
const move = this._heldMove;
|
const move = this._heldMove;
|
||||||
this._heldMove = null;
|
this._heldMove = null;
|
||||||
@@ -758,6 +877,7 @@ export class ElevationProfile {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._request.release();
|
this._request.release();
|
||||||
|
this._unsubscribeHoveredTrackpoint?.();
|
||||||
if (this._chart) {
|
if (this._chart) {
|
||||||
this._chart.destroy();
|
this._chart.destroy();
|
||||||
this._chart = null;
|
this._chart = null;
|
||||||
|
|||||||
@@ -18,7 +18,11 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
import {
|
||||||
|
hoveredPoint,
|
||||||
|
hoveredTrackpoint,
|
||||||
|
slicedStatistics,
|
||||||
|
} from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
useHash = true,
|
useHash = true,
|
||||||
@@ -119,6 +123,7 @@
|
|||||||
{statistics}
|
{statistics}
|
||||||
{slicedStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
|
{hoveredTrackpoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
showControls={options.elevation.controls}
|
showControls={options.elevation.controls}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||||
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
let distanceMarkers: DistanceMarkers;
|
let distanceMarkers: DistanceMarkers;
|
||||||
let startEndMarkers: StartEndMarkers;
|
let startEndMarkers: StartEndMarkers;
|
||||||
@@ -14,6 +15,11 @@
|
|||||||
startEndMarkers = new StartEndMarkers();
|
startEndMarkers = new StartEndMarkers();
|
||||||
distanceMarkers = new DistanceMarkers();
|
distanceMarkers = new DistanceMarkers();
|
||||||
createPopups(map_);
|
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(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import { Compass, Earth } from '@lucide/svelte';
|
||||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
import type { TrackpointDetails } from '$lib/engine';
|
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
trackpoint,
|
trackpoint,
|
||||||
}: {
|
}: {
|
||||||
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
|
trackpoint: PopupItem<{ lng: number; lat: number }>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,33 +20,19 @@
|
|||||||
<Compass size="14" />
|
<Compass size="14" />
|
||||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||||
</div>
|
</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
|
<CopyCoordinates
|
||||||
coordinates={trackpoint.item}
|
coordinates={trackpoint.item}
|
||||||
onCopy={() => trackpoint.hide?.()}
|
onCopy={() => trackpoint.hide?.()}
|
||||||
class="mt-0.5"
|
class="mt-0.5"
|
||||||
/>
|
/>
|
||||||
{#if trackpoint.fileId === undefined}
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
class="justify-start"
|
||||||
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)}`}
|
||||||
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"
|
||||||
target="_blank"
|
>
|
||||||
>
|
<Earth size="14" />
|
||||||
<Earth size="14" />
|
{i18n._('menu.edit_osm')}
|
||||||
{i18n._('menu.edit_osm')}
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Point,
|
Point,
|
||||||
} from 'maplibre-gl';
|
} from 'maplibre-gl';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
import { waypointPopup } from './gpx-layer-popup';
|
||||||
import { closestPointIndex } from '$lib/geo/closest-point';
|
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||||
import { MapPin, Square } from 'lucide-static';
|
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 { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
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) {
|
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||||
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||||
@@ -63,6 +64,7 @@ export class GPXLayer {
|
|||||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||||
draggedWaypointIndex: number | null = null;
|
draggedWaypointIndex: number | null = null;
|
||||||
draggingStartingPosition: Point = new Point(0, 0);
|
draggingStartingPosition: Point = new Point(0, 0);
|
||||||
|
hoversTrackpoint: boolean = false;
|
||||||
unsubscribe: (() => void)[] = [];
|
unsubscribe: (() => void)[] = [];
|
||||||
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
@@ -400,25 +402,50 @@ export class GPXLayer {
|
|||||||
layerOnMouseLeave() {
|
layerOnMouseLeave() {
|
||||||
mapCursor.notify(MapCursorState.SCISSORS, false);
|
mapCursor.notify(MapCursorState.SCISSORS, false);
|
||||||
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
||||||
|
this.stopHoveringTrackpoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
async layerOnMouseMove(e: MapLayerMouseEvent) {
|
layerOnMouseMove(e: MapLayerMouseEvent) {
|
||||||
if (e.originalEvent.shiftKey) {
|
const index = this.selectedTrackpointAt(e);
|
||||||
const { segmentId } = e.features![0].properties;
|
if (index === undefined) {
|
||||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
this.stopHoveringTrackpoint();
|
||||||
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
|
} else {
|
||||||
const trackpoint =
|
this.hoversTrackpoint = true;
|
||||||
index === undefined
|
hoveredTrackpoint.set(index);
|
||||||
? undefined
|
}
|
||||||
: await engine.trackpoint(this.fileId, segmentId, index);
|
}
|
||||||
if (trackpoint) {
|
|
||||||
trackpointPopup?.setItem({
|
stopHoveringTrackpoint() {
|
||||||
item: trackpoint,
|
if (this.hoversTrackpoint) {
|
||||||
kind: 'trackpoint',
|
this.hoversTrackpoint = false;
|
||||||
fileId: this.fileId,
|
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) {
|
layerOnClick(e: MapLayerMouseEvent) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileStateCollectionObserver } from '$lib/engine';
|
import { engine, FileStateCollectionObserver } from '$lib/engine';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { GPXLayer } from './gpx-layer';
|
import { GPXLayer } from './gpx-layer';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export class GPXLayerCollection {
|
|||||||
if (this._fileStateCollectionObserver) {
|
if (this._fileStateCollectionObserver) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
engine.requestStatistics().set(['segments']);
|
||||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||||
(newFiles) => {
|
(newFiles) => {
|
||||||
newFiles.forEach((fileStore, fileId) => {
|
newFiles.forEach((fileStore, fileId) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
|||||||
export type PopupItem<T = any> = {
|
export type PopupItem<T = any> = {
|
||||||
/** Has a position (`lng` and `lat`). */
|
/** Has a position (`lng` and `lat`). */
|
||||||
item: T;
|
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';
|
kind: 'waypoint' | 'trackpoint' | 'overpass';
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('statistics', () => {
|
|||||||
assert.equal(get(engine.statistics).hr, undefined);
|
assert.equal(get(engine.statistics).hr, undefined);
|
||||||
|
|
||||||
const request = engine.requestStatistics();
|
const request = engine.requestStatistics();
|
||||||
await request.set(['hr', 'anchors', 'slopeSegment']);
|
await request.set(['hr', 'anchors', 'slopeSegment', 'segments']);
|
||||||
const statistics = get(engine.statistics);
|
const statistics = get(engine.statistics);
|
||||||
assert.equal(statistics.slopeSegments!.starts[0], 0);
|
assert.equal(statistics.slopeSegments!.starts[0], 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -270,6 +270,8 @@ describe('statistics', () => {
|
|||||||
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
||||||
assert.equal(statistics.cad, undefined);
|
assert.equal(statistics.cad, undefined);
|
||||||
assert.equal(statistics.anchors!.indices[0], 0);
|
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);
|
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
|
||||||
|
|
||||||
// they are read again after a change, without being asked again
|
// they are read again after a change, without being asked again
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ export class StatisticsReader {
|
|||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'segments': {
|
||||||
|
const [starts, ids] = await Promise.all([
|
||||||
|
backend.segment_starts(),
|
||||||
|
backend.segment_ids(),
|
||||||
|
]);
|
||||||
|
statistics.segments = { starts, ids };
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'highway':
|
case 'highway':
|
||||||
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ export type StatisticsMetric =
|
|||||||
/** `highway`, `sacScale` and `mtbScale`. */
|
/** `highway`, `sacScale` and `mtbScale`. */
|
||||||
| 'highway'
|
| 'highway'
|
||||||
/** `anchors`. */
|
/** `anchors`. */
|
||||||
| 'anchors';
|
| 'anchors'
|
||||||
|
/** `segments`. */
|
||||||
|
| 'segments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
|
* 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;
|
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
|
* 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
|
* 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. */
|
/** Mountain biking scale. */
|
||||||
mtbScale?: CategoryIntervals;
|
mtbScale?: CategoryIntervals;
|
||||||
anchors?: SelectionAnchors;
|
anchors?: SelectionAnchors;
|
||||||
|
segments?: SelectionSegments;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global statistics of the trackpoints from `start` to `end` (both included), for example
|
* Global statistics of the trackpoints from `start` to `end` (both included), for example
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { closestPointIndex, closestPointIndexIn } from '$lib/geo/closest-point';
|
import { 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('closestPointIndexIn', () => {
|
describe('closestPointIndexIn', () => {
|
||||||
const lng = [0, 1, 2, 2, 5, 6];
|
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);
|
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', () => {
|
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, 3, 3, { lng: 0, lat: 0 }), undefined);
|
||||||
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
|
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
const metersPerDegree = 111320;
|
const metersPerDegree = 111320;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to
|
* Index of the point, among the points `from` (included) to `to` (excluded) of a polyline given as
|
||||||
* `point`, among the two ends of the segment of the polyline that passes the closest to it.
|
* the arrays of its longitudes and latitudes, that is the closest to `point`: one of the two ends
|
||||||
* `undefined` for an empty polyline. Planar approximation, like the one used to simplify tracks.
|
* 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 closestPointIndex(
|
|
||||||
coordinates: ArrayLike<number>,
|
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
export function closestPointIndexIn(
|
export function closestPointIndexIn(
|
||||||
lng: ArrayLike<number>,
|
lng: ArrayLike<number>,
|
||||||
|
|||||||
@@ -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. */
|
/** The point of the elevation profile that the pointer is over, shown on the map. */
|
||||||
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
||||||
|
|
||||||
|
/** The trackpoint of the selection that the pointer is over on the map, shown on the profile. */
|
||||||
|
export const hoveredTrackpoint: Writable<number | null> = writable(null);
|
||||||
|
|
||||||
|
// its index only makes sense for the selection it was found in
|
||||||
|
engine.statistics.subscribe(() => hoveredTrackpoint.set(null));
|
||||||
@@ -24,7 +24,11 @@
|
|||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { inDesktopApp } from '$lib/engine/backend';
|
import { inDesktopApp } from '$lib/engine/backend';
|
||||||
import { openLinksInBrowser } from '$lib/desktop/links';
|
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 { engine } from '$lib/engine';
|
||||||
import { boundsManager } from '$lib/state/bounds';
|
import { boundsManager } from '$lib/state/bounds';
|
||||||
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
||||||
@@ -195,6 +199,7 @@
|
|||||||
statistics={engine.statistics}
|
statistics={engine.statistics}
|
||||||
{slicedStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
|
{hoveredTrackpoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user