Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd

Restructure the map controls from MapLibre corner buttons into a left
sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching
OsmAnd's layout more closely:

- Base map and GPX selection are now full-height side panels docked
  next to the sidebar (not small dropdown flyouts or a right-docked
  panel), opening/closing exclusively via a small shared event bus
  (panels.js) so only one is ever open at a time.
- The GPX panel's upload/new-folder controls are now icon buttons in
  the header instead of an always-visible dropzone and form; dragging
  a GPX file onto the tree, a specific folder, or straight onto the
  map itself all upload it.
- New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live
  GNSS fixes from a local gpsd daemon over Server-Sent Events
  (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one
  background gpsd connection shared by every browser tab.
- The online map style (public/js/osmand-style.js) is now styled to
  resemble OsmAnd's own rendering, applied to both the online and
  offline (PMTiles) styles.

Also adds a GeolocateControl (browser geolocation) next to the zoom
controls.
This commit is contained in:
randy committed 2026-08-25 19:39:13 +02:00
1 parent 008e034932
commit 87df90f75c
14 files changed
+939 -200

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
import { Marker } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { GNSS_ICON } from "/js/icons.js";
const POSITION_URL = "/api/gnss/position";
const POLL_INTERVAL_MS = 1500;
// A sidebar icon that polls /api/gnss/position (backed server-side by a
// gpsd connection, see lib/maptool/gpsd_client.rb) and shows the fix as a
// marker, with fix status in a flyout. Polling rather than a push channel
// is deliberate — see the comment in app.rb next to GNSS_LATEST — and fits
// gpsd's own ~1Hz report rate fine.
export class GnssControl {
constructor(map) {
this._map = map;
this._marker = null;
this._timer = null;
this._enabled = false;
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `
<button type="button" class="sidebar-icon" title="GNSS" aria-label="GNSS">${GNSS_ICON}</button>
<div class="sidebar-flyout" hidden>
<span class="gnss-control__status"></span>
</div>
`;
this._icon = this._container.querySelector(".sidebar-icon");
this._flyout = this._container.querySelector(".sidebar-flyout");
this._status = this._container.querySelector(".gnss-control__status");
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
sidebar.appendChild(this._container);
}
_setEnabled(enabled) {
this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
this._flyout.hidden = !enabled;
if (enabled) {
this._poll();
this._timer = setInterval(() => this._poll(), POLL_INTERVAL_MS);
} else {
this._stopPolling();
this._removeMarker();
this._status.textContent = "";
}
}
_stopPolling() {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
}
async _poll() {
let data;
try {
const response = await fetch(POSITION_URL);
data = await response.json();
} catch (err) {
this._status.textContent = "unreachable";
return;
}
if (!data.available) {
this._removeMarker();
this._status.textContent = "no fix";
return;
}
this._status.textContent = fixLabel(data);
const lngLat = [data.lon, data.lat];
if (!this._marker) {
const el = document.createElement("div");
el.className = "gnss-marker";
// MapLibre markers are plain positioned DOM elements, not tied to the
// style's sources/layers, so — unlike the radar/GPX layers — this
// survives map.setStyle() (switching Online/Offline) with no extra
// re-add handling needed.
this._marker = new Marker({ element: el }).setLngLat(lngLat).addTo(this._map);
} else {
this._marker.setLngLat(lngLat);
}
}
_removeMarker() {
if (this._marker) {
this._marker.remove();
this._marker = null;
}
}
}
function fixLabel({ mode, eph }) {
const kind = mode === 3 ? "3D" : mode === 2 ? "2D" : "no fix";
return eph ? `${kind} (±${Math.round(eph)}m)` : kind;
}
+63 -50
View File
@@ -1,4 +1,6 @@
import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs";
import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
const TREE_URL = "/api/gpx/tree";
const UPLOAD_URL = "/api/gpx";
@@ -11,10 +13,10 @@ const TRACK_COLOR = "#e63946";
// one — both land on the same drop targets, told apart by this.
const MOVE_MIME = "application/x-gpx-path";
// A MapLibre IControl: a "GPX" button that opens a side panel for
// uploading GPX files (via drag & drop, stored server-side in a folder
// hierarchy the user can add to and reorganize by dragging), and toggling
// their tracks/waypoints on and off the map.
// A sidebar icon that opens a side panel for uploading GPX files (via
// drag & drop, stored server-side in a folder hierarchy the user can add to
// and reorganize by dragging), and toggling their tracks/waypoints on and
// off the map.
export class GpxPanelControl {
constructor(map) {
this._map = map;
@@ -28,88 +30,74 @@ export class GpxPanelControl {
});
}
onAdd() {
this._button = document.createElement("div");
this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle";
this._button.innerHTML = `<button type="button">GPX</button>`;
this._button
.querySelector("button")
.addEventListener("click", () => this._togglePanel());
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="GPX" aria-label="GPX">${GPX_ICON}</button>`;
this._icon = this._container.querySelector(".sidebar-icon");
this._icon.addEventListener("click", () => this._togglePanel());
this._panel = document.getElementById("gpx-panel");
this._treeContainer = this._panel.querySelector(".gpx-tree");
this._uploadStatus = this._panel.querySelector(".gpx-upload__status");
this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON;
this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON;
this._panel
.querySelector(".gpx-panel__close")
.addEventListener("click", () => this._togglePanel(false));
this._setupDropzone();
this._setupNewFolderForm();
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
this._setupToolbarActions();
this._setupRootDropTarget();
this._setupMapDropTarget();
this._loadTree();
return this._button;
}
onRemove() {
this._button.remove();
sidebar.appendChild(this._container);
}
_togglePanel(force) {
this._panel.hidden = force === undefined ? !this._panel.hidden : !force;
const open = force === undefined ? this._panel.hidden : force;
this._panel.hidden = !open;
this._icon.classList.toggle("active", open);
if (open) notifySidePanelOpen(this._panel);
}
_setupDropzone() {
const dropzone = this._panel.querySelector(".gpx-dropzone");
const fileInput = this._panel.querySelector(".gpx-dropzone__input");
dropzone.addEventListener("click", () => fileInput.click());
// Both toolbar actions are icon buttons now (matching OsmAnd's tracks
// panel) rather than an always-visible dropzone/form: upload triggers the
// native file picker, new folder prompts for a path.
_setupToolbarActions() {
const fileInput = this._panel.querySelector(".gpx-file-input");
fileInput.addEventListener("change", () => {
this._uploadFiles(fileInput.files, "");
fileInput.value = "";
});
dropzone.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
dropzone.classList.add("gpx-drop-hover");
});
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("gpx-drop-hover"));
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
dropzone.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) this._uploadFiles(event.dataTransfer.files, "");
});
}
_setupNewFolderForm() {
const form = this._panel.querySelector(".gpx-new-folder");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const input = form.querySelector("input");
const path = input.value.trim();
if (!path) return;
this._panel.querySelector('[data-action="upload"]').addEventListener("click", () => fileInput.click());
this._panel.querySelector('[data-action="new-folder"]').addEventListener("click", async () => {
const path = window.prompt("New folder name (e.g. Hiking/2026):");
if (!path || !path.trim()) return;
const response = await fetch(FOLDER_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `path=${encodeURIComponent(path)}`,
body: `path=${encodeURIComponent(path.trim())}`,
});
if (response.ok) {
const { tree } = await response.json();
this._renderTree(tree);
input.value = "";
}
});
}
// Dropping a dragged file anywhere in the tree that isn't a folder (e.g.
// blank space, or onto another file) moves it back to the top level —
// folder drop targets call stopPropagation() so this only fires then.
// The tree itself is the drop target for both an OS file drag (upload to
// the top level) and, for anywhere that isn't a folder (blank space, or
// onto another file — folder drop targets call stopPropagation() so this
// only fires then), an internal file drag (move back to the top level).
_setupRootDropTarget() {
this._treeContainer.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes(MOVE_MIME)) return;
if (!event.dataTransfer.types.includes("Files") && !event.dataTransfer.types.includes(MOVE_MIME)) return;
event.preventDefault();
this._treeContainer.classList.add("gpx-drop-hover");
});
@@ -119,11 +107,36 @@ export class GpxPanelControl {
this._treeContainer.addEventListener("drop", (event) => {
event.preventDefault();
this._treeContainer.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) {
this._uploadFiles(event.dataTransfer.files, "");
return;
}
const from = event.dataTransfer.getData(MOVE_MIME);
if (from) this._moveFile(from, "");
});
}
// Dropping a GPX file straight onto the map (not just the panel) uploads
// it to the top level too.
_setupMapDropTarget() {
const mapContainer = this._map.getContainer();
mapContainer.addEventListener("dragover", (event) => {
if (!event.dataTransfer.types.includes("Files")) return;
event.preventDefault();
mapContainer.classList.add("gpx-drop-hover");
});
mapContainer.addEventListener("dragleave", (event) => {
if (event.target === mapContainer) mapContainer.classList.remove("gpx-drop-hover");
});
mapContainer.addEventListener("drop", (event) => {
mapContainer.classList.remove("gpx-drop-hover");
if (event.dataTransfer.files?.length > 0) {
event.preventDefault();
this._uploadFiles(event.dataTransfer.files, "");
}
});
}
async _loadTree() {
const response = await fetch(TREE_URL);
const tree = await response.json();
+39
View File
@@ -0,0 +1,39 @@
// Small hand-authored monoline SVG icons for the sidebar, so the app
// doesn't need to vendor an icon font/library for four glyphs.
export const LAYERS_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M12 3 2 9l10 6 10-6-10-6z"/>
<path d="M2 14l10 6 10-6"/>
</svg>`;
export const GPX_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="5" cy="6" r="2"/>
<circle cx="19" cy="18" r="2"/>
<path d="M6.5 7.5C10 11 8 13 12 13s2 5 6.5 5"/>
</svg>`;
export const RADAR_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M7 18a4 4 0 1 1 .7-7.94A5 5 0 0 1 17 9a4 4 0 0 1 0 8H7z"/>
</svg>`;
export const GNSS_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="12" cy="12" r="5.5"/>
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
</svg>`;
export const NEW_FOLDER_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
<path d="M12 11v5M9.5 13.5h5"/>
</svg>`;
export const UPLOAD_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 15V4M8 8l4-4 4 4"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
</svg>`;
+45 -23
View File
@@ -6,10 +6,13 @@ import {
} 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 { onlineStyle, offlineTheme } from "/js/osmand-style.js";
import { LAYERS_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.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);
@@ -17,7 +20,7 @@ addProtocol("pmtiles", protocol.tile);
// No label layers here (see docs/architecture.adoc): rendering text needs a
// glyphs server, which the offline style deliberately does not depend on.
function offlineStyle() {
const theme = protomaps_themes_base.namedTheme("light");
const theme = offlineTheme(protomaps_themes_base.namedTheme("light"));
const pmtilesUrl = `${window.location.origin}/tiles/offline.pmtiles`;
return {
@@ -33,31 +36,46 @@ function offlineStyle() {
};
}
// 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;
}
onAdd() {
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group layer-toggle";
this._container.innerHTML = `
<button type="button" data-style="online">Online</button>
<button type="button" data-style="offline">Offline</button>
`;
this._container.addEventListener("click", (event) => {
const button = event.target.closest("button[data-style]");
if (button) this._onSelect(button.dataset.style);
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Base map" aria-label="Base map">${LAYERS_ICON}</button>`;
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);
});
});
return this._container;
onlyOneSidePanelOpen(this._panel, () => this._icon.classList.remove("active"));
sidebar.appendChild(this._container);
}
onRemove() {
this._container.remove();
_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._container.querySelectorAll("button").forEach((button) => {
this._panel.querySelectorAll(".basemap-panel__option").forEach((button) => {
button.classList.toggle("active", button.dataset.style === name);
});
}
@@ -65,30 +83,34 @@ class LayerToggleControl {
const map = new MaplibreMap({
container: "map",
style: ONLINE_STYLE,
style: await onlineStyle(),
center: CENTER,
zoom: ZOOM,
});
map.addControl(new NavigationControl(), "top-right");
map.addControl(new NavigationControl(), "bottom-right");
map.addControl(
new GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showAccuracyCircle: true,
}),
"top-right",
"bottom-right",
);
map.addControl(new RadarControl(map), "top-right");
const sidebar = document.getElementById("sidebar");
const layerToggle = new LayerToggleControl(applyStyle);
map.addControl(layerToggle, "top-left");
layerToggle.mount(sidebar);
layerToggle.setActive("online");
map.addControl(new GpxPanelControl(map), "top-left");
function applyStyle(name) {
new GpxPanelControl(map).mount(sidebar);
new RadarControl(map).mount(sidebar);
new GnssControl(map).mount(sidebar);
async function applyStyle(name) {
layerToggle.setActive(name);
map.setStyle(name === "offline" ? offlineStyle() : ONLINE_STYLE);
map.setStyle(name === "offline" ? offlineStyle() : await onlineStyle());
}
// If a local PMTiles file is present, prefer it as the default style — its
+160
View File
@@ -0,0 +1,160 @@
// Approximates the look of OsmAnd's own map render (osmand.net/map — muted
// earth tones, cream background, orange roads) without depending on their
// tile server: OsmAnd's terms restrict tile use to "personal, non-commercial
// use within OsmAnd applications and platforms", which this app isn't.
//
// One shared palette drives two independent recolorings so Online and
// Offline look consistent: OpenFreeMap's "liberty" vector style (paint
// colors patched in place, see recolorLayer) for Online, and a custom
// protomaps-themes-base Theme (see offlineTheme) for Offline.
const PALETTE = {
background: "#f1ecd7",
water: "#aad3df",
wood: "#c8dfb0",
park: "#d3e8b8",
landuse: "#ece4c6",
building: "#d9cead",
buildingOutline: "#c7bb92",
roadMajor: "#eb9d43",
roadMajorCasing: "#c9822c",
roadMinor: "#fdf8ee",
roadMinorCasing: "#cfc09a",
boundary: "#a98fc2",
};
const LIBERTY_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
let cachedOnlineStyle = null;
// Fetches OpenFreeMap's "liberty" style and recolors it in place. Cached —
// the recoloring is pure, and re-fetching on every Online/Offline toggle
// would be wasteful.
export async function onlineStyle() {
if (cachedOnlineStyle) return cachedOnlineStyle;
const response = await fetch(LIBERTY_STYLE_URL);
const style = await response.json();
style.layers = style.layers.map(recolorLayer);
cachedOnlineStyle = style;
return style;
}
// Overrides the color roles that define the "look" on top of a
// protomaps-themes-base Theme (e.g. namedTheme("light")), leaving roles it
// doesn't set (labels, rail, tunnels, POI colors) at their defaults.
export function offlineTheme(baseTheme) {
return {
...baseTheme,
background: PALETTE.background,
earth: PALETTE.background,
water: PALETTE.water,
wood_a: PALETTE.wood,
wood_b: PALETTE.wood,
park_a: PALETTE.park,
park_b: PALETTE.park,
scrub_a: PALETTE.park,
scrub_b: PALETTE.park,
pedestrian: PALETTE.landuse,
industrial: PALETTE.landuse,
school: PALETTE.landuse,
hospital: PALETTE.landuse,
buildings: PALETTE.building,
highway_casing_early: PALETTE.roadMajorCasing,
highway_casing_late: PALETTE.roadMajorCasing,
highway: PALETTE.roadMajor,
major_casing_early: PALETTE.roadMajorCasing,
major_casing_late: PALETTE.roadMajorCasing,
major: PALETTE.roadMajor,
link_casing: PALETTE.roadMajorCasing,
link: PALETTE.roadMajor,
minor_casing: PALETTE.roadMinorCasing,
minor_service_casing: PALETTE.roadMinorCasing,
minor_a: PALETTE.roadMinor,
minor_b: PALETTE.roadMinor,
other: PALETTE.roadMinor,
minor_service: PALETTE.roadMinor,
boundaries: PALETTE.boundary,
};
}
const FILL_PROP = /^(background-color|fill-color|line-color|fill-extrusion-color)$/;
const OUTLINE_PROP = /outline-color$/;
const FILL_COLOR = {
background: PALETTE.background,
water: PALETTE.water,
wood: PALETTE.wood,
park: PALETTE.park,
landuse: PALETTE.landuse,
building: PALETTE.building,
roadMajor: PALETTE.roadMajor,
roadMinor: PALETTE.roadMinor,
boundary: PALETTE.boundary,
};
const OUTLINE_COLOR = {
building: PALETTE.buildingOutline,
roadMajor: PALETTE.roadMajorCasing,
roadMinor: PALETTE.roadMinorCasing,
park: "#bcdb9a",
};
// Maps a "liberty" style layer id to one of the categories above, based on
// the id naming conventions OpenFreeMap's style uses (verified against its
// actual layer list — see docs/architecture.adoc). Returns null for
// anything left at its original color: labels/icons (all "symbol" layers,
// filtered by type below before this even runs), railways, and minor
// terrain fills (ice/wetland/sand) that aren't part of OsmAnd's signature
// look.
function categoryFor(layerId) {
if (layerId === "background") return "background";
if (/rail/.test(layerId)) return null;
if (/^water/.test(layerId)) return "water";
if (/^landcover_wood/.test(layerId)) return "wood";
if (/^(park|landcover_grass)/.test(layerId)) return "park";
if (/^landuse/.test(layerId)) return "landuse";
if (/^building/.test(layerId)) return "building";
if (/^(road|bridge|tunnel)_(motorway|trunk_primary)/.test(layerId)) return "roadMajor";
if (/^(road|bridge|tunnel)_/.test(layerId)) return "roadMinor";
if (/^boundary/.test(layerId)) return "boundary";
return null;
}
const RECOLORABLE_TYPES = new Set(["background", "fill", "line", "fill-extrusion"]);
function recolorLayer(layer) {
if (!RECOLORABLE_TYPES.has(layer.type) || !layer.paint) return layer;
const category = categoryFor(layer.id);
if (!category) return layer;
const paint = { ...layer.paint };
for (const [prop, value] of Object.entries(paint)) {
if (OUTLINE_PROP.test(prop) && OUTLINE_COLOR[category]) {
paint[prop] = replaceColors(value, OUTLINE_COLOR[category]);
} else if (FILL_PROP.test(prop)) {
paint[prop] = replaceColors(value, FILL_COLOR[category]);
}
}
return { ...layer, paint };
}
const COLOR_PATTERN = /^#[0-9a-f]{3,8}$/i;
const COLOR_FN_PATTERN = /^(rgb|hsl)a?\(/i;
// Walks a paint value — a flat color string, or a MapLibre expression like
// ["interpolate", ["linear"], ["zoom"], 5, "hsl(26,87%,62%)", 6, "#fc8"] —
// and swaps every color-shaped string for `newColor`, leaving operators,
// zoom/get references and numbers untouched. Generic across expression
// types (interpolate/match/case/step/...) since it never inspects the
// expression's operator, only whether each leaf looks like a color.
function replaceColors(value, newColor) {
if (typeof value === "string") {
return COLOR_PATTERN.test(value) || COLOR_FN_PATTERN.test(value) ? newColor : value;
}
if (Array.isArray(value)) {
return value.map((item) => replaceColors(item, newColor));
}
return value;
}
+17
View File
@@ -0,0 +1,17 @@
// Keeps at most one full-height side panel (base map, GPX, ...) open at a
// time — they all dock to the same spot next to the sidebar, so two open
// together would just stack on top of each other.
const OPEN_EVENT = "side-panel-open";
export function notifySidePanelOpen(panel) {
document.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: panel }));
}
export function onlyOneSidePanelOpen(panel, onClosedByOther) {
document.addEventListener(OPEN_EVENT, (event) => {
if (event.detail !== panel && !panel.hidden) {
panel.hidden = true;
onClosedByOther();
}
});
}
+27 -31
View File
@@ -1,3 +1,5 @@
import { RADAR_ICON } from "/js/icons.js";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
// Matches the default window used by github.com/jpettitt/weather-radar-card
@@ -9,10 +11,10 @@ const OPACITY = 0.7;
const ATTRIBUTION =
'Weather data &copy; <a href="https://www.rainviewer.com" target="_blank">RainViewer</a>';
// A MapLibre IControl: a "Radar" checkbox that fetches RainViewer's recent
// precipitation frames on demand and animates them as a raster overlay, with
// play/pause and a scrub slider. No API key needed — see
// docs/architecture.adoc for RainViewer's usage terms.
// A sidebar icon that fetches RainViewer's recent precipitation frames on
// demand and animates them as a raster overlay, with play/pause and a scrub
// slider in a flyout. No API key needed — see docs/architecture.adoc for
// RainViewer's usage terms.
export class RadarControl {
constructor(map) {
this._map = map;
@@ -29,45 +31,39 @@ export class RadarControl {
});
}
onAdd() {
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "maplibregl-ctrl maplibregl-ctrl-group radar-control";
this._container.className = "sidebar-item";
this._container.innerHTML = `
<label class="radar-control__toggle">
<input type="checkbox" class="radar-control__checkbox">
Radar
</label>
<div class="radar-control__playback" hidden>
<button type="button" class="radar-control__play" aria-label="Play">&#9654;</button>
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
<span class="radar-control__time"></span>
<button type="button" class="sidebar-icon" title="Radar" aria-label="Radar">${RADAR_ICON}</button>
<div class="sidebar-flyout" hidden>
<div class="radar-control__playback">
<button type="button" class="radar-control__play" aria-label="Play">&#9654;</button>
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
<span class="radar-control__time"></span>
</div>
</div>
`;
this._checkbox = this._container.querySelector(".radar-control__checkbox");
this._playback = this._container.querySelector(".radar-control__playback");
this._icon = this._container.querySelector(".sidebar-icon");
this._flyout = this._container.querySelector(".sidebar-flyout");
this._playButton = this._container.querySelector(".radar-control__play");
this._scrub = this._container.querySelector(".radar-control__scrub");
this._time = this._container.querySelector(".radar-control__time");
this._checkbox.addEventListener("change", () => this._setEnabled(this._checkbox.checked));
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
this._playButton.addEventListener("click", () => this._togglePlayback());
this._scrub.addEventListener("input", () => {
this._stopAnimation();
this._showFrame(Number(this._scrub.value));
});
return this._container;
}
onRemove() {
this._stopAnimation();
this._removeLayers();
this._container.remove();
sidebar.appendChild(this._container);
}
async _setEnabled(enabled) {
this._enabled = enabled;
this._icon.classList.toggle("active", enabled);
if (!enabled) {
this._stopAnimation();
this._removeLayers();
@@ -76,20 +72,20 @@ export class RadarControl {
return;
}
this._checkbox.disabled = true;
this._icon.disabled = true;
let frames;
try {
frames = await fetchFrames();
} catch (err) {
this._enabled = false;
this._checkbox.checked = false;
this._checkbox.disabled = false;
this._icon.classList.remove("active");
this._icon.disabled = false;
return;
}
this._checkbox.disabled = false;
this._icon.disabled = false;
// The checkbox may have been switched off again while the fetch was in
// flight.
// The icon may have been clicked again (disabling) while the fetch was
// in flight.
if (!this._enabled || !frames.length) return;
this._frames = frames;
@@ -168,7 +164,7 @@ export class RadarControl {
}
_updateFrameUI() {
this._playback.hidden = this._frames.length === 0;
this._flyout.hidden = this._frames.length === 0;
this._scrub.max = String(Math.max(0, this._frames.length - 1));
this._scrub.value = String(this._frameIndex);
const frame = this._frames[this._frameIndex];