mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-12-02 10:02:12 +00:00
Compare commits
6 Commits
88c9abb78e
...
0f7f64fb2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7f64fb2f | ||
|
|
b09a1fdcb7 | ||
|
|
e5d45dee3a | ||
|
|
8c3365ef24 | ||
|
|
db5cbffb70 | ||
|
|
683ac4e118 |
@@ -152,11 +152,12 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
linzTopo: {
|
||||
type: 'raster',
|
||||
tiles: [
|
||||
'https://tiles-cdn.koordinates.com/services;key=39a8b989633a4bef98bc0e065380454a/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png',
|
||||
'https://basemaps.linz.govt.nz/v1/tiles/topo-raster/WebMercatorQuad/{z}/{x}/{y}.webp?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.linz.govt.nz/" target="_blank">LINZ</a>',
|
||||
maxzoom: 16,
|
||||
attribution:
|
||||
'© <a href="//www.linz.govt.nz/linz-copyright">LINZ CC BY 4.0</a> © <a href="//www.linz.govt.nz/data/linz-data/linz-basemaps/data-attribution">Imagery Basemap contributors</a>',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { CircleQuestionMark } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export let link: string | undefined = undefined;
|
||||
let {
|
||||
link,
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
link: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
>
|
||||
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
||||
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{@render children()}
|
||||
{#if link}
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{i18n._('menu.more')}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { onMount } from 'svelte';
|
||||
import { customBasemapUpdate } from './utils';
|
||||
import { customBasemapUpdate, isSelected, remove } from './utils';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
@@ -176,11 +176,7 @@
|
||||
return $tree;
|
||||
});
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
@@ -188,10 +184,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
currentOverlays.update(($overlays) => {
|
||||
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||
$overlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
$overlays.overlays['custom'][layerId] = true;
|
||||
return $overlays;
|
||||
});
|
||||
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
@@ -216,49 +215,15 @@
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$selectedBasemapTree = remove($selectedBasemapTree, layerId);
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
$currentOverlays.overlays['custom'][layerId] = false;
|
||||
if ($previousOverlays.overlays['custom']) {
|
||||
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
||||
$previousOverlays.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
}
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays,
|
||||
'custom'
|
||||
);
|
||||
if ($currentOverlays) {
|
||||
$currentOverlays = remove($currentOverlays, layerId);
|
||||
}
|
||||
$previousOverlays = remove($previousOverlays, layerId);
|
||||
$selectedOverlayTree = remove($selectedOverlayTree, layerId);
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { untrack } from 'svelte';
|
||||
import { extensionAPI } from './extension-api';
|
||||
|
||||
const {
|
||||
selectedBasemapTree,
|
||||
@@ -160,7 +161,11 @@
|
||||
<Select.Trigger class="h-8 mr-1 w-full">
|
||||
{#if selectedOverlay}
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{#if extensionAPI.isLayerFromExtension(selectedOverlay)}
|
||||
{extensionAPI.getLayerName(selectedOverlay)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||
{/if}
|
||||
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||
{$customLayers[selectedOverlay].name}
|
||||
{/if}
|
||||
@@ -169,9 +174,13 @@
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}
|
||||
>{i18n._(`layers.label.${id}`)}</Select.Item
|
||||
>
|
||||
<Select.Item value={id}>
|
||||
{#if extensionAPI.isLayerFromExtension(id)}
|
||||
{extensionAPI.getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
</Select.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { anySelectedLayer } from './utils';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||
|
||||
let {
|
||||
name,
|
||||
@@ -72,6 +73,8 @@
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
{$customLayers[id].name}
|
||||
{:else if extensionAPI.isLayerFromExtension(id)}
|
||||
{extensionAPI.getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
|
||||
@@ -9,18 +9,23 @@
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
|
||||
export let poi: PopupItem<any>;
|
||||
let {
|
||||
poi,
|
||||
}: {
|
||||
poi: PopupItem<any>;
|
||||
} = $props();
|
||||
|
||||
let tags: { [key: string]: string } = {};
|
||||
let name = '';
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
|
||||
let name = $derived.by(() => {
|
||||
if (poi) {
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
return tags.name;
|
||||
} else {
|
||||
name = i18n._(`layers.label.${poi.item.query}`);
|
||||
return i18n._(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
function addToFile() {
|
||||
const desc = Object.entries(tags)
|
||||
@@ -74,7 +79,7 @@
|
||||
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
167
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
167
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { map, type MapboxGLMap } from '$lib/components/map/map';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { get } from 'svelte/store';
|
||||
import { isSelected, remove, removeByPrefix, toggle } from './utils';
|
||||
import { overlays, overlayTree } from '$lib/assets/layers';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||
|
||||
export type CustomOverlay = {
|
||||
id: string;
|
||||
name: string;
|
||||
tileUrls: string[];
|
||||
maxZoom?: number;
|
||||
};
|
||||
|
||||
export class ExtensionAPI {
|
||||
private _map: MapboxGLMap;
|
||||
private _overlays: Map<string, CustomOverlay> = new Map();
|
||||
|
||||
constructor(map: MapboxGLMap) {
|
||||
this._map = map;
|
||||
if (browser && !window.hasOwnProperty('gpxstudio')) {
|
||||
Object.defineProperty(window, 'gpxstudio', {
|
||||
value: this,
|
||||
});
|
||||
addEventListener('beforeunload', () => {
|
||||
this.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this._map.onLoad(() => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addOrUpdateOverlay(overlay: CustomOverlay) {
|
||||
if (!overlay.id || !overlay.tileUrls || overlay.tileUrls.length === 0) {
|
||||
throw new Error('Overlay must have an id and at least one tile URL.');
|
||||
}
|
||||
overlay.id = this.getOverlayId(overlay.id);
|
||||
|
||||
this._overlays.set(overlay.id, overlay);
|
||||
|
||||
overlays[overlay.id] = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[overlay.id]: {
|
||||
type: 'raster',
|
||||
tiles: overlay.tileUrls,
|
||||
tileSize: overlay.tileUrls.some((url) => url.includes('512')) ? 512 : 256,
|
||||
maxzoom: overlay.maxZoom ?? 22,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: overlay.id,
|
||||
type: 'raster',
|
||||
source: overlay.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
overlayTree.overlays.world[overlay.id] = true;
|
||||
|
||||
selectedOverlayTree.update((selected) => {
|
||||
selected.overlays.world[overlay.id] = true;
|
||||
return selected;
|
||||
});
|
||||
|
||||
const current = get(currentOverlays);
|
||||
if (current && isSelected(current, overlay.id)) {
|
||||
try {
|
||||
get(this._map)?.removeImport(overlay.id);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
current.overlays.world[overlay.id] = true;
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
removeOverlaysWithPrefix(prefix: string) {
|
||||
prefix = this.getOverlayId(prefix);
|
||||
|
||||
currentOverlays.update((overlays) => {
|
||||
removeByPrefix(overlays, prefix);
|
||||
return overlays;
|
||||
});
|
||||
previousOverlays.update((overlays) => {
|
||||
removeByPrefix(overlays, prefix);
|
||||
return overlays;
|
||||
});
|
||||
selectedOverlayTree.update((overlays) => {
|
||||
removeByPrefix(overlays, prefix);
|
||||
return overlays;
|
||||
});
|
||||
Object.keys(overlays).forEach((id) => {
|
||||
if (id.startsWith(prefix)) {
|
||||
delete overlays[id];
|
||||
}
|
||||
});
|
||||
Object.keys(overlayTree.overlays.world).forEach((id) => {
|
||||
if (id.startsWith(prefix)) {
|
||||
delete overlayTree.overlays.world[id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleOverlay(id: string) {
|
||||
id = this.getOverlayId(id);
|
||||
|
||||
currentOverlays.update((overlays) => {
|
||||
toggle(overlays, id);
|
||||
return overlays;
|
||||
});
|
||||
if (!isSelected(get(selectedOverlayTree), id)) {
|
||||
selectedOverlayTree.update((overlays) => {
|
||||
toggle(overlays, id);
|
||||
return overlays;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
isLayerFromExtension(id: string): boolean {
|
||||
return this._overlays.has(id);
|
||||
}
|
||||
|
||||
getLayerName(id: string): string {
|
||||
const overlay = this._overlays.get(id);
|
||||
return overlay ? overlay.name : '';
|
||||
}
|
||||
|
||||
private getOverlayId(id: string): string {
|
||||
return `extension-${id}`;
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
currentOverlays.update((overlays) => {
|
||||
this._overlays.forEach((_, id) => {
|
||||
remove(overlays, id);
|
||||
});
|
||||
return overlays;
|
||||
});
|
||||
previousOverlays.update((overlays) => {
|
||||
this._overlays.forEach((_, id) => {
|
||||
remove(overlays, id);
|
||||
});
|
||||
return overlays;
|
||||
});
|
||||
selectedOverlayTree.update((overlays) => {
|
||||
this._overlays.forEach((_, id) => {
|
||||
remove(overlays, id);
|
||||
});
|
||||
return overlays;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionAPI = new ExtensionAPI(map);
|
||||
@@ -55,4 +55,26 @@ export function toggle(node: LayerTreeType, id: string) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function remove(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
delete node[key];
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
remove(node[key], id);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export function removeByPrefix(node: LayerTreeType, prefix: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
delete node[key];
|
||||
} else if (typeof node[key] !== 'boolean') {
|
||||
remove(node[key], prefix);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
||||
|
||||
@@ -23,7 +23,7 @@ export class AllHidden {
|
||||
|
||||
update() {
|
||||
let hidden = true;
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
selection.applyToSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
for (let item of items) {
|
||||
|
||||
@@ -187,6 +187,33 @@ export class Selection {
|
||||
return selected;
|
||||
}
|
||||
|
||||
applyToSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void
|
||||
) {
|
||||
let selectedItems = get(this._selection).getSelected();
|
||||
get(fileStateCollection).forEach((_, fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (items.length > 0) {
|
||||
callback(fileId, level, items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
|
||||
Reference in New Issue
Block a user