adds multi trail support for maplibre

This commit is contained in:
Christian Beutel
2024-11-29 18:36:33 +01:00
parent c91f2afcc5
commit 390a7b52ee
16 changed files with 621 additions and 221 deletions

View File

@@ -36,6 +36,7 @@
} }
trail = (await gpx2trail(log.expand.gpx_data)).trail; trail = (await gpx2trail(log.expand.gpx_data)).trail;
trail.id = log.id;
trail.expand.gpx_data = log.expand.gpx_data; trail.expand.gpx_data = log.expand.gpx_data;
openMapModal(); openMapModal();
@@ -102,7 +103,7 @@
> >
<div slot="content" id="summit-log-table-map" class="h-[32rem]"> <div slot="content" id="summit-log-table-map" class="h-[32rem]">
{#if trail} {#if trail}
<MapWithElevationMaplibre {trail} bind:map <MapWithElevationMaplibre trails={[trail]} bind:map
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>
{/if} {/if}
</div> </div>

View File

@@ -6,6 +6,7 @@
import { toGeoJson } from "$lib/util/gpx_util"; import { toGeoJson } from "$lib/util/gpx_util";
import { import {
createMarkerFromWaypoint, createMarkerFromWaypoint,
createPopupFromTrail,
FontawesomeMarker, FontawesomeMarker,
} from "$lib/util/maplibre_util"; } from "$lib/util/maplibre_util";
import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control"; import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control";
@@ -15,21 +16,36 @@
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { createEventDispatcher, onDestroy, onMount } from "svelte"; import { createEventDispatcher, onDestroy, onMount } from "svelte";
export let trail: Trail | null; export let trails: Trail[] = [];
export let markers: M.Marker[] = []; export let markers: M.Marker[] = [];
export let map: M.Map | null = null; export let map: M.Map | null = null;
export let drawing: boolean = false; export let drawing: boolean = false;
export let showElevation: boolean = true;
export let showInfoPopup: boolean = false;
export let activeTrail: number = 0;
export let minZoom: number = 0;
let mapContainer: HTMLDivElement; let mapContainer: HTMLDivElement;
let epc: ElevationProfileControl; let epc: ElevationProfileControl | null = null;
let startMarker: M.Marker;
let endMarker: M.Marker; let layers: Record<
string,
{
startMarker: M.Marker | null;
endMarker: M.Marker | null;
source: M.GeoJSONSource | null;
layer: M.LineLayerSpecification | null;
listener: {
onEnter: ((e: M.MapMouseEvent) => void) | null;
onLeave: ((e: M.MapMouseEvent) => void) | null;
onClick: ((e: M.MapMouseEvent) => void) | null;
};
}
> = {};
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
$: data = trail?.expand.gpx_data $: data = getData(trails);
? (toGeoJson(trail.expand.gpx_data!) as GeoJSON)
: null;
$: if (data && map) { $: if (data && map) {
initMap(); initMap();
@@ -55,84 +71,303 @@
map.getCanvas().style.cursor = "crosshair"; map.getCanvas().style.cursor = "crosshair";
} else if (!drawing && map) { } else if (!drawing && map) {
map.getCanvas().style.cursor = "inherit"; map.getCanvas().style.cursor = "inherit";
addStartEndMarkers(); addStartEndMarkers(
trails[activeTrail],
trails[activeTrail]?.id ?? activeTrail.toString(),
data?.at(activeTrail),
);
}
function getData(trails: Trail[]) {
if (!trails.length) {
return [];
}
const r: GeoJSON[] = [];
trails.forEach((t) => {
if (t.expand.gpx_data) {
r.push(toGeoJson(t.expand.gpx_data) as GeoJSON);
} else if (t.lat && t.lon) {
r.push({
id: "",
type: "Feature",
properties: {},
geometry: {
type: "Point",
coordinates: [t.lon ?? 0, t.lat ?? 0],
},
} as GeoJSON);
}
});
return r;
} }
function initMap() { function initMap() {
if (!map || !data) { if (!map) {
return; return;
} }
epc.setData(data, trail!.expand.waypoints); if (data[activeTrail] && showElevation) {
epc.showProfile(); epc?.setData(
data[activeTrail]!,
const trailSource = map.getSource("trail-source"); trails.at(activeTrail)!.expand.waypoints,
if (!trailSource) { );
addTrailLayer(); epc?.showProfile();
} else {
(trailSource as M.GeoJSONSource).setData(data);
} }
if (!drawing) { trails.forEach((t, i) => {
addStartEndMarkers(); const layerId = t.id ?? i.toString();
addTrailLayer(t, layerId, data[i]);
});
Object.keys(layers).forEach((layerId) => {
const isStillVisible = trails.some((t) => t.id === layerId);
if (!isStillVisible) {
removeTrailLayer(layerId);
}
});
if (!drawing && data.some((d) => d.bbox !== undefined)) {
flyToBounds();
} }
} }
function addTrailLayer() { function getBounds() {
if (!data || !map) { let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const [xMin, yMin, xMax, yMax] of data
.filter((d) => d.bbox !== undefined)
.map((d) => d.bbox!)) {
minX = Math.min(minX, xMin);
minY = Math.min(minY, yMin);
maxX = Math.max(maxX, xMax);
maxY = Math.max(maxY, yMax);
}
return new M.LngLatBounds([minX, minY, maxX, maxY]);
}
function flyToBounds() {
const bounds = data[activeTrail]
? (data[activeTrail].bbox as M.LngLatBoundsLike)
: getBounds();
map!.fitBounds(bounds, {
animate: true,
padding: {
top: 16,
left: 16,
right: 16,
bottom:
16 +
(epc?.isProfileShown
? map!.getContainer().clientHeight * 0.3
: 0),
},
});
}
function removeTrailLayer(id: string) {
if (!layers[id]) {
return; return;
} }
const trailSource = map.getSource("trail-source"); const layer = layers[id];
if (!trailSource) {
if (layer.layer) {
map?.removeLayer(id);
}
if (layer.source) {
map?.removeSource(id);
}
layer.startMarker?.remove();
layer.endMarker?.remove();
map?.off("mouseenter", id, layers[id].listener.onEnter!);
map?.off("mouseleave", id, layers[id].listener.onLeave!);
map?.off("click", id, layers[id].listener.onClick!);
delete layers[id];
}
function createEmptyLayer(id: string) {
if (!layers[id]) {
layers[id] = {
startMarker: null,
endMarker: null,
source: null,
layer: null,
listener: {
onClick: null,
onEnter: null,
onLeave: null,
},
};
}
}
function addTrailLayer(
trail: Trail,
id: string,
geojson: GeoJSON | null | undefined,
) {
if (!geojson || !map) {
return;
}
createEmptyLayer(id);
if (!layers[id].source) {
try { try {
map.addSource("trail-source", { map.addSource(id, {
type: "geojson", type: "geojson",
data: data, data: geojson,
}); });
layers[id].source = map.getSource(id) as M.GeoJSONSource;
// map.addSource("trail-source", {
// type: "vector",
// url: `http://localhost:8080/data/out.json`,
// });
} catch (e) { } catch (e) {
return; return;
} }
} else {
layers[id].source.setData(geojson);
} }
const trailLayer = map.getLayer("trail-layer"); if (!layers[id].layer) {
if (!trailLayer) {
map.addLayer({ map.addLayer({
id: "trail-layer", id: id,
type: "line", type: "line",
source: "trail-source", source: id,
minzoom: minZoom,
paint: { paint: {
"line-color": "#648ad5", "line-color": "#648ad5",
"line-width": 5, "line-width": 5,
}, },
}); });
layers[id].layer = map.getLayer(id) as M.LineLayerSpecification;
layers[id].listener.onEnter = (e) => highlightTrail(id);
layers[id].listener.onLeave = (e) => unHighlightTrail(id);
layers[id].listener.onClick = (e) => focusTrail(trail, e);
map.on("mouseenter", id, layers[id].listener.onEnter);
map.on("mouseleave", id, layers[id].listener.onLeave);
map.on("click", id, layers[id].listener.onClick);
// map.addLayer({
// id: "trail-layer",
// type: "line",
// source: "trail-source",
// "source-layer": "Herzogstand", // Replace with the actual layer name in the .mbtiles file
// layout: {
// "line-join": "round",
// "line-cap": "round",
// },
// paint: {
// "line-color": "#648ad5",
// "line-width": 5,
// },
// });
}
if (!drawing) {
addStartEndMarkers(trail, id, geojson);
} }
} }
function addStartEndMarkers() { export function highlightTrail(id: string) {
if (!map || !data) { map?.setPaintProperty(id, "line-width", 7);
map?.setPaintProperty(id, "line-color", "#2766e3");
}
export function unHighlightTrail(id: string) {
map?.setPaintProperty(id, "line-width", 5);
map?.setPaintProperty(id, "line-color", "#648ad5");
}
export function focusTrail(trail: Trail, e?: M.MapMouseEvent) {
const currentlyFocussedTrail = trails[activeTrail];
if (currentlyFocussedTrail) {
unFocusTrail(currentlyFocussedTrail);
}
e?.preventDefault();
dispatch("select", trail);
const index = trails.indexOf(trail);
if (index == -1) {
return; return;
} }
const startEndPoint = findStartAndEndPoints(data); activeTrail = index;
highlightTrail(trail.id!);
flyToBounds();
if (data[activeTrail] && showElevation) {
epc?.setData(
data[activeTrail]!,
trails.at(activeTrail)!.expand.waypoints,
);
epc?.showProfile();
}
}
startMarker ??= new FontawesomeMarker({ icon: "fa fa-bullseye" }, {}); export function unFocusTrail(trail: Trail) {
dispatch("unselect", trail);
activeTrail = -1;
unHighlightTrail(trail.id!);
flyToBounds();
startMarker.setLngLat(startEndPoint[0] as M.LngLatLike).addTo(map); if (showElevation) {
epc?.hideProfile();
}
}
endMarker ??= new FontawesomeMarker( function addStartEndMarkers(
trail: Trail,
id: string,
geojson: GeoJSON | null | undefined,
) {
if (!map || !trail) {
return;
}
createEmptyLayer(id);
layers[id].startMarker ??= new FontawesomeMarker(
{ icon: "fa fa-bullseye" },
{},
);
if (!geojson) {
if (trail.lon && trail.lat) {
layers[id].startMarker
.setLngLat([trail.lon, trail.lat])
.addTo(map);
}
return;
}
const startEndPoint = findStartAndEndPoints(geojson);
layers[id].startMarker
.setLngLat(startEndPoint[0] as M.LngLatLike)
.addTo(map);
if (showInfoPopup) {
const popup = createPopupFromTrail(trail);
layers[id].startMarker.setPopup(popup);
}
layers[id].endMarker ??= new FontawesomeMarker(
{ icon: "fa fa-flag-checkered" }, { icon: "fa fa-flag-checkered" },
{}, {},
); );
endMarker.setLngLat(startEndPoint[1] as M.LngLatLike).addTo(map); layers[id].endMarker.setLngLat(startEndPoint[1] as M.LngLatLike);
if (map.getZoom() > minZoom) {
layers[id].endMarker.addTo(map);
}
}
map!.fitBounds(data.bbox as any, { export function togglePopup(id: string) {
animate: false, layers[id]?.startMarker?.togglePopup();
padding: {
top: 16,
left: 16,
right: 16,
bottom: map!.getContainer().clientHeight * 0.3 + 16,
},
});
} }
onMount(async () => { onMount(async () => {
@@ -148,6 +383,16 @@
).ElevationProfileControl; ).ElevationProfileControl;
const mapStyles = [ const mapStyles = [
{
text: "Open Street Maps",
value: "/styles/osm.json",
thumbnail: "https://tile.openstreetmap.org/1/0/0.png",
},
{
text: "Open Topo Maps",
value: "/styles/otm.json",
thumbnail: "https://tile.opentopomap.org/1/0/0.png",
},
{ {
text: "Carto Light", text: "Carto Light",
value: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", value: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
@@ -186,28 +431,10 @@
elevationMarker.setLngLat([0, 0]).addTo(map); elevationMarker.setLngLat([0, 0]).addTo(map);
elevationMarker.setOpacity("0"); elevationMarker.setOpacity("0");
epc = new ElevationProfileControl({
visible: false,
profileBackgroundColor: $theme == "light" ? "#242734" : "#191b24",
backgroundColor: "bg-menu-background/90",
unit: $page.data.settings?.unit ?? "metric",
profileLineWidth: 3,
displayDistanceGrid: true,
tooltipDisplayDPlus: false,
onEnter: () => {
elevationMarker.setOpacity("1");
},
onLeave: () => {
elevationMarker.setOpacity("0");
},
onMove: (data) => {
elevationMarker.setLngLat(data.position as M.LngLatLike);
},
});
const switcherControl = new StyleSwitcherControl({ const switcherControl = new StyleSwitcherControl({
styles: mapStyles, styles: mapStyles,
onSwitch: (style) => { onSwitch: (style) => {
layers = {};
map?.setStyle(style.value); map?.setStyle(style.value);
localStorage.setItem("layer", style.text); localStorage.setItem("layer", style.text);
}, },
@@ -222,18 +449,64 @@
}), }),
"top-left", "top-left",
); );
map.addControl(epc);
map.addControl(switcherControl); map.addControl(switcherControl);
if (showElevation) {
epc = new ElevationProfileControl({
visible: false,
profileBackgroundColor:
$theme == "light" ? "#242734" : "#191b24",
backgroundColor: "bg-menu-background/90",
unit: $page.data.settings?.unit ?? "metric",
profileLineWidth: 3,
displayDistanceGrid: true,
tooltipDisplayDPlus: false,
zoom: false,
onEnter: () => {
elevationMarker.setOpacity("1");
},
onLeave: () => {
elevationMarker.setOpacity("0");
},
onMove: (data) => {
elevationMarker.setLngLat(data.position as M.LngLatLike);
},
});
map.addControl(epc);
}
map.on("styledata", () => { map.on("styledata", () => {
addTrailLayer(); trails.forEach((t, i) => {
addTrailLayer(t, t.id ?? i.toString(), data?.at(i));
});
});
map.on("moveend", (e) => {
dispatch("moveend", e.target);
});
map.on("zoom", (e) => {
const zoom = e.target.getZoom();
Object.values(layers).forEach((l) => {
if (zoom > minZoom && map) {
l.endMarker?.addTo(map);
} else {
l.endMarker?.remove();
}
});
dispatch("zoom", e.target);
}); });
map.on("click", (e) => { map.on("click", (e) => {
dispatch("click", e); dispatch("click", e);
}); });
for (const waypoint of trail?.expand.waypoints ?? []) { map.on("load", () => {
dispatch("init", map);
});
for (const waypoint of trails[activeTrail]?.expand.waypoints ?? []) {
const marker = createMarkerFromWaypoint(waypoint); const marker = createMarkerFromWaypoint(waypoint);
marker.addTo(map); marker.addTo(map);
markers.push(marker); markers.push(marker);

View File

@@ -9,6 +9,7 @@ import { ClientResponseError } from "pocketbase";
import { get, writable, type Writable } from "svelte/store"; import { get, writable, type Writable } from "svelte/store";
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store"; import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store"; import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
import * as M from "maplibre-gl";
export const trails: Writable<Trail[]> = writable([]) export const trails: Writable<Trail[]> = writable([])
export const trail: Writable<Trail> = writable(new Trail("")); export const trail: Writable<Trail> = writable(new Trail(""));
@@ -71,7 +72,7 @@ export async function trails_search_filter(filter: TrailFilter, page: number = 1
} }
} }
export async function trails_search_bounding_box(northEast: LatLng, southWest: LatLng, filter?: TrailFilter) { export async function trails_search_bounding_box(northEast: M.LngLat, southWest: M.LngLat, filter?: TrailFilter, loadGPX: boolean = true) {
let filterText: string = ""; let filterText: string = "";
@@ -83,6 +84,7 @@ export async function trails_search_bounding_box(northEast: LatLng, southWest: L
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
q: "", options: { q: "", options: {
limit: 400,
filter: [ filter: [
`_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`, `_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`,
filterText filterText
@@ -92,7 +94,7 @@ export async function trails_search_bounding_box(northEast: LatLng, southWest: L
}); });
const result = await r.json(); const result = await r.json();
const trailIds = result.hits.map((h: Record<string, any>) => h.id); const trailIds = result.hits?.map((h: Record<string, any>) => h.id) ?? [];
if (trailIds.length == 0) { if (trailIds.length == 0) {
const currentTrails: Trail[] = get(trails); const currentTrails: Trail[] = get(trails);
@@ -101,6 +103,7 @@ export async function trails_search_bounding_box(northEast: LatLng, southWest: L
} }
r = await fetch('/api/v1/trail?' + new URLSearchParams({ r = await fetch('/api/v1/trail?' + new URLSearchParams({
"per-page": "-1",
filter: `'${trailIds.join(',')}'~id`, filter: `'${trailIds.join(',')}'~id`,
expand: "category,waypoints,summit_logs", expand: "category,waypoints,summit_logs",
sort: `+name`, sort: `+name`,
@@ -110,14 +113,17 @@ export async function trails_search_bounding_box(northEast: LatLng, southWest: L
const response = await r.json() const response = await r.json()
if (r.ok) { if (r.ok) {
for (const trail of response.items) { if (loadGPX) {
const gpxData: string = await fetchGPX(trail); for (const trail of response.items) {
if (!trail.expand) { const gpxData: string = await fetchGPX(trail);
trail.expand = {}; if (!trail.expand) {
trail.expand = {};
}
trail.expand.gpx_data = gpxData;
} }
trail.expand.gpx_data = gpxData;
} }
const comparison = compareObjectArrays<Trail>(get(trails), response.items) const comparison = compareObjectArrays<Trail>(get(trails), response.items)
if (comparison.added.length || comparison.deleted.length || comparison.updated.length) { if (comparison.added.length || comparison.deleted.length || comparison.updated.length) {

View File

@@ -30,42 +30,91 @@ export function bbox(
export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] { export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] {
const startEndPoints: Position[] = []; const startEndPoints: Position[] = [];
(geojson as FeatureCollection).features.forEach((feature) => { // Check if it's a FeatureCollection
const geometry = feature.geometry; if ((geojson as any).features) {
(geojson as any).features.forEach((feature: any) => {
if (geometry.type === "LineString") { const geometry = feature.geometry;
const coords = geometry.coordinates as number[][]; extractStartAndEndPointsFromGeometry(geometry, startEndPoints);
const start: [number, number] = [coords[0][0], coords[0][1]]; // First point });
const end: [number, number] = [ } else if ((geojson as any).geometry) {
coords[coords.length - 1][0], // Single Feature
coords[coords.length - 1][1], const geometry = (geojson as any).geometry;
]; // Last point extractStartAndEndPointsFromGeometry(geometry, startEndPoints);
startEndPoints.push(start); } else {
startEndPoints.push(end) console.warn(
} else if (geometry.type === "MultiLineString") { "Unsupported GeoJSON type. Expected FeatureCollection or Feature."
const coords = geometry.coordinates as number[][][]; );
const start: [number, number] = [ }
coords[0][0][0],
coords[0][0][1],
]; // First point of the first line
const lastLine = coords[coords.length - 1];
const end: [number, number] = [
lastLine[lastLine.length - 1][0],
lastLine[lastLine.length - 1][1],
]; // Last point of the last line
startEndPoints.push(start);
startEndPoints.push(end)
} else {
console.warn(
`Geometry type ${geometry.type} is not supported for start/end point extraction.`
);
}
});
return startEndPoints; return startEndPoints;
} }
function extractStartAndEndPointsFromGeometry(geometry: any, startEndPoints: Position[]) {
if (geometry.type === "LineString") {
const coords = geometry.coordinates as number[][];
const start: [number, number] = [coords[0][0], coords[0][1]]; // First point
const end: [number, number] = [
coords[coords.length - 1][0],
coords[coords.length - 1][1],
]; // Last point
startEndPoints.push(start, end);
} else if (geometry.type === "MultiLineString") {
const coords = geometry.coordinates as number[][][];
const start: [number, number] = [
coords[0][0][0],
coords[0][0][1],
]; // First point of the first line
const lastLine = coords[coords.length - 1];
const end: [number, number] = [
lastLine[lastLine.length - 1][0],
lastLine[lastLine.length - 1][1],
]; // Last point of the last line
startEndPoints.push(start, end);
} else if (geometry.type === "Point") {
const coords = geometry.coordinates as number[];
startEndPoints.push(coords as [number, number], coords as [number, number]);
} else if (geometry.type === "MultiPoint") {
const coords = geometry.coordinates as number[][];
const start: [number, number] = [coords[0][0], coords[0][1]]; // First point
const end: [number, number] = [
coords[coords.length - 1][0],
coords[coords.length - 1][1],
]; // Last point
startEndPoints.push(start, end);
} else if (geometry.type === "Polygon") {
const coords = geometry.coordinates as number[][][];
const start: [number, number] = [
coords[0][0][0],
coords[0][0][1],
]; // First point of the first ring
const lastRing = coords[coords.length - 1];
const end: [number, number] = [
lastRing[lastRing.length - 1][0],
lastRing[lastRing.length - 1][1],
]; // Last point of the last ring
startEndPoints.push(start, end);
} else if (geometry.type === "MultiPolygon") {
const coords = geometry.coordinates as number[][][][];
const firstPolygon = coords[0];
const start: [number, number] = [
firstPolygon[0][0][0],
firstPolygon[0][0][1],
]; // First point of the first ring of the first polygon
const lastPolygon = coords[coords.length - 1];
const lastRing = lastPolygon[lastPolygon.length - 1];
const end: [number, number] = [
lastRing[lastRing.length - 1][0],
lastRing[lastRing.length - 1][1],
]; // Last point of the last ring of the last polygon
startEndPoints.push(start, end);
} else {
console.warn(
`Geometry type ${geometry.type} is not supported for start/end point extraction.`
);
}
}
function coordEach(geojson: GeoJSON, callback: ( function coordEach(geojson: GeoJSON, callback: (
currentCoord: number[], currentCoord: number[],
coordIndex: number, coordIndex: number,
@@ -144,13 +193,13 @@ function coordEach(geojson: GeoJSON, callback: (
for (j = 0; j < coords.length; j++) { for (j = 0; j < coords.length; j++) {
if ( if (
callback( callback(
coords[j], coords[j],
coordIndex, coordIndex,
featureIndex, featureIndex,
multiFeatureIndex, multiFeatureIndex,
geometryIndex geometryIndex
) === false ) === false
) )
return false; return false;
coordIndex++; coordIndex++;
if (geomType === "MultiPoint") multiFeatureIndex++; if (geomType === "MultiPoint") multiFeatureIndex++;
@@ -163,13 +212,13 @@ function coordEach(geojson: GeoJSON, callback: (
for (k = 0; k < coords[j].length - wrapShrink; k++) { for (k = 0; k < coords[j].length - wrapShrink; k++) {
if ( if (
callback( callback(
coords[j][k], coords[j][k],
coordIndex, coordIndex,
featureIndex, featureIndex,
multiFeatureIndex, multiFeatureIndex,
geometryIndex geometryIndex
) === false ) === false
) )
return false; return false;
coordIndex++; coordIndex++;
} }
@@ -185,13 +234,13 @@ function coordEach(geojson: GeoJSON, callback: (
for (l = 0; l < coords[j][k].length - wrapShrink; l++) { for (l = 0; l < coords[j][k].length - wrapShrink; l++) {
if ( if (
callback( callback(
coords[j][k][l], coords[j][k][l],
coordIndex, coordIndex,
featureIndex, featureIndex,
multiFeatureIndex, multiFeatureIndex,
geometryIndex geometryIndex
) === false ) === false
) )
return false; return false;
coordIndex++; coordIndex++;
} }

View File

@@ -1,5 +1,10 @@
import type { Trail } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint"; import type { Waypoint } from "$lib/models/waypoint";
import M from "maplibre-gl"; import M from "maplibre-gl";
import { getFileURL } from "./file_util";
import { formatDistance, formatElevation, formatTimeHHMM } from "./format_util";
import { get } from "svelte/store";
import { _ } from "svelte-i18n";
export class FontawesomeMarker extends M.Marker { export class FontawesomeMarker extends M.Marker {
constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string }, markerOptions?: M.MarkerOptions) { constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string }, markerOptions?: M.MarkerOptions) {
@@ -72,6 +77,38 @@ export function createAnchorMarker(lat: number, lon: number, index: number, onDe
return marker return marker
} }
export function createPopupFromTrail(trail: Trail) {
const thumbnail = trail.photos.length
? getFileURL(trail, trail.photos[trail.thumbnail])
: "/imgs/default_thumbnail.webp";
const popup = new M.Popup({maxWidth: "320px"});
popup.setHTML(
`<a href="/trail/view/${trail.id}" data-sveltekit-preload-data="off">
<li class="flex items-center gap-4 cursor-pointer text-black max-w-80">
<div class="shrink-0"><img class="h-14 w-14 object-cover rounded-xl" src="${thumbnail}" alt="">
</div>
<div>
<h4 class="font-semibold text-lg">${trail.name}</h4>
<div class="flex gap-x-4">
${trail.location ? `<h5><i class="fa fa-location-dot mr-2"></i>${trail.location}</h5>` : ""}
<h5><i class="fa fa-gauge mr-2"></i>${get(_)(trail.difficulty as string)}</h5>
</div>
<div class="grid grid-cols-2 mt-2 gap-x-4 gap-y-2 text-sm text-gray-500 flex-wrap"><span class="shrink-0"><i
class="fa fa-left-right mr-2"></i>${formatDistance(
trail.distance,
)}</span><span class="shrink-0"><i class="fa fa-clock mr-2"></i>${formatTimeHHMM(
trail.duration,
)}</span><span class="shrink-0"><i class="fa fa-arrow-trend-up mr-2"></i>${formatElevation(
trail.elevation_gain,
)}</span></span> <span class="shrink-0"><i class="fa fa-arrow-trend-down mr-2"></i>${formatElevation(
trail.elevation_loss,
)}</span></div>
</div>
</li>
</a>`)
return popup;
}
// export function calculatePixelPerMeter(map: Map, meters: number) { // export function calculatePixelPerMeter(map: Map, meters: number) {
// const y = map.getSize().y; // const y = map.getSize().y;
// const x = map.getSize().x; // const x = map.getSize().x;

View File

@@ -58,7 +58,7 @@ export class ElevationProfileControl implements IControl {
private map?: M.Map; private map?: M.Map;
private buttonContainer?: HTMLDivElement; private buttonContainer?: HTMLDivElement;
private toggleButton?: HTMLButtonElement; private toggleButton?: HTMLButtonElement;
private isProfileShown = false; public isProfileShown = false;
private iconSpan?: HTMLSpanElement; private iconSpan?: HTMLSpanElement;
private profileContainer?: HTMLDivElement; private profileContainer?: HTMLDivElement;

View File

@@ -8,15 +8,15 @@ import type {
Position, Position,
} from "geojson"; } from "geojson";
import { Chart, registerables, type ScriptableContext } from "chart.js"; import { Chart, registerables } from "chart.js";
import zoomPlugin from "chartjs-plugin-zoom"; import zoomPlugin from "chartjs-plugin-zoom";
// @ts-ignore // @ts-ignore
import { CrosshairPlugin } from "chartjs-plugin-crosshair"; import { CrosshairPlugin } from "chartjs-plugin-crosshair";
import type { Waypoint } from "$lib/models/waypoint";
import { haversineCumulatedDistanceWgs84, smoothElevations } from "./tools";
import { haversineDistance } from "$lib/models/gpx/utils"; import { haversineDistance } from "$lib/models/gpx/utils";
import type { Waypoint } from "$lib/models/waypoint";
import { formatTimeHHMM } from "$lib/util/format_util"; import { formatTimeHHMM } from "$lib/util/format_util";
import { haversineCumulatedDistanceWgs84, smoothElevations } from "./tools";
const FEET_PER_METER = 3.28084; const FEET_PER_METER = 3.28084;
const MILES_PER_METER = 0.000621371; const MILES_PER_METER = 0.000621371;
@@ -288,6 +288,7 @@ export type ElevationProfileOptions = {
* Default: `"#0005"` (partially transparent black) * Default: `"#0005"` (partially transparent black)
*/ */
crosshairColor?: string; crosshairColor?: string;
zoom?: boolean;
/** /**
* Callback function to call when the chart is zoomed or panned. * Callback function to call when the chart is zoomed or panned.
* The argument `windowedLineString` is the GeoJSON LineString corresponding * The argument `windowedLineString` is the GeoJSON LineString corresponding
@@ -348,6 +349,7 @@ const elevationProfileDefaultOptions: ElevationProfileOptions = {
paddingRight: 10, paddingRight: 10,
onClick: null, onClick: null,
onMove: null, onMove: null,
zoom: true
}; };
/** /**
@@ -573,15 +575,15 @@ export class ElevationProfile {
zoom: { zoom: {
zoom: { zoom: {
wheel: { wheel: {
enabled: true, enabled: this.settings.zoom,
}, },
pinch: { pinch: {
enabled: true, enabled: this.settings.zoom,
}, },
mode: "x", mode: "x",
}, },
pan: { pan: {
enabled: true, enabled: this.settings.zoom,
mode: "x", mode: "x",
}, },
limits: { limits: {
@@ -701,7 +703,7 @@ export class ElevationProfile {
}, },
{ {
id: "customZoomEvent", id: "customZoomEvent",
afterDataLimits: () => { afterDataLimits: (chart) => {
if (typeof this.settings.onChangeView !== "function") return; if (typeof this.settings.onChangeView !== "function") return;
try { try {
this.settings.onChangeView.apply(this, [ this.settings.onChangeView.apply(this, [
@@ -791,6 +793,7 @@ export class ElevationProfile {
if (color !== prevColor) { if (color !== prevColor) {
const percentDone = this.cumulatedDistance[i] / this.cumulatedDistance[this.cumulatedDistance.length - 1] const percentDone = this.cumulatedDistance[i] / this.cumulatedDistance[this.cumulatedDistance.length - 1]
this.gradient.addColorStop(percentDone, color); this.gradient.addColorStop(percentDone, color);
prevColor = color; prevColor = color;
} }
@@ -974,7 +977,7 @@ export class ElevationProfile {
} }
} }
} }
this.grade.push(this.grade.at(-1) ?? 0); this.grade.push(this.grade.at(-1) ?? 0);
this.cumulatedDPlus.push(cumulatedDPlus); this.cumulatedDPlus.push(cumulatedDPlus);

View File

@@ -6,22 +6,18 @@
import ListCard from "$lib/components/list/list_card.svelte"; import ListCard from "$lib/components/list/list_card.svelte";
import ListPanel from "$lib/components/list/list_panel.svelte"; import ListPanel from "$lib/components/list/list_panel.svelte";
import ListShareModal from "$lib/components/list/list_share_modal.svelte"; import ListShareModal from "$lib/components/list/list_share_modal.svelte";
import MapWithElevationMultiple from "$lib/components/trail/map_with_elevation_multiple.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte"; import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte";
import TrailList from "$lib/components/trail/trail_list.svelte"; import TrailList from "$lib/components/trail/trail_list.svelte";
import { List } from "$lib/models/list"; import { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { import {
list,
lists, lists,
lists_delete, lists_delete,
lists_index, lists_index
} from "$lib/stores/list_store"; } from "$lib/stores/list_store";
import { fetchGPX } from "$lib/stores/trail_store"; import { fetchGPX } from "$lib/stores/trail_store";
import "$lib/vendor/leaflet-elevation/src/index.css"; import * as M from "maplibre-gl";
import type { Map } from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css";
import { onMount, tick } from "svelte"; import { onMount, tick } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
@@ -29,14 +25,16 @@
let openConfirmModal: () => void; let openConfirmModal: () => void;
let openShareModal: () => void; let openShareModal: () => void;
let map: Map; let map: M.Map;
let mapWithElevationMultiple: MapWithElevationMultiple; let mapWithElevation: MapWithElevationMaplibre;
let markers: any[]; let markers: any[];
let showMap: boolean = true; let showMap: boolean = true;
let selectedList: List | null = null; let selectedList: List | null = null;
let selectedTrail: Trail | null = null; let selectedTrail: Trail | null = null;
let activeTrailIndex: number = -1;
onMount(() => { onMount(() => {
if ($page.url.searchParams.get("list")) { if ($page.url.searchParams.get("list")) {
const listToFocus = $lists.find( const listToFocus = $lists.find(
@@ -86,32 +84,32 @@
selectedList = item; selectedList = item;
} }
function back() { async function back() {
if (selectedTrail) { if (selectedTrail) {
mapWithElevation.unFocusTrail(selectedTrail);
selectedTrail = null; selectedTrail = null;
mapWithElevationMultiple.resetSelection();
} else if (selectedList) { } else if (selectedList) {
selectedList = null; selectedList = null;
map.flyTo([0, 0], 4, { map.flyTo({
duration: 0.25, animate: true,
easeLinearity: 0.25, zoom: 1,
noMoveStart: true, center: [0, 0],
}); });
} }
} }
function selectTrail(trail: Trail) { function selectTrail(trail: Trail) {
selectedTrail = trail; selectedTrail = trail;
mapWithElevationMultiple.selectTrail(trail.id!); mapWithElevation.focusTrail(trail);
window.scrollTo({ top: 0 }); window.scrollTo({ top: 0 });
} }
function highlightTrail(trail: Trail) { function highlightTrail(trail: Trail) {
mapWithElevationMultiple.highlightTrail(trail.id!); mapWithElevation.highlightTrail(trail.id!);
} }
function unHighlightTrail(trail: Trail) { function unHighlightTrail(trail: Trail) {
mapWithElevationMultiple.unHighlightTrail(trail.id!); mapWithElevation.unHighlightTrail(trail.id!);
} }
</script> </script>
@@ -170,17 +168,17 @@
</div> </div>
</div> </div>
<div id="trail-map" class="md:sticky md:top-[62px]" class:hidden={!showMap}> <div id="trail-map" class="md:sticky md:top-[62px]" class:hidden={!showMap}>
<MapWithElevationMultiple <MapWithElevationMaplibre
trails={selectedList?.expand?.trails ?? []} trails={selectedList?.expand?.trails ?? []}
bind:map bind:map
bind:this={mapWithElevationMultiple} bind:this={mapWithElevation}
bind:markers bind:markers
on:select={(e) => { on:select={(e) => {
selectedTrail = e.detail selectedTrail = e.detail;
}} }}
bindRoutePopup={false} bind:activeTrail={activeTrailIndex}
options={{ itinerary: true, flyToBounds: true }} showInfoPopup={true}
></MapWithElevationMultiple> ></MapWithElevationMaplibre>
</div> </div>
<div class="min-w-0" class:hidden={showMap}> <div class="min-w-0" class:hidden={showMap}>
<TrailList trails={selectedList?.expand?.trails ?? []}></TrailList> <TrailList trails={selectedList?.expand?.trails ?? []}></TrailList>

View File

@@ -10,10 +10,16 @@
} from "$lib/components/base/search.svelte"; } from "$lib/components/base/search.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import Textarea from "$lib/components/base/textarea.svelte"; import Textarea from "$lib/components/base/textarea.svelte";
import MapWithElevationMultiple from "$lib/components/trail/map_with_elevation_multiple.svelte"; import ConfirmModal from "$lib/components/confirm_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import type { Trail } from "$lib/models/trail.js"; import type { Trail } from "$lib/models/trail.js";
import { TrailShare } from "$lib/models/trail_share.js";
import { lists_create, lists_update } from "$lib/stores/list_store.js"; import { lists_create, lists_update } from "$lib/stores/list_store.js";
import { show_toast } from "$lib/stores/toast_store.js"; import { show_toast } from "$lib/stores/toast_store.js";
import {
trail_share_create,
trail_share_index,
} from "$lib/stores/trail_share_store.js";
import { trails_show } from "$lib/stores/trail_store"; import { trails_show } from "$lib/stores/trail_store";
import { getFileURL } from "$lib/util/file_util.js"; import { getFileURL } from "$lib/util/file_util.js";
import { import {
@@ -21,21 +27,15 @@
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import {
trail_share_create,
trail_share_index,
} from "$lib/stores/trail_share_store.js";
import { TrailShare } from "$lib/models/trail_share.js";
import ConfirmModal from "$lib/components/confirm_modal.svelte";
export let data; export let data;
let previewURL = data.previewUrl ?? ""; let previewURL = data.previewUrl ?? "";
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = [];
let activeTrailIndex: number | null = null; let activeTrailIndex: number = -1;
let map: MapWithElevationMultiple; let map: MapWithElevationMaplibre;
let loading: boolean = false; let loading: boolean = false;
@@ -301,7 +301,9 @@
trail.difficulty ?? "?", trail.difficulty ?? "?",
)}</span )}</span
> >
<div class="grid grid-cols-2 mt-1 gap-x-4 gap-y-2 text-sm text-gray-500"> <div
class="grid grid-cols-2 mt-1 gap-x-4 gap-y-2 text-sm text-gray-500"
>
<span <span
><i class="fa fa-left-right mr-2" ><i class="fa fa-left-right mr-2"
></i>{formatDistance(trail.distance)}</span ></i>{formatDistance(trail.distance)}</span
@@ -368,12 +370,11 @@
> >
</form> </form>
<div id="trail-map" class="max-h-full"> <div id="trail-map" class="max-h-full">
<MapWithElevationMultiple <MapWithElevationMaplibre
trails={$form.expand?.trails ?? []} trails={$form.expand?.trails ?? []}
options={{ flyToBounds: true }} bind:activeTrail={activeTrailIndex}
bind:activeTrailIndex
bind:this={map} bind:this={map}
></MapWithElevationMultiple> ></MapWithElevationMaplibre>
</div> </div>
</main> </main>

View File

@@ -6,7 +6,7 @@
type SearchItem, type SearchItem,
} from "$lib/components/base/search.svelte"; } from "$lib/components/base/search.svelte";
import EmptyStateSearch from "$lib/components/empty_states/empty_state_search.svelte"; import EmptyStateSearch from "$lib/components/empty_states/empty_state_search.svelte";
import MapWithElevationMultiple from "$lib/components/trail/map_with_elevation_multiple.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import TrailCard from "$lib/components/trail/trail_card.svelte"; import TrailCard from "$lib/components/trail/trail_card.svelte";
import TrailFilterPanel from "$lib/components/trail/trail_filter_panel.svelte"; import TrailFilterPanel from "$lib/components/trail/trail_filter_panel.svelte";
import type { Settings } from "$lib/models/settings"; import type { Settings } from "$lib/models/settings";
@@ -21,21 +21,13 @@
trails_search_bounding_box, trails_search_bounding_box,
} from "$lib/stores/trail_store"; } from "$lib/stores/trail_store";
import { country_codes } from "$lib/util/country_code_util"; import { country_codes } from "$lib/util/country_code_util";
import "$lib/vendor/leaflet-elevation/src/index.css"; import * as M from "maplibre-gl";
import type {
GPX,
LatLng,
LatLngBoundsExpression,
Map,
Marker,
} from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
let map: Map;
let mapWithElevation: MapWithElevationMultiple; let map: M.Map;
let mapWithElevation: MapWithElevationMaplibre;
let searchDropdownItems: SearchItem[] = []; let searchDropdownItems: SearchItem[] = [];
let showFilter: boolean = false; let showFilter: boolean = false;
@@ -45,6 +37,8 @@
const maxBoundingBox: TrailBoundingBox = $page.data.boundingBox; const maxBoundingBox: TrailBoundingBox = $page.data.boundingBox;
const settings: Settings = $page.data.settings; const settings: Settings = $page.data.settings;
const MIN_ZOOM = 6;
onMount(async () => {}); onMount(async () => {});
async function search(q: string) { async function search(q: string) {
@@ -93,23 +87,25 @@
} }
function handleSearchClick(item: SearchItem) { function handleSearchClick(item: SearchItem) {
map.setView([item.value._geo.lat, item.value._geo.lng], 14); map.setCenter([item.value._geo.lng, item.value._geo.lat]);
map.setZoom(14);
} }
async function searchTrails(northEast: LatLng, southWest: LatLng) { async function searchTrails(northEast: M.LngLat, southWest: M.LngLat) {
const changes = await trails_search_bounding_box( const changes = await trails_search_bounding_box(
northEast, northEast,
southWest, southWest,
filter, filter,
map.getZoom() > MIN_ZOOM
); );
} }
function handleTrailCardMouseEnter(trail: Trail) { function handleTrailCardMouseEnter(trail: Trail) {
mapWithElevation.openPopup(trail.id!) mapWithElevation.togglePopup(trail.id!);
} }
function handleTrailCardMouseLeave(trail: Trail) { function handleTrailCardMouseLeave(trail: Trail) {
mapWithElevation.closePopup(trail.id!) mapWithElevation.togglePopup(trail.id!);
} }
async function handleFilterUpdate(filter: TrailFilter) { async function handleFilterUpdate(filter: TrailFilter) {
@@ -136,29 +132,37 @@
$page.url.searchParams.has("br_lat") && $page.url.searchParams.has("br_lat") &&
$page.url.searchParams.has("br_lon") $page.url.searchParams.has("br_lon")
) { ) {
const boundingBox: LatLngBoundsExpression = [ const boundingBox: M.LngLatBoundsLike = [
[parseFloat($page.url.searchParams.get("br_lat")!), parseFloat($page.url.searchParams.get("tl_lon")!)], [
[parseFloat($page.url.searchParams.get("tl_lat")!), parseFloat($page.url.searchParams.get("br_lon")!)], parseFloat($page.url.searchParams.get("br_lon")!),
parseFloat($page.url.searchParams.get("tl_lat")!),
],
[
parseFloat($page.url.searchParams.get("tl_lon")!),
parseFloat($page.url.searchParams.get("br_lat")!),
],
]; ];
map.fitBounds(boundingBox); map.fitBounds(boundingBox, { animate: false });
} else if (settings && settings.mapFocus == "trails") { } else if (settings && settings.mapFocus == "trails") {
const boundingBox: LatLngBoundsExpression = [ const boundingBox: M.LngLatBoundsLike = [
[maxBoundingBox.max_lat, maxBoundingBox.min_lon], [maxBoundingBox.min_lon, maxBoundingBox.max_lat],
[maxBoundingBox.min_lat, maxBoundingBox.max_lon], [maxBoundingBox.max_lon, maxBoundingBox.min_lat],
]; ];
map.fitBounds(boundingBox); map.fitBounds(boundingBox, { animate: false, padding: 32 });
} else if ( } else if (
settings && settings &&
settings.mapFocus == "location" && settings.mapFocus == "location" &&
settings.location settings.location
) { ) {
map.setView([settings.location.lat, settings.location.lon], 12); map.setCenter([settings.location.lon, settings.location.lat]);
map.setZoom(12);
} else { } else {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
const lat = position.coords.latitude; const lat = position.coords.latitude;
const lon = position.coords.longitude; const lon = position.coords.longitude;
map.setView([lat, lon], 13); map.setCenter([lat, lon]);
map.setZoom(12);
}, },
(error) => { (error) => {
console.error("Error getting user location:", error); console.error("Error getting user location:", error);
@@ -235,14 +239,17 @@
id="trail-map" id="trail-map"
class:hidden={!showMap && browser && window.innerWidth < 768} class:hidden={!showMap && browser && window.innerWidth < 768}
> >
<MapWithElevationMultiple <MapWithElevationMaplibre
on:moveend={handleMapMove} on:moveend={handleMapMove}
on:init={handleMapInit} on:init={handleMapInit}
trails={$trails} trails={$trails}
options={{ flyToBounds: false }} showElevation={false}
showInfoPopup={true}
activeTrail={-1}
minZoom={MIN_ZOOM}
bind:map bind:map
bind:this={mapWithElevation} bind:this={mapWithElevation}
></MapWithElevationMultiple> ></MapWithElevationMaplibre>
</div> </div>
</main> </main>

View File

@@ -18,7 +18,7 @@
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4"> <main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
<TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel> <TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel>
<div id="trail-details" class="sticky top-[62px]"> <div id="trail-details" class="sticky top-[62px]">
<MapWithElevationMaplibre trail={$trail} bind:markers></MapWithElevationMaplibre> <MapWithElevationMaplibre trails={[$trail]} bind:markers></MapWithElevationMaplibre>
</div> </div>
</main> </main>

View File

@@ -1,8 +0,0 @@
<script>
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import { onMount } from "svelte";
export let data;
</script>
<MapWithElevationMaplibre trail={data.trail}></MapWithElevationMaplibre>

View File

@@ -1,9 +0,0 @@
import type { Trail } from "$lib/models/trail";
import { trails_show } from "$lib/stores/trail_store";
export const load = async ({ params, fetch }) => {
const t: Trail = await trails_show("yesm2tqc6jok8jq", true, fetch)
return { trail: t }
};

View File

@@ -921,7 +921,7 @@
</div> </div>
{/if} {/if}
<MapWithElevationMaplibre <MapWithElevationMaplibre
trail={$form} trails={[$form]}
drawing={drawingActive} drawing={drawingActive}
bind:map bind:map
on:click={(e) => handleMapClick(e.detail)} on:click={(e) => handleMapClick(e.detail)}

View File

@@ -0,0 +1,21 @@
{
"version": 8,
"sources": {
"osm-tiles": {
"type": "raster",
"tiles": [
"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
],
"tileSize": 256
}
},
"layers": [
{
"id": "osm-tiles",
"type": "raster",
"source": "osm-tiles",
"minzoom": 0,
"maxzoom": 19
}
]
}

View File

@@ -0,0 +1,21 @@
{
"version": 8,
"sources": {
"osm-tiles": {
"type": "raster",
"tiles": [
"https://tile.opentopomap.org/{z}/{x}/{y}.png"
],
"tileSize": 256
}
},
"layers": [
{
"id": "osm-tiles",
"type": "raster",
"source": "osm-tiles",
"minzoom": 0,
"maxzoom": 19
}
]
}