import { Map as MaplibreMap, NavigationControl, Popup, ScaleControl, addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { GpxPanelControl } from "/js/gpx.js"; import { OverlaysControl } from "/js/overlays.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { MeasureControl } from "/js/measure.js"; import { NotesControl } from "/js/notes.js"; import { PluginsControl } from "/js/plugins.js"; import { LocateControl } from "/js/locate.js"; import { SearchControl } from "/js/search.js"; import { OfflineDataControl } from "/js/offline_data.js"; import { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js"; import { formatCoords, formatSunInfo } from "/js/sun.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { isLoggedIn } from "/js/auth.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import OverviewMap from "/vendor/npmap-overview/overview.esm.js"; import * as maplibregl from "/vendor/maplibre-gl/maplibre-gl.mjs"; const CENTER = [17.1077, 48.1486]; const ZOOM = 13; const ONLINE_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty"; // Recolors OpenFreeMap Liberty to a warm, NPS-Park-Tiles-inspired palette — // cream ground, muted roads/boundaries — mapped onto Liberty's OpenMapTiles // layer ids. Applied identically regardless of which PMTiles data actually // backs the "openmaptiles" source (see offlineStyle() below): offline tiles // are built by Planetiler's own OpenMapTiles profile (docs/development.adoc), // the same schema Liberty already expects, so one patch serves both — // no separate offline theme/palette to keep in sync. Only the layers that // dominate the map's overall color read are touched; labels, halos, rail, // and minor overlays (cemetery/hospital/school/pitch/track shading, // building outlines) are left at Liberty's own defaults. // // wood/grass are deliberately left unpatched — Liberty's own green (a // translucent landcover_wood/landcover_grass wash) already reads well over // the patched cream background. const ONLINE_STYLE_PATCH = { background: { "background-color": "#f9f6ef" }, // Liberty washes every built-up area in a translucent gray-tan below // z12 (landuse_residential, maxzoom 12 — gone by this app's default // z13) that the offline schema has no equivalent of; protomaps just // leaves that ground as plain background/earth. Matched to background // here rather than left alone, so a zoomed-out view doesn't read as // visibly dingier online than offline. landuse_residential: { "fill-color": "#f9f6ef" }, landcover_sand: { "fill-color": "#f0e4c4" }, water: { "fill-color": "#a3cedf" }, // fill-outline-color isn't touched by a plain fill-color patch — Liberty // draws it separately, defaulting to a bright green that reads as a // distinct border around named conservation areas (e.g. the Malé // Karpaty CHKO). Matched to the fill itself, and park_outline's own // dashed line hidden below, so a reserve blends in as ordinary forest — // offline has no equivalent stroke at all (see park_a/park_b below), // and the point was never to call reserves out as visibly different, // on either side. park: { "fill-color": "#b3e378", "fill-outline-color": "#b3e378" }, park_outline: { "line-opacity": 0 }, // fill-opacity: 0.5 matches the offline theme's own "buildings" layer — // without it, Liberty's dense individual building footprints (each with // its own outline) paint fully opaque and read much heavier/grayer than // offline's softer, half-transparent fill over the same cream ground. building: { "fill-color": "#ded7cc", "fill-opacity": 0.5 }, boundary_2: { "line-color": "#546619" }, boundary_3: { "line-color": "#546619" }, // Highway (motorway/trunk/primary), major (secondary/tertiary/link), // minor (residential/service/track), pedestrian — each repeated for its // tunnel_/bridge_ counterpart so a road doesn't change color when it // dips into a tunnel or crosses a bridge. Rail is left alone. // // line-width below is a flat number, not Liberty's own zoom-interpolated // expression — picked to match the offline theme's actual rendered width // at this app's default zoom (13; verified live via queryRenderedFeatures // on both sides), since without it Liberty's own curve draws surface // roads roughly twice as thick as offline at street level, reading as a // much bolder road network online. // ponytail: tuned for z13 only, not a matching curve across all zooms — // will drift back apart at very different zoom levels. Upgrade path: // sample offline's width at a few more zooms and replace with a matching // ["interpolate", ["linear"], ["zoom"], ...] expression. road_motorway: { "line-color": "#fdefbe", "line-width": 2.26 }, road_trunk_primary: { "line-color": "#fdefbe", "line-width": 2.26 }, road_secondary_tertiary: { "line-color": "#fef7dd", "line-width": 1.87 }, road_link: { "line-color": "#fef7dd", "line-width": 1 }, road_motorway_link: { "line-color": "#fef7dd", "line-width": 1 }, road_minor: { "line-color": "#fffdf8" }, road_service_track: { "line-color": "#fffdf8" }, road_path_pedestrian: { "line-color": "#e9e2d8" }, road_motorway_casing: { "line-color": "#c9a66b", "line-width": 1.48 }, road_trunk_primary_casing: { "line-color": "#c9a66b", "line-width": 1.48 }, road_secondary_tertiary_casing: { "line-color": "#c9a66b", "line-width": 1 }, road_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 }, road_motorway_link_casing: { "line-color": "#c9a66b", "line-width": 1.5 }, road_minor_casing: { "line-color": "#c9a66b" }, road_service_track_casing: { "line-color": "#c9a66b" }, tunnel_motorway: { "line-color": "#fdefbe" }, tunnel_trunk_primary: { "line-color": "#fdefbe" }, tunnel_secondary_tertiary: { "line-color": "#fef7dd" }, tunnel_link: { "line-color": "#fef7dd" }, tunnel_motorway_link: { "line-color": "#fef7dd" }, tunnel_minor: { "line-color": "#fffdf8" }, tunnel_service_track: { "line-color": "#fffdf8" }, tunnel_path_pedestrian: { "line-color": "#e9e2d8" }, tunnel_motorway_casing: { "line-color": "#c9a66b" }, tunnel_trunk_primary_casing: { "line-color": "#c9a66b" }, tunnel_secondary_tertiary_casing: { "line-color": "#c9a66b" }, tunnel_link_casing: { "line-color": "#c9a66b" }, tunnel_motorway_link_casing: { "line-color": "#c9a66b" }, tunnel_street_casing: { "line-color": "#c9a66b" }, bridge_motorway: { "line-color": "#fdefbe" }, bridge_trunk_primary: { "line-color": "#fdefbe" }, bridge_secondary_tertiary: { "line-color": "#fef7dd" }, bridge_link: { "line-color": "#fef7dd" }, bridge_motorway_link: { "line-color": "#fef7dd" }, bridge_street: { "line-color": "#fffdf8" }, bridge_service_track: { "line-color": "#fffdf8" }, bridge_path_pedestrian: { "line-color": "#e9e2d8" }, bridge_motorway_casing: { "line-color": "#c9a66b" }, bridge_trunk_primary_casing: { "line-color": "#c9a66b" }, bridge_secondary_tertiary_casing: { "line-color": "#c9a66b" }, bridge_link_casing: { "line-color": "#c9a66b" }, bridge_motorway_link_casing: { "line-color": "#c9a66b" }, bridge_street_casing: { "line-color": "#c9a66b" }, bridge_path_pedestrian_casing: { "line-color": "#c9a66b" }, }; function patchOnlineStyle(style) { for (const layer of style.layers) { const patch = ONLINE_STYLE_PATCH[layer.id]; if (patch) Object.assign((layer.paint ??= {}), patch); } return insertTrackOnlyLayer(style); } // Liberty's own "road_service_track" layer paints service roads (paved // driveways/parking aisles) and actual unpaved tracks the same color — // fine for a general-purpose map, not for this app's "can the camper get // there" use case. The underlying OpenMapTiles data does carry `class: // "track"` separately from `"service"` (see the filter below), so rather // than recoloring the merged layer (which would mislabel paved driveways // as unpaved), this clones it into a second layer scoped to `class == // "track"` only, dashed and dirt-brown — matching how the offline theme // already renders protomaps' own separate "other"/"path" road kind. // Surface-level only (excludes tunnel_/bridge_ variants): an unpaved track // in a tunnel or on a bridge is rare enough not to be worth a third layer. function insertTrackOnlyLayer(style) { const index = style.layers.findIndex((layer) => layer.id === "road_service_track"); if (index === -1) return style; const base = style.layers[index]; const trackOnly = { ...base, id: "road_track_only", filter: ["all", base.filter, ["==", ["get", "class"], "track"]], paint: { ...base.paint, "line-color": "#c9a66b", "line-dasharray": [2, 2] }, }; style.layers.splice(index + 1, 0, trackOnly); return style; } // Fetched (not just referenced by URL) so its colors can be patched before // the map ever sees it — MapLibre accepts a style object anywhere it'd // accept a style URL, so every other use of ONLINE_STYLE (applyStyle's // map.setStyle) needs no change. This runs before the map — and the rest // of the page's setup — is constructed (see the top-level await below), so // it falls back to the plain URL, letting MapLibre fetch and render it // unpatched, both when the fetch fails outright and when it's just slow // (a 3s timeout): this app is meant to work offline in the field, and // blocking the entire page on a remote style server would undermine that // even though this particular fetch is only for the online map. async function loadOnlineStyle() { try { const response = await fetch(ONLINE_STYLE_URL, { signal: AbortSignal.timeout(3000) }); return patchOnlineStyle(await response.json()); } catch (err) { return ONLINE_STYLE_URL; } } const ONLINE_STYLE = await loadOnlineStyle(); // A vendored copy of the same Liberty style ONLINE_STYLE fetches live // (public/vendor/liberty-style/liberty.json — a point-in-time snapshot, not // auto-updated) that offlineStyle() below patches+repoints the exact same // way. Offline mode can't depend on ONLINE_STYLE_URL actually being // reachable — that fetch failing/timing out is the normal case in the // field, not an edge case — so this is a local, same-origin fetch instead // (fast and always available, since it's served by this app itself). const OFFLINE_STYLE_TEMPLATE = await fetch("/vendor/liberty-style/liberty.json").then((response) => response.json()); const protocol = new pmtiles.Protocol(); addProtocol("pmtiles", protocol.tile); // Builds the offline style from OFFLINE_STYLE_TEMPLATE — the identical // Liberty JSON + ONLINE_STYLE_PATCH recolor as the online map, just // repointed at a local PMTiles source instead of tiles.openfreemap.org. // This works because data/offline.pmtiles is now built by Planetiler's own // OpenMapTiles profile (see docs/development.adoc), the same vector schema // Liberty expects — before that, offline tiles used a different schema // (protomaps' own basemaps) needing a completely separate hand-tuned // theme (protomaps-themes-base + OFFLINE_THEME) that could never be made // to match ONLINE_STYLE_PATCH's look exactly, no matter how much either // side got tuned. // // glyphs/sprite point at vendored local copies (public/vendor/fonts, // public/vendor/liberty-sprite) rather than tiles.openfreemap.org's — // glyphs are Latin-only (see docs/architecture.adoc: labels in other // scripts won't render offline), sprite is a one-time snapshot like // OFFLINE_STYLE_TEMPLATE itself. ne2_shaded (Liberty's low-zoom shaded- // relief backdrop, z0-6 only) is left pointing at its remote URL // unchanged — offline usage is regional, not whole-earth zoomed out, so // this only ever silently fails to load at a zoom level offline users // aren't in, the same graceful-degradation story as this app's other // online-only extras (Wikipedia, live radar). function offlineStyle() { const style = patchOnlineStyle(structuredClone(OFFLINE_STYLE_TEMPLATE)); style.sources.openmaptiles.url = `pmtiles://${window.location.origin}/tiles/offline.pmtiles`; style.glyphs = `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`; style.sprite = `${window.location.origin}/vendor/liberty-sprite/ofm`; return style; } // A sidebar icon that opens a full-height side panel (like the GPX one) // with the Online/Offline choice — modelled after OsmAnd's "Configure map" // panel, rather than a small dropdown flyout. class LayerToggleControl { constructor(onSelect) { this._onSelect = onSelect; } mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; this._container.innerHTML = ``; this._icon = this._container.querySelector(".sidebar-icon"); this._icon.addEventListener("click", () => this._togglePanel()); this._panel = document.getElementById("basemap-panel"); this._panel .querySelector(".basemap-panel__close") .addEventListener("click", () => this._togglePanel(false)); this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { button.addEventListener("click", () => { this._onSelect(button.dataset.style); this._togglePanel(false); }); }); onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active")); sidebar.appendChild(this._container); } _togglePanel(force) { const open = force === undefined ? this._panel.hidden : force; this._panel.hidden = !open; this._icon.classList.toggle("active", open); if (open) notifySidePanelOpen(this._panel); } // Public wrapper so a second entry point (MapLayersButton, a map corner // control) can open/close the same panel without reaching into a // "private" method by convention only. toggle() { this._togglePanel(); } setActive(name) { this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => { button.classList.toggle("active", button.dataset.style === name); }); } // Called once it's known there's no offline map to switch to (see the // /api/offline-tiles check below) — an Online/Offline switch doesn't make // sense to show at all when Offline was never going to work. remove() { this._container.remove(); this._panel.hidden = true; } } // A small map-corner button (grouped with Home/zoom, see below) that's a // second entry point to the same panel LayerToggleControl already manages // in the page's own sidebar — matching the National Park Service's own // map control layout, which keeps zoom/home/base-map together on the map // rather than split between the map and a separate side rail. class MapLayersButton { constructor(layerToggle) { this._layerToggle = layerToggle; } onAdd() { this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group"; const button = document.createElement("button"); button.type = "button"; button.title = "Base map"; button.setAttribute("aria-label", "Base map"); button.innerHTML = LAYERS_ICON; button.addEventListener("click", () => this._layerToggle.toggle()); this._container.appendChild(button); return this._container; } onRemove() { this._container.remove(); } getDefaultPosition() { return "top-left"; } } // A passive zoom-level readout, sitting in the same stack as the buttons // around it — the National Park Service's own control layout has one (the // small numbered square next to their zoom buttons). Not a button: no // click behavior, just a live display, so it's a plain div rather than a //