mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
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:
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,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)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||
</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)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
{waypoint.item.lat.toFixed(6)}° {waypoint.item.lng.toFixed(6)}°
|
||||
<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)}° {poi.item.lon.toFixed(6)}°
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lng.toFixed(6)}°
|
||||
</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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user