import { Map as MaplibreMap, NavigationControl, GeolocateControl, addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; import { GpxPanelControl } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; import { LAYERS_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; const CENTER = [17.1077, 48.1486]; const ZOOM = 13; const ONLINE_STYLE = "https://tiles.openfreemap.org/styles/liberty"; const protocol = new pmtiles.Protocol(); addProtocol("pmtiles", protocol.tile); // A few color roles nudged from protomaps-themes-base's stock "light" theme // towards the online OpenFreeMap "liberty" style's actual palette (sampled // from its style JSON — see docs/architecture.adoc), so switching between // Online/Offline doesn't look like two different apps. Not a full recolor: // most roles (labels, casings, minor terrain fills, ...) are left at their // theme defaults. const OFFLINE_THEME = { ...protomaps_themes_base.namedTheme("light"), background: "#f8f4f0", earth: "#f8f4f0", water: "#9ebdff", wood_a: "#ace38c", wood_b: "#ace38c", buildings: "#dcd9d6", highway: "#f2934a", major: "#ffeeaa", link: "#ffeeaa", }; function offlineStyle() { const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`; return { version: 8, glyphs: `${window.location.origin}/vendor/fonts/{fontstack}/{range}.pbf`, sources: { offline: { type: "vector", url: `pmtiles://${pmtilesUrl}`, attribution: "© OpenStreetMap contributors", }, }, // lang: "en" pulls in labels_layers (see docs/architecture.adoc) — only // the Latin-script glyph ranges are vendored, so labels using other // scripts won't render offline. layers: protomaps_themes_base.layers("offline", OFFLINE_THEME, { lang: "en" }), }; } // 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); } 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; } } const map = new MaplibreMap({ container: "map", style: ONLINE_STYLE, center: CENTER, zoom: ZOOM, // MapLibre's built-in Hash control: keeps #map=zoom/lat/lon in the URL in // sync with the view (via history.replaceState, so panning doesn't spam // the back button) and, if the page loads with one already in the URL, // opens there instead of the default CENTER/ZOOM — the same "grab the URL // to share this exact spot" permalink pattern OSM/Google Maps use. hash: "map", }); map.addControl(new NavigationControl(), "bottom-right"); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true, showAccuracyCircle: true, }), "bottom-right", ); // homePosition/zoom pinned to the app's own default rather than left to the // button's own fallback (current center/zoom at construction) — now that // the URL hash can restore any view on load (see the Hash option above), // "current view" and "the default view" are no longer the same thing, and // a home button that resets to wherever you happened to load the page // wouldn't be a home button. map.addControl(new HomeButton({ homePosition: CENTER, zoom: ZOOM }), "top-left"); const sidebar = document.getElementById("sidebar"); const layerToggle = new LayerToggleControl(applyStyle); layerToggle.mount(sidebar); layerToggle.setActive("online"); const profilePanel = new TrackProfilePanel(map); profilePanel.mount(); new GpxPanelControl(map, profilePanel).mount(sidebar); new RadarControl(map).mount(sidebar); new GnssControl(map).mount(sidebar); new LiveTrackPanelControl(map).mount(sidebar); function applyStyle(name) { layerToggle.setActive(name); map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE); } // If a local PMTiles file is present, prefer it as the default style — its // presence signals the app is meant to be used offline here. If one isn't, // the Online/Offline switch itself doesn't make sense (there's nothing to // switch to), so it's removed entirely rather than left sitting there // offering a choice that can't work. fetch("/api/offline-tiles") .then((response) => response.json()) .then(({ available }) => { if (available) { applyStyle("offline"); } else { layerToggle.remove(); } }) .catch(() => { // Backend check failed; keep the online style and leave the switch as // is rather than guessing — this is the same-origin request itself // failing, not evidence offline tiles are actually unavailable. });