Migration to new Rust-based engine (#401)

* wasm first steps

* parsing start

* parsing continued

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* progress

* fixes

* reorganize

* change type

* fix

* progress

* refactoring

* progress

* progress

* progress

* keep style info

* progress

* progress

* remove (copy) in name

* progress

* merge methods into 1

* splice

* progress

* progress

* progress

* cleaning

* progress

* progress

* progress

* visibility

* progress

* progress

* progress

* progress

* progress

* fixes

* progress

* fix markers

* fix

* progress

* progress

* progress

* progress

* progress

* progress

* small fix

* use engine info

* fix popups

* small fix

* progress

* progress

* progress

* clean tool

* handle self closing elements

* reduce data exchanges across boundary

* copy on wasm side to avoid any data corruption

* speed fix

* routing

* refactoring

* reverse

* reverse

* crop

* split

* merge

* progress

* time tool

* fix bounds for waypoints

* reduce

* fix reactivity

* handle corrupt time data

* export

* progress

* remove gpx js package

* remove todo

* update readme

* cleaning

* progress

* fix link fields

* remove unused fields

* refactoring and tests

* import and export of routes, ref #192

* per-chunk categories storage

* cross-tab sync

* outdated state

* refactoring

* reorder methods

* refactoring

* fix

* improve flow

* renaming

* progress

* progress

* fix

* progress

* per file history

* keep original flag

* use history variant

* fix

* progress

* file explorer

* progress

* fix

* sticky folders

* improved sticky folders

* fix sortable

* fix sortable

* fix

* fix tab size

* no file delete on desktop

* fix routing on other file

* fix sorting

* remember language

* open links in browser

* commit missing changes

* avoid selecting on resize

* clarify filename

* explorer actions

* fix folder styling

* drop anywhere in folder

* cleaning

* fixes

* remove focus-ring

* full screen

* small fix

* consistent highlight color

* improved preview

* performance improvements

* sparser data transfers

* sparser data transfers

* show popup once data is loaded

* fix routing control size

* fix

* sparse desktop build

* remove custom ign styles

* use new endpoint for openhikingmap

* strip native menu

* reorder context menu items

* route full support

* route - track conversion

* create route file action

* route export, filter out anchors at high zoom level

* clarify name handling

* move hide & center to view

* routes continued

* update docs

* fix power format, closes #378

* lint fixes

* fix display of additional data with gaps, closes #361

* fix linting issues

* fix import modules

* fix multidrag

* change base to resolve

* fix type errors

* fix layer typings

* fix last type error

* each keys

* improve typings

* deploy to cloudflare

---------

Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

@@ -1,7 +1,6 @@
<script lang="ts">
import { map } from '$lib/components/map/map';
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { TrackPoint } from 'gpx';
map.onLoad((map_) => {
map_.on('contextmenu', (e) => {
@@ -9,19 +8,15 @@
map_.queryRenderedFeatures(e.point, {
layers: map_
.getLayersOrder()
.filter((layerId) => layerId.startsWith('routing-controls')),
.filter((layerId) => layerId == 'routing-controls'),
}).length
) {
// Clicked on routing control, ignoring
return;
}
trackpointPopup?.setItem({
item: new TrackPoint({
attributes: {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
},
}),
kind: 'trackpoint',
item: { lng: e.lngLat.lng, lat: e.lngLat.lat },
});
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { page } from '$app/state';
import { map } from '$lib/components/map/map';
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
@@ -1,5 +1,4 @@
<script lang="ts">
import { TrackPoint, Waypoint } from 'gpx';
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
@@ -23,9 +22,9 @@
<div bind:this={container}>
{#if $item}
{#if $item.item instanceof Waypoint}
{#if $item.kind === 'waypoint'}
<WaypointPopup waypoint={$item} />
{:else if $item.item instanceof TrackPoint}
{:else if $item.kind === 'trackpoint'}
<TrackpointPopup trackpoint={$item} />
{:else}
<OverpassPopup poi={$item} />
@@ -1,15 +1,14 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ClipboardCopy } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import type { Coordinates } from 'gpx';
import { i18n } from '$lib/i18n/i18n.svelte';
let {
coordinates,
onCopy = () => {},
class: className = '',
}: {
coordinates: Coordinates;
coordinates: { lng: number; lat: number };
onCopy?: () => void;
class?: string;
} = $props();
@@ -20,7 +19,7 @@
variant="outline"
onclick={() => {
navigator.clipboard.writeText(
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
`${coordinates.lat.toFixed(6)}, ${coordinates.lng.toFixed(6)}`
);
onCopy();
}}
@@ -1,24 +1,26 @@
<script lang="ts">
import type { TrackPoint } from 'gpx';
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 { i18n } from '$lib/i18n.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<TrackPoint> } = $props();
let {
trackpoint,
}: {
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
} = $props();
</script>
<Card.Root class="border-none shadow-md text-base p-2">
<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)}&deg; {trackpoint.item
.getLongitude()
.toFixed(6)}&deg;
{trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg;
</div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
@@ -26,14 +28,14 @@
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time}
{#if trackpoint.item.time !== undefined}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{i18n.df.format(trackpoint.item.time)}
{i18n.df.format(new Date(trackpoint.item.time))}
</div>
{/if}
<CopyCoordinates
coordinates={trackpoint.item.attributes}
coordinates={trackpoint.item}
onCopy={() => trackpoint.hide?.()}
class="mt-0.5"
/>
@@ -41,7 +43,7 @@
<Button
variant="outline"
class="justify-start"
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().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"
>
<Earth size="14" />
@@ -6,24 +6,26 @@
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { i18n } from '$lib/i18n.svelte';
import { getSymbolKey, symbols } from '$lib/data/symbols';
import { i18n } from '$lib/i18n/i18n.svelte';
import sanitizeHtml from 'sanitize-html';
import type { Waypoint } from 'gpx';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { fileActions } from '$lib/logic/file-actions';
import type { PopupItem } from '$lib/components/map/map-popup';
import { selection } from '$lib/logic/selection';
import { ListFileItem } from '$lib/components/file-list/file-list';
import { engine, type WaypointDetails } from '$lib/engine';
import { hasSelectionWithin } from '$lib/engine/selection-helpers';
let {
waypoint,
}: {
waypoint: PopupItem<Waypoint>;
waypoint: PopupItem<WaypointDetails>;
} = $props();
const { selection } = engine;
let selected = $derived(
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
waypoint.fileId
? hasSelectionWithin($selection, { type: 'file', fileId: waypoint.fileId })
: false
);
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
@@ -44,9 +46,10 @@
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
<Card.Header class="p-0 gap-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}
{@const link = waypoint.item.links?.[0]}
{#if link?.href}
<a href={link.href} target="_blank" rel="external">
{waypoint.item.name ?? link.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
@@ -68,30 +71,28 @@
</span>
<Dot size="16" />
{/if}
{waypoint.item.getLatitude().toFixed(6)}&deg; {waypoint.item
.getLongitude()
.toFixed(6)}&deg;
{#if waypoint.item.ele !== undefined}
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
{/if}
{waypoint.item.lat.toFixed(6)}&deg; {waypoint.item.lng.toFixed(6)}&deg;
<Dot size="16" />
<WithUnits value={waypoint.item.ele} type="elevation" />
</div>
<ScrollArea class="flex flex-col max-h-[30dvh]">
{#if waypoint.item.desc}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized -->
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
{/if}
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized -->
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
{/if}
</ScrollArea>
<div class="mt-2 flex flex-col gap-1">
<CopyCoordinates coordinates={waypoint.item.attributes} />
<CopyCoordinates coordinates={waypoint.item} />
{#if $currentTool === Tool.WAYPOINT && selected}
<Button
variant="outline"
onclick={() => {
if (waypoint.fileId) {
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
engine.deleteWaypoint(waypoint.fileId, waypoint.item.id);
waypoint.hide?.();
}
}}
@@ -1,9 +1,9 @@
import { settings } from '$lib/logic/settings';
import { gpxStatistics } from '$lib/logic/statistics';
import { getConvertedDistanceToKilometers } from '$lib/units';
import { settings } from '$lib/state/settings';
import { engine } from '$lib/engine';
import { getConvertedDistanceToKilometers } from '$lib/units/units';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { allHidden } from '$lib/engine/selection-stores';
import type { GeoJSONSource } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
@@ -16,7 +16,7 @@ export class DistanceMarkers {
unsubscribes: (() => void)[] = [];
constructor() {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(engine.statistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -35,7 +35,8 @@ export class DistanceMarkers {
try {
if (get(distanceMarkers) && !get(allHidden)) {
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
const distanceSource: GeoJSONSource | undefined =
map_.getSource('distance-markers');
if (distanceSource) {
distanceSource.setData(this.getDistanceMarkersGeoJSON());
} else {
@@ -90,7 +91,7 @@ export class DistanceMarkers {
map_.removeLayer('distance-markers');
}
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
return;
}
@@ -101,19 +102,20 @@ export class DistanceMarkers {
}
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
let statistics = get(gpxStatistics);
// the distance from the start of the selection, and the position, of each trackpoint
const { length, totalDistance, lng, lat } = get(engine.statistics);
let features: GeoJSON.Feature[] = [];
const features: GeoJSON.Feature[] = [];
let currentTargetDistance = 1;
statistics.forEachTrackPoint((trkpt, dist) => {
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
let distance = currentTargetDistance.toFixed(0);
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
for (let i = 0; i < length; i++) {
if (totalDistance[i] >= getConvertedDistanceToKilometers(currentTargetDistance)) {
const distance = currentTargetDistance.toFixed(0);
const level = levels.find((level) => currentTargetDistance % level === 0) || 1;
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
coordinates: [lng[i], lat[i]],
},
properties: {
distance,
@@ -122,7 +124,7 @@ export class DistanceMarkers {
} as GeoJSON.Feature);
currentTargetDistance += 1;
}
});
}
return {
type: 'FeatureCollection',
@@ -8,82 +8,22 @@ import {
} from 'maplibre-gl';
import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
import {
ListTrackSegmentItem,
ListWaypointItem,
ListWaypointsItem,
ListTrackItem,
ListFileItem,
ListRootItem,
} from '$lib/components/file-list/file-list';
import { getClosestLinePoint, getElevation, loadSVGIcon } from '$lib/utils';
import { closestPointIndex } 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';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
import { selection } from '$lib/logic/selection';
import { settings } from '$lib/logic/settings';
import { getSymbolKey, symbols } from '$lib/data/symbols';
import { engine, type FileState, type Selection } from '$lib/engine';
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/engine/selection-helpers';
import { isSegmentHidden, isWaypointHidden } from '$lib/engine/file-visibility';
import { settings } from '$lib/state/settings';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { fileActionManager } from '$lib/logic/file-action-manager';
import { fileActions } from '$lib/logic/file-actions';
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
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 { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
const colors = [
'#ff0000',
'#0000ff',
'#46e646',
'#00ccff',
'#ff9900',
'#ff00ff',
'#ffff32',
'#288228',
'#9933ff',
'#50f0be',
'#8c645a',
];
const colorCount: { [key: string]: number } = {};
for (let color of colors) {
colorCount[color] = 0;
}
// Get the color with the least amount of uses
function getColor(fileId: string) {
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
colorCount[color]++;
gpxColors.update((colors) => {
colors.set(fileId, color);
return colors;
});
return color;
}
function replaceColor(fileId: string, oldColor: string, newColor: string) {
if (colorCount.hasOwnProperty(oldColor)) {
colorCount[oldColor]--;
}
colorCount[newColor]++;
gpxColors.update((colors) => {
colors.set(fileId, newColor);
return colors;
});
}
function removeColor(fileId: string, color: string) {
if (colorCount.hasOwnProperty(color)) {
colorCount[color]--;
}
gpxColors.update((colors) => {
colors.delete(fileId);
return colors;
});
}
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
${
layerColor
@@ -116,18 +56,19 @@ const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = setting
export class GPXLayer {
fileId: string;
file: Readable<GPXFileWithStatistics | undefined>;
layerColor: string;
file: Readable<FileState>;
layerColor: string = '';
selectionState: Selection = { type: 'empty' };
selected: boolean = false;
currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null;
draggingStartingPosition: Point = new Point(0, 0);
unsubscribe: Function[] = [];
unsubscribe: (() => void)[] = [];
updateBinded: () => void = this.update.bind(this);
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void = this.layerOnMouseEnter.bind(this);
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
layerOnMouseMoveBinded: (e: MapLayerMouseEvent) => void = this.layerOnMouseMove.bind(this);
layerOnClickBinded: (e: MapLayerMouseEvent) => void = this.layerOnClick.bind(this);
layerOnContextMenuBinded: (e: MapLayerMouseEvent) => void = this.layerOnContextMenu.bind(this);
waypointLayerOnMouseEnterBinded: (e: MapLayerMouseEvent) => void =
@@ -145,10 +86,9 @@ export class GPXLayer {
waypointLayerOnMouseUpBinded: (e: MapLayerMouseEvent | MapLayerTouchEvent) => void =
this.waypointLayerOnMouseUp.bind(this);
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
constructor(fileId: string, file: Readable<FileState>) {
this.fileId = fileId;
this.file = file;
this.layerColor = getColor(fileId);
this.unsubscribe.push(
map.subscribe(($map) => {
if ($map) {
@@ -159,8 +99,9 @@ export class GPXLayer {
);
this.unsubscribe.push(file.subscribe(this.updateBinded));
this.unsubscribe.push(
selection.subscribe(($selection) => {
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
engine.selection.subscribe(($selection) => {
this.selectionState = $selection;
const newSelected = hasSelectionWithin($selection, this.fileNode());
if (this.selected || newSelected) {
this.selected = newSelected;
this.update();
@@ -173,27 +114,39 @@ export class GPXLayer {
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
}
fileNode(): FileTreeNode {
return { type: 'file', fileId: this.fileId };
}
/** The node of a segment of the map: its route, which has no other, or the segment itself. */
segmentNode(properties: GeoJSON.GeoJsonProperties): FileTreeNode {
if (properties!.routeId !== undefined) {
return { type: 'route', fileId: this.fileId, routeId: properties!.routeId };
}
return {
type: 'segment',
fileId: this.fileId,
trackId: properties!.trackId,
segmentId: properties!.segmentId,
};
}
update() {
const _map = get(map);
const layerEventManager = map.layerEventManager;
let file = get(this.file)?.file;
if (!_map || !layerEventManager || !file) {
const state = get(this.file);
if (!_map || !layerEventManager) {
return;
}
if (
file._data.style &&
file._data.style.color &&
this.layerColor !== `#${file._data.style.color}`
) {
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
this.layerColor = `#${file._data.style.color}`;
if (this.layerColor !== state.color) {
this.layerColor = state.color;
}
this.loadIcons();
try {
let source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
const source = _map.getSource(this.fileId) as GeoJSONSource | undefined;
if (source) {
source.setData(this.getGeoJSON());
} else {
@@ -229,16 +182,20 @@ export class GPXLayer {
layerEventManager.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
}
let visibleTrackSegmentIds: string[] = [];
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (!segment._data.hidden) {
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
}
});
const visibleSegmentIds = state.segments.features
.filter(
({ properties }) =>
!isSegmentHidden(
state,
properties.routeId ?? properties.trackId!,
properties.segmentId
)
)
.map(({ properties }) => properties.segmentId);
const segmentFilter: FilterSpecification = [
'in',
['get', 'trackSegmentId'],
['literal', visibleTrackSegmentIds],
['get', 'segmentId'],
['literal', visibleSegmentIds],
];
_map.setFilter(this.fileId, segmentFilter, { validate: false });
@@ -277,7 +234,7 @@ export class GPXLayer {
}
}
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
const waypointSource = _map.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
this.currentWaypointData = this.getWaypointsGeoJSON();
@@ -335,19 +292,16 @@ export class GPXLayer {
);
}
let visibleWaypoints: number[] = [];
file.wpt.forEach((waypoint, waypointIndex) => {
if (!waypoint._data.hidden) {
visibleWaypoints.push(waypointIndex);
}
});
const visibleWaypoints = state.waypoints.features
.filter(({ properties }) => !isWaypointHidden(state, properties.waypointId))
.map(({ properties }) => properties.index);
_map.setFilter(
this.fileId + '-waypoints',
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
{ validate: false }
);
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
return;
}
@@ -414,8 +368,6 @@ export class GPXLayer {
}
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
removeColor(this.fileId, this.layerColor);
}
moveToFront() {
@@ -434,15 +386,10 @@ export class GPXLayer {
}
}
layerOnMouseEnter(e: any) {
let trackIndex = e.features[0].properties.trackIndex;
let segmentIndex = e.features[0].properties.segmentIndex;
layerOnMouseEnter(e: MapLayerMouseEvent) {
if (
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
isCovered(this.selectionState, this.segmentNode(e.features![0].properties))
) {
mapCursor.notify(MapCursorState.SCISSORS, true);
} else {
@@ -455,75 +402,74 @@ export class GPXLayer {
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
}
layerOnMouseMove(e: any) {
async layerOnMouseMove(e: MapLayerMouseEvent) {
if (e.originalEvent.shiftKey) {
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 });
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,
});
}
}
}
layerOnClick(e: MapLayerMouseEvent) {
const properties = e.features![0].properties!;
const { routeId, trackId, segmentId } = properties;
const selectionType = this.selectionState.type;
if (
get(currentTool) === Tool.ROUTING &&
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
selectionType !== 'empty' &&
selectionType !== 'waypoints' &&
selectionType !== 'waypoint'
) {
return;
}
let trackIndex = e.features![0].properties!.trackIndex;
let segmentIndex = e.features![0].properties!.segmentIndex;
if (
get(currentTool) === Tool.SCISSORS &&
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
)
isCovered(this.selectionState, this.segmentNode(properties))
) {
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
// Clicked on split control, ignoring
return;
}
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
splitAtPoint(segmentId, { lng: e.lngLat.lng, lat: e.lngLat.lat });
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let item = undefined;
if (get(treeFileView) && file.getSegments().length > 1) {
const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace';
const { routes, tracks } = get(this.file).structure;
const segmentCount =
routes.length + tracks.reduce((count, track) => count + track.segments.length, 0);
if (get(treeFileView) && segmentCount > 1) {
// Select inner item
item =
file.children[trackIndex].children.length > 1
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
: new ListTrackItem(this.fileId, trackIndex);
if (routeId !== undefined) {
engine.selectRoutes(this.fileId, [routeId], mode);
return;
}
const track = tracks.find((track) => track.id === trackId);
if (track && track.segments.length > 1) {
engine.selectSegments(this.fileId, trackId, [segmentId], mode);
} else {
engine.selectTracks(this.fileId, [trackId], mode);
}
} else {
item = new ListFileItem(this.fileId);
}
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
selection.addSelectItem(item);
} else {
selection.selectItem(item);
engine.select([this.fileId], mode);
}
}
layerOnContextMenu(e: any) {
layerOnContextMenu(e: MapLayerMouseEvent) {
if (e.originalEvent.ctrlKey) {
this.layerOnClick(e);
}
@@ -533,18 +479,19 @@ export class GPXLayer {
if (this.draggedWaypointIndex !== null) {
return;
}
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypointIndex = e.features![0].properties!.waypointIndex;
let waypoint = file.wpt[waypointIndex];
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
this.showWaypointPopup(e.features![0].properties!.waypointId);
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
}
async showWaypointPopup(waypointId: string) {
const waypoint = await engine.waypoint(this.fileId, waypointId);
if (waypoint) {
waypointPopup?.setItem({ item: waypoint, kind: 'waypoint', fileId: this.fileId });
}
}
waypointLayerOnMouseLeave() {
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
}
@@ -552,41 +499,36 @@ export class GPXLayer {
waypointLayerOnClick(e: MapLayerMouseEvent) {
e.preventDefault();
let waypointIndex = e.features![0].properties!.waypointIndex;
let file = get(this.file)?.file;
if (!file) {
return;
}
let waypoint = file.wpt[waypointIndex];
const { waypointId } = e.features![0].properties!;
if (get(currentTool) === Tool.WAYPOINT) {
if (this.selected) {
if (e.originalEvent.shiftKey) {
fileActions.deleteWaypoint(this.fileId, waypointIndex);
engine.deleteWaypoint(this.fileId, waypointId);
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
selectedWaypoint.set([waypoint, this.fileId]);
engine.selectWaypoints(this.fileId, [waypointId]);
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
}
} else {
if (get(treeFileView)) {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
engine.selectWaypoints(this.fileId, [waypointId]);
} else {
selection.selectItem(new ListFileItem(this.fileId));
engine.select([this.fileId]);
}
selectedWaypoint.set([waypoint, this.fileId]);
selectedWaypoint.set({ fileId: this.fileId, id: waypointId });
}
} else {
if (get(treeFileView)) {
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
} else {
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
}
const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey;
engine.selectWaypoints(
this.fileId,
[waypointId],
add && this.selected ? 'add' : 'replace'
);
} else {
if (!this.selected) {
selection.selectItem(new ListFileItem(this.fileId));
engine.select([this.fileId]);
}
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
this.showWaypointPopup(waypointId);
}
}
}
@@ -642,7 +584,7 @@ export class GPXLayer {
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
).coordinates = [e.lngLat.lng, e.lngLat.lat];
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
const waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
| GeoJSONSource
| undefined;
if (waypointSource) {
@@ -681,115 +623,84 @@ export class GPXLayer {
return;
}
// the dragged waypoint, as it is in the features of the source
const waypointId =
this.currentWaypointData?.features[this.draggedWaypointIndex].properties?.waypointId;
getElevation([
{
lat: e.lngLat.lat,
lon: e.lngLat.lng,
lng: e.lngLat.lng,
},
]).then((ele) => {
]).then(async (ele) => {
if (this.draggedWaypointIndex === null) {
return;
}
fileActionManager.applyToFile(this.fileId, (file) => {
let wpt = file.wpt[this.draggedWaypointIndex!];
wpt.setCoordinates({
lat: e.lngLat.lat,
lon: e.lngLat.lng,
});
wpt.ele = ele[0];
});
if (waypointId) {
await engine.moveWaypoint(
this.fileId,
waypointId,
e.lngLat.lng,
e.lngLat.lat,
ele[0]
);
}
this.draggedWaypointIndex = null;
});
}
getGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
if (!file) {
return {
type: 'FeatureCollection',
features: [],
};
}
const state = get(this.file);
const waypointsSelected = hasSelectionWithin(this.selectionState, {
type: 'waypoints',
fileId: this.fileId,
});
let data = file.toGeoJSON();
let trackIndex = 0,
segmentIndex = 0;
for (let feature of data.features) {
if (!feature.properties) {
feature.properties = {};
}
if (!feature.properties.color) {
feature.properties.color = this.layerColor;
}
if (!feature.properties.opacity) {
feature.properties.opacity = get(defaultOpacity);
}
if (!feature.properties.width) {
feature.properties.width = get(defaultWidth);
}
if (
get(selection).hasAnyParent(
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
) ||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
) {
feature.properties.width = feature.properties.width + 2;
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
}
feature.properties.trackIndex = trackIndex;
feature.properties.segmentIndex = segmentIndex;
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
segmentIndex++;
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
segmentIndex = 0;
trackIndex++;
}
}
return data;
return {
type: 'FeatureCollection',
features: state.segments.features.map((feature) => {
const properties = {
...feature.properties,
opacity: feature.properties.opacity ?? get(defaultOpacity),
width: feature.properties.width ?? get(defaultWidth),
};
if (
isCovered(this.selectionState, this.segmentNode(properties)) ||
waypointsSelected
) {
properties.width = properties.width + 2;
properties.opacity = Math.min(1, properties.opacity + 0.1);
}
return { ...feature, properties };
}),
};
}
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
let file = get(this.file)?.file;
let data: GeoJSON.FeatureCollection = {
return {
type: 'FeatureCollection',
features: [],
};
if (!file) {
return data;
}
file.wpt.forEach((waypoint, index) => {
data.features.push({
features: get(this.file).waypoints.features.map((feature) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
},
// copied: the coordinates are changed while a waypoint is dragged
geometry: { type: 'Point', coordinates: [...feature.geometry.coordinates] },
properties: {
fileId: this.fileId,
waypointIndex: index,
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
waypointId: feature.properties.waypointId,
waypointIndex: feature.properties.index,
icon: `waypoint-${getSymbolKey(feature.properties.sym) ?? 'default'}-${this.layerColor}`,
},
});
});
return data;
})),
};
}
loadIcons() {
const _map = get(map);
let file = get(this.file)?.file;
if (!_map || !file) {
if (!_map) {
return;
}
let symbols = new Set<string | undefined>();
file.wpt.forEach((waypoint) => {
symbols.add(getSymbolKey(waypoint.sym));
const symbols = new Set<string | undefined>();
get(this.file).waypoints.features.forEach((feature) => {
symbols.add(getSymbolKey(feature.properties.sym));
});
symbols.forEach((symbol) => {
@@ -1,10 +1,10 @@
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
import { FileStateCollectionObserver } from '$lib/engine';
import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer';
export class GPXLayerCollection {
private _layers: Map<string, GPXLayer>;
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
private _fileStateCollectionObserver: FileStateCollectionObserver | null = null;
constructor() {
this._layers = new Map<string, GPXLayer>();
@@ -14,10 +14,10 @@ export class GPXLayerCollection {
if (this._fileStateCollectionObserver) {
return;
}
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => {
newFiles.forEach((fileState, fileId) => {
const layer = new GPXLayer(fileId, fileState);
newFiles.forEach((fileStore, fileId) => {
const layer = new GPXLayer(fileId, fileStore);
this._layers.set(fileId, layer);
});
},
@@ -1,11 +1,12 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { gpxStatistics, hoveredPoint, slicedGPXStatistics } from '$lib/logic/statistics';
import type { GeoJSONSource } from 'maplibre-gl';
import { get } from 'svelte/store';
import { map } from '$lib/components/map/map';
import { allHidden } from '$lib/logic/hidden';
import { allHidden } from '$lib/engine/selection-stores';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { loadSVGIcon } from '$lib/utils';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
import { engine } from '$lib/engine';
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6" fill="#22c55e" stroke="white" stroke-width="1.5"/>
@@ -26,14 +27,16 @@ const hoverMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="h
<circle cx="8" cy="8" r="6" fill="#00b8db" stroke="white" stroke-width="1.5"/>
</svg>`;
const { statistics } = engine;
export class StartEndMarkers {
updateBinded: () => void = this.update.bind(this);
unsubscribes: (() => void)[] = [];
constructor() {
map.onLoad((map_) => map_.on('style.load', this.updateBinded));
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(statistics.subscribe(this.updateBinded));
this.unsubscribes.push(slicedStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(hoveredPoint.subscribe(this.updateBinded));
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
@@ -46,8 +49,8 @@ export class StartEndMarkers {
this.loadIcons();
const tool = get(currentTool);
const statistics = get(gpxStatistics);
const slicedStatistics = get(slicedGPXStatistics);
const stats = get(statistics);
const sliced = get(slicedStatistics);
const hovered = get(hoveredPoint);
const hidden = get(allHidden);
if (!hidden) {
@@ -56,18 +59,17 @@ export class StartEndMarkers {
features: [],
};
if (statistics.global.length > 0 && tool !== Tool.ROUTING) {
const start = statistics
.getTrackPoint(slicedStatistics?.[1] ?? 0)!
.trkpt.getCoordinates();
const end = statistics
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
.trkpt.getCoordinates();
if (stats.length > 0 && tool !== Tool.ROUTING) {
const start = [stats.lng[sliced?.start ?? 0], stats.lat[sliced?.start ?? 0]];
const end = [
stats.lng[sliced?.end ?? stats.length - 1],
stats.lat[sliced?.end ?? stats.length - 1],
];
data.features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [start.lon, start.lat],
coordinates: start,
},
properties: {
icon: 'start-marker',
@@ -77,7 +79,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [end.lon, end.lat],
coordinates: end,
},
properties: {
icon: 'end-marker',
@@ -90,7 +92,7 @@ export class StartEndMarkers {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [hovered.lon, hovered.lat],
coordinates: [hovered.lng, hovered.lat],
},
properties: {
icon: 'hover-marker',
@@ -98,7 +100,7 @@ export class StartEndMarkers {
});
}
let source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
const source = map_.getSource('start-end-markers') as GeoJSONSource | undefined;
if (source) {
source.setData(data);
} else {
@@ -17,11 +17,11 @@
Map,
Layers2,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { i18n } from '$lib/i18n/i18n.svelte';
import { defaultBasemap, layerGroup, type CustomLayer } from '$lib/data/layers';
import { onMount } from 'svelte';
import { remove } from './utils';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { dndzone } from 'svelte-dnd-action';
const {
@@ -128,15 +128,16 @@
],
};
}
addLayer(layerId);
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
setDataFromSelectedLayer();
}
function getLayerId() {
for (let id = 0; ; id++) {
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
if (!Object.hasOwn($customLayers, `custom-${id}`)) {
return `custom-${id}`;
}
}
@@ -145,7 +146,7 @@
function addLayer(layerId: string) {
if (layerType === 'basemap') {
selectedBasemapTree.update(($tree) => {
if (!$tree.basemaps.hasOwnProperty('custom')) {
if (!Object.hasOwn($tree.basemaps, 'custom')) {
$tree.basemaps['custom'] = {};
}
$tree.basemaps['custom'][layerId] = true;
@@ -161,7 +162,7 @@
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
if (!Object.hasOwn($tree.overlays, 'custom')) {
$tree.overlays['custom'] = {};
}
$tree.overlays['custom'][layerId] = true;
@@ -169,7 +170,7 @@
});
currentOverlays.update(($overlays) => {
if (!$overlays.overlays.hasOwnProperty('custom')) {
if (!Object.hasOwn($overlays.overlays, 'custom')) {
$overlays.overlays['custom'] = {};
}
$overlays.overlays['custom'][layerId] = true;
@@ -182,8 +183,8 @@
}
}
function tryDeleteLayer(node: any, id: string): any {
if (node.hasOwnProperty(id)) {
function tryDeleteLayer<T>(node: Record<string, T>, id: string): Record<string, T> {
if (Object.hasOwn(node, id)) {
delete node[id];
}
return node;
@@ -255,13 +256,12 @@
onfinalize={(e) => {
customBasemapItems = e.detail.items;
$customBasemapOrder = customBasemapItems.map((item) => item.id);
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
selectedBasemapTree.update(($tree) => {
layerGroup($tree.basemaps)['custom'] = Object.fromEntries(
customBasemapItems.map((item) => [item.id, true])
);
return $tree;
});
}}
>
{#each customBasemapItems as item (item.id)}
@@ -315,13 +315,12 @@
onfinalize={(e) => {
customOverlayItems = e.detail.items;
$customOverlayOrder = customOverlayItems.map((item) => item.id);
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce(
(acc, item) => {
acc[item.id] = true;
return acc;
},
{}
);
selectedOverlayTree.update(($tree) => {
layerGroup($tree.overlays)['custom'] = Object.fromEntries(
customOverlayItems.map((item) => [item.id, true])
);
return $tree;
});
}}
>
{#each customOverlayItems as item (item.id)}
@@ -365,7 +364,7 @@
<Label for="name">{i18n._('menu.metadata.name')}</Label>
<Input bind:value={name} id="name" class="h-8" />
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label>
{#each tileUrls as url, i}
{#each tileUrls as _, i (i)}
<div class="flex flex-row gap-2">
<Input
bind:value={tileUrls[i]}
@@ -5,7 +5,7 @@
import { Separator } from '$lib/components/ui/separator';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import { Layers } from '@lucide/svelte';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { map } from '$lib/components/map/map';
import type { Map } from 'maplibre-gl';
@@ -14,12 +14,12 @@
overlayTree,
overpassTree,
terrainSources,
} from '$lib/assets/layers';
} from '$lib/data/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { map } from '$lib/components/map/map';
import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { untrack } from 'svelte';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
@@ -45,7 +45,7 @@
function setOpacityFromSelection() {
if (selectedOverlay) {
if ($opacities.hasOwnProperty(selectedOverlay)) {
if (Object.hasOwn($opacities, selectedOverlay)) {
overlayOpacity = $opacities[selectedOverlay];
} else {
overlayOpacity = 1;
@@ -169,7 +169,7 @@
{#if isSelected($selectedOverlayTree, selectedOverlay)}
{#if $isLayerFromExtension(selectedOverlay)}
{$getLayerName(selectedOverlay)}
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
{:else if Object.hasOwn($customLayers, selectedOverlay)}
{$customLayers[selectedOverlay].name}
{:else}
{i18n._(`layers.label.${selectedOverlay}`)}
@@ -178,7 +178,7 @@
{/if}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(overlays) as id}
{#each Object.keys(overlays) as id (id)}
{#if isSelected($selectedOverlayTree, id)}
<Select.Item value={id}>
{#if $isLayerFromExtension(id)}
@@ -189,7 +189,7 @@
</Select.Item>
{/if}
{/each}
{#each Object.entries($customLayers) as [id, layer]}
{#each Object.entries($customLayers) as [id, layer] (id)}
{#if layer.layerType === 'overlay'}
<Select.Item value={id}>{layer.name}</Select.Item>
{/if}
@@ -218,7 +218,7 @@
if ($map.getLayer(selectedOverlay)) {
$map.removeLayer(selectedOverlay);
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
@@ -247,7 +247,7 @@
{i18n._(`layers.label.${$terrainSource}`)}
</Select.Trigger>
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
{#each Object.keys(terrainSources) as id}
{#each Object.keys(terrainSources) as id (id)}
<Select.Item value={id}>
{i18n._(`layers.label.${id}`)}
</Select.Item>
@@ -1,6 +1,6 @@
<script lang="ts">
import LayerTreeNode from './LayerTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { type LayerTreeType } from '$lib/data/layers';
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
let {
@@ -3,10 +3,10 @@
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
import { type LayerTreeType } from '$lib/assets/layers';
import { type LayerTreeType } from '$lib/data/layers';
import { anySelectedLayer } from './utils';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { settings } from '$lib/state/settings';
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
let {
@@ -22,7 +22,7 @@
selected?: string;
onselect?: (value: string) => void;
multiple: boolean;
checked: LayerTreeType;
checked?: LayerTreeType;
} = $props();
const { customLayers } = settings;
@@ -31,7 +31,7 @@
$effect.pre(() => {
if (checked !== undefined) {
Object.keys(node).forEach((id) => {
if (!checked.hasOwnProperty(id)) {
if (!Object.hasOwn(checked, id)) {
if (typeof node[id] == 'boolean') {
checked[id] = false;
} else {
@@ -44,7 +44,7 @@
</script>
<div class="flex flex-col gap-[3px]">
{#each Object.keys(node) as id}
{#each Object.keys(node) as id (id)}
{#if typeof node[id] == 'boolean'}
{#if node[id]}
<div class="flex flex-row items-center gap-2 first:mt-0.5 h-4">
@@ -53,7 +53,9 @@
id="{name}-{id}"
{name}
value={id}
bind:checked={checked[id]}
bind:checked={
() => checked[id] === true, (value) => (checked[id] = value)
}
class="scale-90"
aria-label={i18n._(`layers.label.${id}`)}
/>
@@ -72,7 +74,7 @@
/>
{/if}
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
{#if $customLayers.hasOwnProperty(id)}
{#if Object.hasOwn($customLayers, id)}
{$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
@@ -90,12 +92,18 @@
{#snippet content()}
<div class="ml-2">
<Self
node={node[id]}
node={node[id] as LayerTreeType}
{name}
{selected}
{onselect}
{multiple}
bind:checked={checked[id]}
bind:checked={
() => {
const group = checked[id];
return typeof group === 'object' ? group : undefined;
},
(value) => (checked[id] = value ?? {})
}
/>
</div>
{/snippet}
@@ -2,12 +2,12 @@
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { PencilLine, MapPin } from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import type { WaypointType } from 'gpx';
import type { PopupItem } from '$lib/components/map/map-popup';
import { fileActions } from '$lib/logic/file-actions';
import { selection } from '$lib/logic/selection';
import { engine } from '$lib/engine';
import { selectionSize } from '$lib/engine/selection-helpers';
import { getElevation } from '$lib/utils';
let {
poi,
@@ -29,28 +29,22 @@
return '';
});
function addToFile() {
const { selection } = engine;
async function addToFile() {
const desc = Object.entries(tags)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
let wpt: WaypointType = {
attributes: {
lat: poi.item.lat,
lon: poi.item.lon,
},
name: name,
desc: desc,
cmt: desc,
sym: poi.item.sym,
};
if (tags.website) {
wpt.link = {
attributes: {
href: tags.website,
},
};
}
fileActions.addOrUpdateWaypoint(wpt);
const [ele] = await getElevation([{ lat: poi.item.lat, lng: poi.item.lng }]);
await engine.newWaypoint({
lng: poi.item.lng,
lat: poi.item.lat,
ele,
name,
desc,
icon: poi.item.sym ?? '',
link: tags.website ?? '',
});
}
</script>
@@ -60,7 +54,7 @@
<div class="flex flex-col">
<p>{name}</p>
<div class="text-muted-foreground text-xs font-normal">
{poi.item.lat.toFixed(6)}&deg; {poi.item.lon.toFixed(6)}&deg;
{poi.item.lat.toFixed(6)}&deg; {poi.item.lng.toFixed(6)}&deg;
</div>
</div>
@@ -85,11 +79,16 @@
</div>
{/if}
<div class="grid grid-cols-[auto_auto] gap-x-3">
{#each Object.entries(tags) as [key, value]}
{#each Object.entries(tags) as [key, value] (key)}
{#if key !== 'name' && !key.includes('image')}
<span class="font-mono">{key}</span>
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
<a href={value} target="_blank" class="text-link underline">{value}</a>
<a
href={value}
target="_blank"
rel="external"
class="text-link underline">{value}</a
>
{:else if key === 'wikipedia' || key.startsWith('wikipedia:') || key.endsWith(':wikipedia')}
<a
href="https://wikipedia.org/wiki/{value}"
@@ -113,7 +112,7 @@
size="sm"
class="mt-1 justify-start"
variant="outline"
disabled={$selection.size === 0}
disabled={selectionSize($selection) === 0}
onclick={addToFile}
>
<MapPin size="14" />
@@ -0,0 +1,153 @@
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api';
import { isSelected } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting };
/** The raster source that an overlay added by an extension uses. */
function rasterSource(id: string) {
const style = overlays[id];
assert.ok(typeof style === 'object');
const source = style.sources[id];
assert.ok(source?.type === 'raster');
return source;
}
const overlayTreeBefore = JSON.stringify(overlayTree);
const overlayIdsBefore = Object.keys(overlays).sort();
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
extensionName,
id,
name: `Overlay ${id}`,
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
});
let api: ExtensionAPI;
beforeEach(() => {
engine.setSetting = () => {};
engine.deleteSetting = () => {};
settings.connect({});
settings.initialize();
api = new ExtensionAPI();
});
afterEach(() => {
// the layers are shared by the whole app: leave them as they were
api.filterOverlays([]);
for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension];
}
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
});
describe('ExtensionAPI', () => {
it('adds an overlay as a layer, in the tree of its extension', () => {
api.addOrUpdateOverlay(overlay('one'));
const source = rasterSource('extension-one');
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
assert.equal(source.tileSize, 256);
assert.equal(source.maxzoom, 22);
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
assert.equal(isSelected(overlayTree, 'extension-one'), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
// not shown until the user chooses to
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
assert.ok(get(api.isLayerFromExtension)('extension-one'));
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
assert.equal(get(api.getLayerName)('other'), '');
});
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
api.addOrUpdateOverlay({
...overlay('big'),
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
maxZoom: 15,
});
const source = rasterSource('extension-big');
assert.equal(source.tileSize, 512);
assert.equal(source.maxzoom, 15);
});
it('refuses an overlay that is not complete', () => {
for (const bad of [
{ ...overlay('a'), extensionName: '' },
{ ...overlay('a'), id: '' },
{ ...overlay('a'), name: '' },
{ ...overlay('a'), tileUrls: [] },
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
]) {
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
}
assert.ok(!('extension-a' in overlays));
});
it('updates an overlay that is added again, keeping it shown', () => {
api.addOrUpdateOverlay(overlay('one'));
settings.currentOverlays.update((current) => {
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
return current;
});
api.addOrUpdateOverlay({
...overlay('one'),
name: 'Renamed',
tileUrls: ['https://other/{z}'],
});
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
assert.deepEqual(rasterSource('extension-one').tiles, ['https://other/{z}']);
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
});
it('removes the overlays that are not in the list anymore, everywhere', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
api.filterOverlays(['two']);
assert.ok(!('extension-one' in overlays));
assert.ok('extension-two' in overlays);
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
for (const tree of [
overlayTree,
get(settings.selectedOverlayTree),
get(settings.currentOverlays)!,
]) {
assert.equal(isSelected(tree, 'extension-one'), false);
}
assert.equal(isSelected(overlayTree, 'extension-two'), true);
api.filterOverlays([]);
assert.ok(!('extension-two' in overlays));
});
it('puts the overlays in the order it is given', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two'));
api.addOrUpdateOverlay(overlay('three'));
api.updateOverlaysOrder(['three', 'one', 'two']);
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
'My extension'
];
assert.deepEqual(Object.keys(extension as LayerTreeType), [
'extension-three',
'extension-one',
'extension-two',
]);
// ids it does not know are ignored
api.updateOverlaysOrder(['unknown', 'two']);
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
});
});
@@ -1,7 +1,7 @@
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { derived, get, writable, type Writable } from 'svelte/store';
import { isSelected, remove, removeAll } from './utils';
import { overlays, overlayTree } from '$lib/assets/layers';
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { browser } from '$app/environment';
import { map } from '$lib/components/map/map';
@@ -19,7 +19,7 @@ export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
init() {
if (browser && !window.hasOwnProperty('gpxstudio')) {
if (browser && !Object.hasOwn(window, 'gpxstudio')) {
Object.defineProperty(window, 'gpxstudio', {
value: this,
});
@@ -84,14 +84,15 @@ export class ExtensionAPI {
],
};
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
overlayTree.overlays[overlay.extensionName] = {};
const extensions = layerGroup(overlayTree.overlays);
if (!Object.hasOwn(extensions, overlay.extensionName)) {
extensions[overlay.extensionName] = {};
}
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
layerGroup(extensions[overlay.extensionName])[overlay.id] = true;
selectedOverlayTree.update((selected) => {
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
if (!Object.hasOwn(selected.overlays, overlay.extensionName)) {
selected.overlays[overlay.extensionName] = {};
}
selected.overlays[overlay.extensionName][overlay.id] = true;
@@ -104,13 +105,13 @@ export class ExtensionAPI {
show = true;
try {
get(map)?.removeLayer(overlay.id);
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
currentOverlays.update((current) => {
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
if (!Object.hasOwn(current.overlays, overlay.extensionName)) {
current.overlays[overlay.extensionName] = {};
}
current.overlays[overlay.extensionName][overlay.id] = show;
@@ -155,13 +156,13 @@ export class ExtensionAPI {
updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id));
selectedOverlayTree.update((selected) => {
let isSelected: Record<string, boolean> = {};
const isSelected: Record<string, boolean> = {};
ids.forEach((id) => {
const overlay = get(this._overlays).get(id);
if (
overlay &&
selected.overlays.hasOwnProperty(overlay.extensionName) &&
selected.overlays[overlay.extensionName].hasOwnProperty(id)
Object.hasOwn(selected.overlays, overlay.extensionName) &&
Object.hasOwn(selected.overlays[overlay.extensionName] as LayerTreeType, id)
) {
isSelected[id] = selected.overlays[overlay.extensionName][id];
delete selected.overlays[overlay.extensionName][id];
@@ -1,39 +1,38 @@
import { SphericalMercator } from '@mapbox/sphericalmercator';
import { getLayers } from './utils';
import { get, writable } from 'svelte/store';
import { liveQuery } from 'dexie';
import { overpassQueryData } from '$lib/assets/layers';
import { overpassQueryData } from '$lib/data/layers';
import { MapPopup } from '$lib/components/map/map-popup';
import { settings } from '$lib/logic/settings';
import { db } from '$lib/db';
import type { Map as MapLibreMap, GeoJSONSource } from 'maplibre-gl';
import { settings } from '$lib/state/settings';
import type { GeoJSONSource, MapLayerMouseEvent, Map as MapLibreMap } from 'maplibre-gl';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
import { loadSVGIcon } from '$lib/utils';
import { cachedFetch } from '$lib/storage/cached-fetch';
const { currentOverpassQueries } = settings;
/** The responses of Overpass are kept in the browser for a week. */
const CACHE = { name: 'overpass', maxAge: 7 * 24 * 3600 * 1000 };
const mercator = new SphericalMercator({
size: 256,
});
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
data.set({ type: 'FeatureCollection', features: pois.map((poi) => poi.poi) });
});
export class OverpassLayer {
overpassUrl = 'https://overpass.gpx.studio/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: MapLibreMap;
layerEventManager: MapLayerEventManager;
popup: MapPopup;
data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
// What was queried this session: the queries that were made for each tile. The responses are
// kept longer, see `cachedFetch`.
queriedTiles = new Map<string, Set<string>>();
currentQueries: Set<string> = new Set();
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
unsubscribes: (() => void)[] = [];
queryIfNeededBinded = this.queryIfNeeded.bind(this);
@@ -54,7 +53,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(this.data.subscribe(this.updateBinded));
this.unsubscribes.push(
currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -77,7 +76,7 @@ export class OverpassLayer {
update() {
this.loadIcons();
const fullData = get(data);
const fullData = get(this.data);
const queries = getCurrentQueries();
const d: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
@@ -87,7 +86,7 @@ export class OverpassLayer {
};
try {
let source = this.map.getSource('overpass') as GeoJSONSource | undefined;
const source = this.map.getSource('overpass') as GeoJSONSource | undefined;
if (source) {
source.setData(d);
} else {
@@ -116,7 +115,7 @@ export class OverpassLayer {
this.layerEventManager.on('mouseenter', 'overpass', this.onHoverBinded);
this.layerEventManager.on('click', 'overpass', this.onHoverBinded);
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to add sources and layers
}
}
@@ -136,28 +135,28 @@ export class OverpassLayer {
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
}
} catch (e) {
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
onHover(e: any) {
onHover(e: MapLayerMouseEvent) {
this.popup.setItem({
kind: 'overpass',
item: {
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
...e.features![0].properties,
sym: overpassQueryData[e.features![0].properties.query].symbol ?? '',
},
});
}
query(bbox: [number, number, number, number]) {
let queries = getCurrentQueries();
const queries = getCurrentQueries();
if (queries.length === 0) {
return;
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
const tileLimits = mercator.xyz(bbox, this.queryZoom);
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
@@ -165,66 +164,35 @@ export class OverpassLayer {
continue;
}
db.overpasstiles
.where('[x+y]')
.equals([x, y])
.toArray()
.then((querytiles) => {
let missingQueries = queries.filter(
(query) =>
!querytiles.some(
(querytile) =>
querytile.query === query &&
time - querytile.time < this.expirationTime
)
);
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
});
const queried = this.queriedTiles.get(`${x},${y}`);
const missingQueries = queries.filter((query) => !queried?.has(query));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
}
}
}
queryTile(x: number, y: number, queries: string[]) {
if (this.currentQueries.size > 5) {
return;
}
this.currentQueries.add(`${x},${y}`);
const bounds = mercator.bbox(x, y, this.queryZoom);
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
.then(
(response) => {
if (response.ok) {
return response.json();
}
this.currentQueries.delete(`${x},${y}`);
return Promise.reject();
},
() => this.currentQueries.delete(`${x},${y}`)
)
cachedFetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`, CACHE)
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => this.storeOverpassData(x, y, queries, data))
.catch(() => this.currentQueries.delete(`${x},${y}`));
}
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
return;
}
for (let element of data.elements) {
for (let query of queries) {
if (belongsToQuery(element, query)) {
pois.push({
query,
id: element.id,
poi: {
this.data.update((updatedData) => {
for (const element of data.elements) {
for (const query of queries) {
if (belongsToQuery(element, query)) {
updatedData.features.push({
type: 'Feature',
geometry: {
type: 'Point',
@@ -235,28 +203,28 @@ export class OverpassLayer {
properties: {
id: element.id,
lat: element.center ? element.center.lat : element.lat,
lon: element.center ? element.center.lon : element.lon,
lng: element.center ? element.center.lon : element.lon,
query: query,
icon: `overpass-${query}`,
tags: element.tags,
type: element.type,
},
},
});
});
}
}
}
}
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
return updatedData;
});
const queried = this.queriedTiles.get(`${x},${y}`) ?? new Set<string>();
queries.forEach((query) => queried.add(query));
this.queriedTiles.set(`${x},${y}`, queried);
this.currentQueries.delete(`${x},${y}`);
}
loadIcons() {
let currentQueries = getCurrentQueries();
const currentQueries = getCurrentQueries();
currentQueries.forEach((query) => {
loadSVGIcon(
this.map,
@@ -289,7 +257,7 @@ function getQuery(query: string) {
}
function getQueryItem(tags: Record<string, string | string[]>) {
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
const arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
Array.isArray(entry[1])
);
if (arrayEntry !== undefined) {
@@ -323,12 +291,12 @@ function belongsToQueryItem(element: any, tags: Record<string, string | string[]
}
function getCurrentQueries() {
let currentQueries = get(currentOverpassQueries);
const currentQueries = get(currentOverpassQueries);
if (currentQueries === undefined) {
return [];
}
return Object.entries(getLayers(currentQueries))
.filter(([_, selected]) => selected)
.map(([query, _]) => query);
.filter(([, selected]) => selected)
.map(([query]) => query);
}
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/data/layers';
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
// roads: bike (selected), foot
// satellite: esri, ign: fr (selected)
// osm
const tree = (): LayerTreeType => ({
roads: { bike: true, foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
describe('layer trees', () => {
it('tells if a layer is selected', () => {
assert.equal(isSelected(tree(), 'bike'), true);
assert.equal(isSelected(tree(), 'foot'), false);
assert.equal(isSelected(tree(), 'fr'), true);
assert.equal(isSelected(tree(), 'osm'), false);
assert.equal(isSelected(tree(), 'unknown'), false);
});
it('tells if anything is selected in a branch', () => {
const t = tree();
assert.equal(anySelectedLayer(t), true);
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
assert.equal(anySelectedLayer({}), false);
});
it('flattens the layers of the leaves', () => {
assert.deepEqual(getLayers(tree()), {
bike: true,
foot: false,
esri: false,
fr: true,
osm: false,
});
assert.deepEqual(getLayers({}), {});
});
it('toggles a layer wherever it is', () => {
const t = toggle(tree(), 'fr');
assert.equal(isSelected(t, 'fr'), false);
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
// the tree is edited, and returned
const own = tree();
assert.equal(toggle(own, 'foot'), own);
assert.equal((own.roads as LayerTreeType).foot, true);
// an id that is not there changes nothing
assert.deepEqual(toggle(tree(), 'unknown'), tree());
});
it('removes layers and branches', () => {
assert.deepEqual(remove(tree(), 'bike'), {
roads: { foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
assert.deepEqual(remove(tree(), 'ign'), {
roads: { bike: true, foot: false },
satellite: { esri: false },
osm: false,
});
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
roads: { foot: false },
satellite: { esri: false, ign: {} },
});
assert.deepEqual(removeAll(tree(), []), tree());
});
});
@@ -1,4 +1,4 @@
import type { LayerTreeType } from '$lib/assets/layers';
import type { LayerTreeType } from '$lib/data/layers';
export function anySelectedLayer(node: LayerTreeType) {
return (
@@ -1,13 +1,14 @@
import { fileStateCollection } from '$lib/logic/file-state';
import { engine } from '$lib/engine';
import { get } from 'svelte/store';
import {
LngLatBounds,
MapMouseEvent,
MapTouchEvent,
Point,
type Map,
type MapGeoJSONFeature,
type MapLayerMouseEvent,
type MapLayerTouchEvent,
MapMouseEvent,
MapTouchEvent,
type Point,
} from 'maplibre-gl';
type MapLayerMouseEventListener = (e: MapLayerMouseEvent) => void;
@@ -233,17 +234,23 @@ export class MapLayerEventManager {
}
private _filterLayersIntersectingBounds(layerIds: string[], bounds: LngLatBounds): string[] {
let result = layerIds.filter((layerId) => {
const files = get(engine.files);
const result = layerIds.filter((layerId) => {
if (!this._map.getLayer(layerId)) return false;
const fileId = layerId.replace('-waypoints', '');
if (fileId === layerId) {
return fileStateCollection.getStatistics(fileId)?.intersectsBBox(bounds) ?? true;
} else {
return (
fileStateCollection.getStatistics(fileId)?.intersectsWaypointBBox(bounds) ??
true
);
// the tracks of a file are all within its bounds
const file = files.get(fileId);
const box = file && get(file).statistics.bounds;
return box
? bounds.getWest() <= box.east &&
bounds.getEast() >= box.west &&
bounds.getSouth() <= box.north &&
bounds.getNorth() >= box.south
: true;
}
// the waypoints have no bounds of their own
return true;
});
return result;
}
+6 -6
View File
@@ -1,11 +1,13 @@
import { TrackPoint, Waypoint } from 'gpx';
import { LngLat, Map, MapMouseEvent, Popup, type PopupOptions } from 'maplibre-gl';
import { LngLat, type Map, type MapMouseEvent, Popup, type PopupOptions } from 'maplibre-gl';
import { mount, tick, unmount } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = Waypoint | TrackPoint | any> = {
export type PopupItem<T = any> = {
/** Has a position (`lng` and `lat`). */
item: T;
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
kind: 'waypoint' | 'trackpoint' | 'overpass';
fileId?: string;
hide?: () => void;
};
@@ -77,8 +79,6 @@ export class MapPopup {
if (item === null) {
return new LngLat(0, 0);
}
return item.item instanceof Waypoint || item.item instanceof TrackPoint
? item.item.getCoordinates()
: new LngLat(item.item.lon, item.item.lat);
return new LngLat(item.item.lng, item.item.lat);
}
}
+25 -7
View File
@@ -6,14 +6,14 @@ import MaplibreGeocoder, {
} from '@maplibre/maplibre-gl-geocoder';
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
import { get, writable, type Writable } from 'svelte/store';
import { settings } from '$lib/logic/settings';
import { settings } from '$lib/state/settings';
import { tick } from 'svelte';
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
let fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
const fitBoundsOptions: maplibregl.MapOptions['fitBoundsOptions'] = {
maxZoom: 15,
linear: true,
easing: () => 1,
@@ -43,6 +43,16 @@ export class MapLibreGLMap {
maplibregl.setWorkerUrl(workerUrl);
this._maptilerKey = maptilerKey;
this._styleManager = new StyleManager(this._mapStore, this._maptilerKey);
// MapLibre gives Safari more workers, but only recognizes it by its user agent: not
// WebKit in the desktop app, where the data of the sources was then processed one source
// after the other, and the map drew the files a frame before the anchors of the routing
// tool. Before the first map, which starts the workers.
const userAgent = navigator.userAgent;
if (userAgent.includes('AppleWebKit') && !userAgent.includes('Chrome')) {
maplibregl.setWorkerCount(
Math.max(Math.min(Math.floor(navigator.hardwareConcurrency / 2), 3), 1)
);
}
const map = new maplibregl.Map({
container: 'map',
style: {
@@ -65,7 +75,7 @@ export class MapLibreGLMap {
})
);
if (geocoder) {
let geocoder = new MaplibreGeocoder(
const geocoder = new MaplibreGeocoder(
{
forwardGeocode: async (config) => {
const results: MaplibreGeocoderFeatureResults = {
@@ -75,18 +85,26 @@ export class MapLibreGLMap {
try {
const request = `https://nominatim.openstreetmap.org/search?format=json&q=${config.query}&limit=5&accept-language=${language}`;
const response = await fetch(request);
const geojson = await response.json();
results.features = geojson.map((result: any) => {
const places: {
lat: string;
lon: string;
type: string;
display_name: string;
}[] = await response.json();
results.features = places.map((result) => {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [result.lon, result.lat],
coordinates: [Number(result.lon), Number(result.lat)],
},
properties: {},
text: result.display_name,
place_name: result.display_name,
place_type: [result.type],
};
});
} catch (e) {}
} catch {}
return results;
},
},
@@ -5,8 +5,8 @@
import { PersonStanding, X } from '@lucide/svelte';
import { MapillaryLayer } from './mapillary';
import { GoogleRedirect } from './google';
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { settings } from '$lib/state/settings';
import { i18n } from '$lib/i18n/i18n.svelte';
import { onMount } from 'svelte';
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
import type { Map } from 'maplibre-gl';
@@ -1,5 +1,5 @@
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import type { Map, MapMouseEvent } from 'maplibre-gl';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
export class GoogleRedirect {
map: Map;
@@ -5,9 +5,9 @@ import {
type MapLayerMouseEvent,
type VectorSourceSpecification,
} from 'maplibre-gl';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
import { Viewer, type ViewerBearingEvent } from 'mapillary-js';
import 'mapillary-js/dist/mapillary.css';
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
@@ -89,7 +89,7 @@ export class MapillaryLayer {
this.viewer.on('position', async () => {
if (this.active) {
popupOpen.value = true;
let latLng = await this.viewer.getPosition();
const latLng = await this.viewer.getPosition();
this.marker.setLngLat(latLng).addTo(this.map);
if (!this.map.getBounds()?.contains(latLng)) {
this.map.panTo(latLng);
+35 -20
View File
@@ -1,4 +1,11 @@
import { settings } from '$lib/logic/settings';
import type {
GeoJSONSourceSpecification,
LayerSpecification,
Map,
SkySpecification,
StyleSpecification,
} from 'maplibre-gl';
import { settings } from '$lib/state/settings';
import { get, type Writable } from 'svelte/store';
import {
basemaps,
@@ -6,15 +13,9 @@ import {
maptilerKeyPlaceHolder,
overlays,
terrainSources,
} from '$lib/assets/layers';
} from '$lib/data/layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n.svelte';
import type {
Map,
GeoJSONSourceSpecification,
LayerSpecification,
StyleSpecification,
} from 'maplibre-gl';
import { i18n } from '$lib/i18n/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
settings;
@@ -26,6 +27,16 @@ const emptySource: GeoJSONSourceSpecification = {
features: [],
},
};
/**
* The sky above the horizon when the map is tilted, without the atmosphere around the globe.
*/
const sky: SkySpecification = {
'sky-color': '#88C6FC',
'horizon-color': '#ffffff',
'sky-horizon-blend': 0.8,
'atmosphere-blend': 0,
};
export const ANCHOR_LAYER_KEY = {
overlays: 'overlays-end',
mapillary: 'mapillary-end',
@@ -76,7 +87,7 @@ export class StyleManager {
updateBasemap() {
const map_ = get(this._map);
if (!map_) return;
let basemap = get(currentBasemap);
const basemap = get(currentBasemap);
this.buildStyle(basemap).then((style) => {
if (get(currentBasemap) === basemap) map_.setStyle(style);
});
@@ -114,9 +125,10 @@ export class StyleManager {
}
}
} catch (e) {
console.error(e.message);
console.error(e instanceof Error ? e.message : e);
}
this.merge(style, basemapStyle);
style.sky ??= sky;
const terrain = this.getCurrentTerrain();
style.sources[terrain.source] = terrainSources[terrain.source];
@@ -136,18 +148,18 @@ export class StyleManager {
const overlayOpacities = get(opacities);
try {
const layers = getLayers(get(currentOverlays) ?? {});
for (let overlay in layers) {
for (const overlay in layers) {
if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try {
const overlayStyle = await this.get(overlayInfo);
for (let layer of overlayStyle.layers ?? []) {
for (const layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id);
}
}
} catch (e) {
} catch {
// Should not happen
}
this._pastOverlays.delete(overlay);
@@ -158,13 +170,13 @@ export class StyleManager {
const overlayStyle = await this.get(overlayInfo);
const opacity = overlayOpacities[overlay];
for (let sourceId in overlayStyle.sources) {
for (const sourceId in overlayStyle.sources) {
if (!map_.getSource(sourceId)) {
map_.addSource(sourceId, overlayStyle.sources[sourceId]);
}
}
for (let layer of overlayStyle.layers ?? []) {
for (const layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) {
if (opacity !== undefined) {
if (layer.type === 'raster') {
@@ -184,11 +196,11 @@ export class StyleManager {
}
this._pastOverlays.add(overlay);
} catch (e) {
console.error(e.message);
console.error(e instanceof Error ? e.message : e);
}
}
}
} catch (e) {}
} catch {}
}
updateTerrain() {
@@ -211,7 +223,7 @@ export class StyleManager {
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
if (typeof styleInfo === 'string') {
let styleUrl = styleInfo as string;
const styleUrl = styleInfo as string;
const response = await fetch(styleUrl, { cache: 'force-cache' });
if (!response.ok) {
throw new Error(`HTTP error fetching style "${styleInfo}": ${response.status}`);
@@ -226,7 +238,7 @@ export class StyleManager {
merge(style: StyleSpecification, other: StyleSpecification) {
style.sources = { ...style.sources, ...other.sources };
const units = get(distanceUnits);
for (let layer of other.layers ?? []) {
for (const layer of other.layers ?? []) {
if ('source' in layer) {
if (layer.source == 'contours_m' && units === 'imperial') continue;
if (layer.source == 'contours_ft' && units !== 'imperial') continue;
@@ -275,6 +287,9 @@ export class StyleManager {
if (other.glyphs && !style.glyphs) {
style.glyphs = other.glyphs;
}
if (other.sky && !style.sky) {
style.sky = other.sky;
}
}
getCurrentTerrain() {