mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-08-30 23:30:04 +00:00
refactor map popups and add inspect trackpoint feature
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
Construction
|
||||
} from 'lucide-svelte';
|
||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
getCadenceWithUnits,
|
||||
getConvertedDistance,
|
||||
@@ -36,10 +36,10 @@
|
||||
getVelocityWithUnits
|
||||
} from '$lib/units';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { DateFormatter } from '@internationalized/date';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import { settings } from '$lib/db';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { df } from '$lib/utils';
|
||||
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
@@ -49,15 +49,6 @@
|
||||
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
|
||||
let df: DateFormatter;
|
||||
|
||||
$: if ($locale) {
|
||||
df = new DateFormatter($locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium'
|
||||
});
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let overlay: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
|
25
website/src/lib/components/MapPopup.svelte
Normal file
25
website/src/lib/components/MapPopup.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<svelte:options accessors />
|
||||
|
||||
<script lang="ts">
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
||||
import type { PopupItem } from './MapPopup';
|
||||
|
||||
export let item: Writable<PopupItem | null>;
|
||||
export let container: HTMLDivElement | null = null;
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
{#if $item}
|
||||
{#if $item.item instanceof Waypoint}
|
||||
<WaypointPopup waypoint={$item} />
|
||||
{:else if $item.item instanceof TrackPoint}
|
||||
<TrackpointPopup trackpoint={$item} />
|
||||
{:else}
|
||||
<OverpassPopup poi={$item} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
78
website/src/lib/components/MapPopup.ts
Normal file
78
website/src/lib/components/MapPopup.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TrackPoint, Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { tick } from "svelte";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import MapPopupComponent from "./MapPopup.svelte";
|
||||
|
||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
item: T;
|
||||
fileId?: string;
|
||||
};
|
||||
|
||||
export class MapPopup {
|
||||
map: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
item: Writable<PopupItem | null> = writable(null);
|
||||
maybeHideBinded = this.maybeHide.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||
this.map = map;
|
||||
this.popup = new mapboxgl.Popup(options);
|
||||
|
||||
let component = new MapPopupComponent({
|
||||
target: document.body,
|
||||
props: {
|
||||
item: this.item
|
||||
}
|
||||
});
|
||||
|
||||
tick().then(() => this.popup.setDOMContent(component.container));
|
||||
}
|
||||
|
||||
setItem(item: PopupItem | null) {
|
||||
this.item.set(item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
} else {
|
||||
tick().then(() => this.show());
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
|
||||
this.map.on('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.remove();
|
||||
this.map.off('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.popup.remove();
|
||||
}
|
||||
|
||||
getCoordinates() {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return (i.item instanceof Waypoint || i.item instanceof TrackPoint) ? i.item.getCoordinates() : new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
}
|
||||
}
|
@@ -49,17 +49,11 @@
|
||||
gpxLayers,
|
||||
map
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
GPXTreeElement,
|
||||
Track,
|
||||
TrackSegment,
|
||||
type AnyGPXTreeElement,
|
||||
Waypoint,
|
||||
GPXFile
|
||||
} from 'gpx';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import MetadataDialog from './MetadataDialog.svelte';
|
||||
import StyleDialog from './StyleDialog.svelte';
|
||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
@@ -179,7 +173,7 @@
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
layer.showWaypointPopup(waypoint);
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: item.getFileId() });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +182,7 @@
|
||||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
layer.hideWaypointPopup();
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
@@ -2,15 +2,13 @@ import { currentTool, map, Tool } from "$lib/stores";
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointPopup";
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from "./GPXLayerPopup";
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import type { Waypoint } from "gpx";
|
||||
import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
import { tick } from "svelte";
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -44,6 +42,31 @@ function decrementColor(color: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const inspectKey = 'Shift';
|
||||
let inspectKeyDown: KeyDown | null = null;
|
||||
class KeyDown {
|
||||
key: string;
|
||||
down: boolean = false;
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
document.addEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = true;
|
||||
}
|
||||
}
|
||||
onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = false;
|
||||
}
|
||||
}
|
||||
isDown() {
|
||||
return this.down;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
@@ -81,9 +104,9 @@ export class GPXLayer {
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
this.map = map;
|
||||
@@ -114,6 +137,10 @@ export class GPXLayer {
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
|
||||
if (inspectKeyDown === null) {
|
||||
inspectKeyDown = new KeyDown(inspectKey);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
@@ -158,6 +185,7 @@ export class GPXLayer {
|
||||
this.map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
@@ -225,11 +253,11 @@ export class GPXLayer {
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
marker.getElement().addEventListener('mouseover', (e) => {
|
||||
marker.getElement().addEventListener('mousemove', (e) => {
|
||||
if (marker._isDragging) {
|
||||
return;
|
||||
}
|
||||
this.showWaypointPopup(marker._waypoint);
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
@@ -252,14 +280,14 @@ export class GPXLayer {
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
} else {
|
||||
this.showWaypointPopup(marker._waypoint);
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.on('dragstart', () => {
|
||||
setGrabbingCursor();
|
||||
marker.getElement().style.cursor = 'grabbing';
|
||||
this.hideWaypointPopup();
|
||||
waypointPopup?.hide();
|
||||
});
|
||||
marker.on('dragend', (e) => {
|
||||
resetCursor();
|
||||
@@ -308,6 +336,7 @@ export class GPXLayer {
|
||||
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
this.map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
@@ -354,6 +383,19 @@ export class GPXLayer {
|
||||
resetCursor();
|
||||
}
|
||||
|
||||
layerOnMouseMove(e: any) {
|
||||
if (inspectKeyDown?.isDown()) {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(file.trk[trackIndex].trkseg[segmentIndex].trkpt, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
return;
|
||||
@@ -392,48 +434,6 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
showWaypointPopup(waypoint: Waypoint) {
|
||||
if (get(currentPopupWaypoint) !== null) {
|
||||
this.hideWaypointPopup();
|
||||
} else if (waypoint === get(currentPopupWaypoint)?.[0]) {
|
||||
return;
|
||||
}
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
if (marker) {
|
||||
currentPopupWaypoint.set([waypoint, this.fileId]);
|
||||
tick().then(() => {
|
||||
// Show popup once the content component has been rendered
|
||||
marker.setPopup(waypointPopup);
|
||||
marker.togglePopup();
|
||||
this.map.on('mousemove', this.maybeHideWaypointPopupBinded);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
maybeHideWaypointPopup(e: any) {
|
||||
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||
if (waypoint) {
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
if (marker) {
|
||||
if (this.map.project(marker.getLngLat()).dist(this.map.project(e.lngLat)) > 60) {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
} else {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideWaypointPopup() {
|
||||
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||
if (waypoint) {
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
marker?.getPopup()?.remove();
|
||||
currentPopupWaypoint.set(null);
|
||||
this.map.off('mousemove', this.maybeHideWaypointPopupBinded);
|
||||
}
|
||||
}
|
||||
|
||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
|
44
website/src/lib/components/gpx-layer/GPXLayerPopup.ts
Normal file
44
website/src/lib/components/gpx-layer/GPXLayerPopup.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { dbUtils } from "$lib/db";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
|
||||
export function createPopups(map: mapboxgl.Map) {
|
||||
removePopups();
|
||||
waypointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [10, -15],
|
||||
'right': [-10, -15],
|
||||
},
|
||||
});
|
||||
trackpointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function removePopups() {
|
||||
if (waypointPopup !== null) {
|
||||
waypointPopup.remove();
|
||||
waypointPopup = null;
|
||||
}
|
||||
if (trackpointPopup !== null) {
|
||||
trackpointPopup.remove();
|
||||
trackpointPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { map, gpxLayers } from '$lib/stores';
|
||||
import { GPXLayer } from './GPXLayer';
|
||||
import WaypointPopup from './WaypointPopup.svelte';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { DistanceMarkers } from './DistanceMarkers';
|
||||
import { StartEndMarkers } from './StartEndMarkers';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { createPopups, removePopups } from './GPXLayerPopup';
|
||||
|
||||
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
||||
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
||||
@@ -35,6 +35,7 @@
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
}
|
||||
createPopups($map);
|
||||
distanceMarkers = new DistanceMarkers($map);
|
||||
startEndMarkers = new StartEndMarkers($map);
|
||||
}
|
||||
@@ -42,17 +43,14 @@
|
||||
onDestroy(() => {
|
||||
gpxLayers.forEach((layer) => layer.remove());
|
||||
gpxLayers.clear();
|
||||
|
||||
removePopups();
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
distanceMarkers = undefined;
|
||||
}
|
||||
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
startEndMarkers = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<WaypointPopup />
|
||||
|
36
website/src/lib/components/gpx-layer/TrackpointPopup.svelte
Normal file
36
website/src/lib/components/gpx-layer/TrackpointPopup.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md"></Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</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}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{df.format(trackpoint.item.time)}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
@@ -2,23 +2,19 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
|
||||
import { deleteWaypoint } from './GPXLayerPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
|
||||
onMount(() => {
|
||||
waypointPopup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
|
||||
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
|
||||
function sanitize(text: string | undefined): string {
|
||||
if (text === undefined) {
|
||||
@@ -34,68 +30,61 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={popupElement} class="hidden">
|
||||
{#if $currentPopupWaypoint}
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
|
||||
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
|
||||
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
{waypoint.item.name ?? $_('gpx.waypoint.item')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm">
|
||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
{#if symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="12"
|
||||
class="inline-block mb-0.5"
|
||||
/>
|
||||
{:else}
|
||||
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm">
|
||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
{#if symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="12"
|
||||
class="inline-block mb-0.5"
|
||||
/>
|
||||
{:else}
|
||||
<span class="w-4 inline-block" />
|
||||
{/if}
|
||||
{$_(`gpx.symbol.${symbolKey}`)}
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}° {$currentPopupWaypoint[0]
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if $currentPopupWaypoint[0].ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={$currentPopupWaypoint[0].ele} type="elevation" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
|
||||
{/if}
|
||||
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
|
||||
{/if}
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() =>
|
||||
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{$_(`gpx.symbol.${symbolKey}`)}
|
||||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item.getLongitude().toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||
{/if}
|
||||
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
||||
{/if}
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
|
@@ -1,26 +0,0 @@
|
||||
import { dbUtils } from "$lib/db";
|
||||
import type { Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const currentPopupWaypoint = writable<[Waypoint, string] | null>(null);
|
||||
|
||||
export const waypointPopup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [10, -15],
|
||||
'right': [-10, -15],
|
||||
},
|
||||
});
|
||||
|
||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||
}
|
@@ -13,7 +13,6 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import OverpassPopup from './OverpassPopup.svelte';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
@@ -34,7 +33,7 @@
|
||||
if ($map) {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
@@ -211,8 +210,6 @@
|
||||
</div>
|
||||
</CustomControl>
|
||||
|
||||
<OverpassPopup />
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||
import { getLayers } from "./utils";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { liveQuery } from "dexie";
|
||||
import { db, settings } from "$lib/db";
|
||||
import { overpassQueryData } from "$lib/assets/layers";
|
||||
import { tick } from "svelte";
|
||||
import { MapPopup } from "$lib/components/MapPopup";
|
||||
|
||||
const {
|
||||
currentOverpassQueries
|
||||
@@ -15,15 +14,6 @@ const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
});
|
||||
|
||||
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
|
||||
|
||||
export const overpassPopup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: 15,
|
||||
});
|
||||
|
||||
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||
@@ -36,6 +26,7 @@ export class OverpassLayer {
|
||||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: mapboxgl.Map;
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||
@@ -44,10 +35,15 @@ export class OverpassLayer {
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
updateBinded = this.update.bind(this);
|
||||
onHoverBinded = this.onHover.bind(this);
|
||||
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
this.popup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: 15,
|
||||
});
|
||||
}
|
||||
|
||||
add() {
|
||||
@@ -127,30 +123,12 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
onHover(e: any) {
|
||||
overpassPopupPOI.set({
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
this.popup.setItem({
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
}
|
||||
});
|
||||
tick().then(() => {
|
||||
// Show the popup once the content component has been rendered
|
||||
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
|
||||
overpassPopup.addTo(this.map);
|
||||
this.map.on('mousemove', this.maybeHidePopupBinded);
|
||||
});
|
||||
}
|
||||
|
||||
maybeHidePopup(e: any) {
|
||||
let poi = get(overpassPopupPOI);
|
||||
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 60) {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
}
|
||||
|
||||
hideWaypointPopup() {
|
||||
overpassPopupPOI.set(null);
|
||||
overpassPopup.remove();
|
||||
|
||||
this.map.off('mousemove', this.maybeHidePopupBinded);
|
||||
}
|
||||
|
||||
query(bbox: [number, number, number, number]) {
|
||||
|
@@ -1,102 +1,92 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { overpassPopup, overpassPopupPOI } from './OverpassLayer';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
overpassPopup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
export let poi: PopupItem<any>;
|
||||
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if ($overpassPopupPOI) {
|
||||
tags = JSON.parse($overpassPopupPOI.tags);
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${$overpassPopupPOI.query}`);
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={popupElement} class="hidden">
|
||||
{#if $overpassPopupPOI}
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{$overpassPopupPOI.lat.toFixed(6)}° {$overpassPopupPOI.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={$overpassPopupPOI.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
class="mt-2"
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: $overpassPopupPOI.lat,
|
||||
lon: $overpassPopupPOI.lon
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: $overpassPopupPOI.sym
|
||||
});
|
||||
}}
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
on:click={() => {
|
||||
let desc = Object.entries(tags)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: poi.item.sym
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MapPin size="16" class="mr-1" />
|
||||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
@@ -12,6 +12,7 @@ import mapboxgl from "mapbox-gl";
|
||||
import tilebelt from "@mapbox/tilebelt";
|
||||
import { PUBLIC_MAPBOX_TOKEN } from "$env/static/public";
|
||||
import PNGReader from "png.js";
|
||||
import type { DateFormatter } from "@internationalized/date";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -215,4 +216,16 @@ export function getURLForLanguage(lang: string | null | undefined, path: string)
|
||||
return `${base}${newPath}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDateFormatter(locale: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium'
|
||||
});
|
||||
}
|
||||
|
||||
export let df: DateFormatter = getDateFormatter('en');
|
||||
locale.subscribe((l) => {
|
||||
df = getDateFormatter(l ?? 'en');
|
||||
});
|
Reference in New Issue
Block a user