Compare commits

..
7 Commits
Author SHA1 Message Date
randy 5caf865fc8 Cache radar tile images with a Service Worker
CI / deploy (push) Successful in 12s
CI / check (push) Successful in 40s
public/sw.js: network-falling-back-to-cache for RainViewer radar tile
requests specifically (nothing else — the app itself is served by the
same local server that's already the offline resource in the van, so
there's no app-shell to precache). Registered from layout.erb, a no-op
where it can't register (insecure context) rather than an error.

Verified: registers and controls the page, tiles get cached on a
normal fetch, cache.match() correctly retrieves them by the same
request. CDP's network-blocking simulation doesn't interact cleanly
with Service Worker fetch interception (a known tooling quirk, not
specific to this), so the actual offline fallback path is the
standard try/catch pattern, verified by code review rather than a
simulated-offline screenshot.
2026-09-09 09:51:29 +02:00
randy a98b8dbd47 Mark GPX drag-and-drop import as done in TODO 2026-09-09 09:47:14 +02:00
randy 8d090b0774 Cache the weather forecast for offline fallback
Radar flyout now also shows a 3-day Open-Meteo forecast (free, no API
key) for the map's current center. Radar frames and the forecast fetch
independently — one being unreachable doesn't hide the other. The last
successful forecast is cached in localStorage and shown (with a
"cached from" note) when the live fetch fails, so there's still
something useful once actually offline.

Radar tile images themselves aren't cached — that needs a Service
Worker, left as a separate follow-up (see TODO.adoc).
2026-09-09 09:42:10 +02:00
randy 707e71caf1 Add elevation-tinted terrain (hypsometric color-relief layer)
Reuses the same raster-dem source the hillshade toggle already added
— a color-relief layer tints by elevation band (green valleys through
brown mid-slopes to pale grey peaks, in this app's own palette) under
the hillshade layer, so one toggle gives the combined shaded-relief
look instead of two separate layers.
2026-09-09 09:33:00 +02:00
randy 77a4c96b1c Add a hillshade/terrain-shading toggle to the map
Sidebar toggle (new HillshadeControl) adds a raster-dem hillshade
layer: public Terrarium tiles when online, or a local
data/terrain.pmtiles when present (mirrors offline.pmtiles' optional-
local-file pattern, new /api/terrain-tiles + /tiles/terrain.pmtiles
endpoints). No offline terrain data is bundled, so the toggle is
online-only until one is generated — see docs/development.adoc for
how.
2026-09-09 09:27:17 +02:00
randy f49e44800a Distinguish unpaved tracks from paved roads on both basemaps
Offline: fill in protomaps' "other" road-kind color (unset before,
so tracks/paths silently fell back to plain white against the cream
background); also fixes a dead "minor" theme key that was never read
by the actual paint expression (only minor_a/minor_b is), so ordinary
streets were quietly using the base theme's default yellow instead of
this app's cream.

Online: Liberty's own road_service_track layer merges paved service
roads with unpaved tracks into one color, which isn't safe to just
recolor brown (real driveways aren't unpaved). Clones it into a
second layer scoped to class=="track" only, dashed and dirt-brown,
matching the offline treatment without mislabeling paved service
roads.
2026-09-09 09:17:00 +02:00
randy 8ca5ca9bc1 Add an offline-data status dot to the topbar
Small green/gray indicator reusing the existing /api/offline-tiles
check, showing at a glance whether this server has offline map data.
Closes out the top banner TODO item — DEM elevation and a hamburger
menu are intentionally left out (no DEM layer yet, and the sidebar
isn't mobile-collapsed).
2026-09-09 09:11:52 +02:00
10 changed files with 448 additions and 26 deletions

No files matched your search

+9 -7
View File
@@ -9,24 +9,26 @@ independence from connectivity.
== Offline data and cartography == Offline data and cartography
* [ ] Offline vector tiles (PMTiles/MBTiles) for Europe/world via Planetiler or MapTiler * [ ] Offline vector tiles (PMTiles/MBTiles) for Europe/world via Planetiler or MapTiler
* [ ] Shaded relief / DEM layer (terrain slope for parking) * [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now
* [ ] Hillshade & contour lines from OSM for hikes and mountain passes ** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes
* [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) * [ ] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs)
* [ ] Import Park4Night / iOverlander / Freecampsites data * [ ] Import Park4Night / iOverlander / Freecampsites data
* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates * [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates
* [ ] Cache weather and precipitation radar when temporarily connected (4G/5G/Wi-Fi) * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline
** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
== NPS visual style == NPS visual style
* [ ] Map Park4Night/OSM place types to NPS pictograms (campground, primitive camp, drinking water, dump station, viewpoint, picnic table) for instant recognition without reading text * [ ] Map Park4Night/OSM place types to NPS pictograms (campground, primitive camp, drinking water, dump station, viewpoint, picnic table) for instant recognition without reading text
* [ ] Restyle popovers, side panels, and point detail views after the NPS Unigrid layout system (Massimo Vignelli) with Frutiger/Rawlinson Roadside-style typography * [ ] Restyle popovers, side panels, and point detail views after the NPS Unigrid layout system (Massimo Vignelli) with Frutiger/Rawlinson Roadside-style typography
** [ ] Top black banner (full width): NPS-style logo + "MAPTOOL" on the left, location telemetry (DMS/DD coordinates, DEM elevation) in the center, hamburger menu + offline-data status on the right, white sans-serif type ** [x] Top black banner (full width): "MAPTOOL" on the left, live map-center coordinates in the center, offline-data status dot on the right, white sans-serif type — DEM elevation and a hamburger menu skipped (no DEM layer yet; sidebar isn't mobile-collapsed, so nothing for a hamburger to reveal)
** [ ] Point detail/sidepanel: black header banner with white location name and an NPS pictogram in the corner; amenities (water, waste, power, slope) shown as a pictogram grid, no extra text; action buttons for "route here" (Valhalla), "start leveling", "save offline" ** [ ] Point detail/sidepanel: black header banner with white location name and an NPS pictogram in the corner; amenities (water, waste, power, slope) shown as a pictogram grid, no extra text; action buttons for "route here" (Valhalla), "start leveling", "save offline"
** [x] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns ** [x] Filter panel as a "Unigrid Matrix" — an icon grid to toggle categories on/off, styled like the legend on an official NPS paper map, instead of plain dropdowns
* [ ] Custom vector style (MVT/MapLibre) using the NPS color palette — muted natural tones for forests, water, and contours — so POIs and routes stand out at high zoom * [ ] Custom vector style (MVT/MapLibre) using the NPS color palette — muted natural tones for forests, water, and contours — so POIs and routes stand out at high zoom
** [ ] Landuse & protections: distinct styling for national parks, nature reserves (translucent green/gray-green), military zones, and private land (important for wild camping) ** [ ] Landuse & protections: distinct styling for national parks, nature reserves (translucent green/gray-green), military zones, and private land (important for wild camping)
** [ ] Elevation-tinted terrain combined with offline hillshade for a characteristic relief look ** [x] Elevation-tinted terrain — a `color-relief` layer (hypsometric tint, this app's own green→brown→gray palette) under the hillshade layer, same "Terrain shading" toggle
** [ ] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there ** [x] Road hierarchy: clearly distinguish paved roads from unpaved/gravel/track — critical for knowing if the camper can get there
== Main menu reorganization (4 modules) == Main menu reorganization (4 modules)
@@ -37,7 +39,7 @@ independence from connectivity.
== Custom layers & data == Custom layers & data
* [ ] GPX/KML/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks), auto-styled in the NPS visual language with a generated elevation profile * [x] GPX/GeoJSON drag-and-drop import (Wikiloc, Komoot, own GPX tracks — also FIT), auto-styled by category with a generated elevation profile — KML not supported (only GPX/FIT/GeoJSON/.zip)
* [ ] User overlay: offline drawing of custom points/polygons with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot") * [ ] User overlay: offline drawing of custom points/polygons with notes (e.g. "turning space for a long vehicle", "narrow gate", "good swimming spot")
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
+14
View File
@@ -58,6 +58,7 @@ module Maptool
DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") } DATA_DIR = ENV.fetch("MAPTOOL_DATA_DIR") { File.join(__dir__, "data") }
OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles") OFFLINE_PMTILES_PATH = File.join(DATA_DIR, "offline.pmtiles")
TERRAIN_PMTILES_PATH = File.join(DATA_DIR, "terrain.pmtiles")
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx")) GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json")) LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
@@ -185,6 +186,19 @@ module Maptool
send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream"
end end
# Same optional-local-file story as offline.pmtiles above — see
# docs/development.adoc for how to generate one, until then hillshade
# just stays unavailable while the offline basemap is active.
get "/api/terrain-tiles" do
json available: File.exist?(TERRAIN_PMTILES_PATH)
end
get "/tiles/terrain.pmtiles" do
halt 404 unless File.exist?(TERRAIN_PMTILES_PATH)
send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream"
end
get "/api/gpx/tree" do get "/api/gpx/tree" do
json GPX_STORE.tree json GPX_STORE.tree
end end
+16
View File
@@ -43,6 +43,22 @@ build one locally:
Once the file is in place, `GET /api/offline-tiles` reports Once the file is in place, `GET /api/offline-tiles` reports
`available: true` and the map defaults to the offline style. `available: true` and the map defaults to the offline style.
== Offline terrain data (hillshade)
The sidebar's "Terrain shading" toggle works online out of the box
(public https://github.com/tilezen/joerd[Terrarium] elevation tiles,
no API key). For it to work offline too, place a PMTiles archive of
Terrarium-encoded `raster-dem` tiles at `data/terrain.pmtiles`
(`GET /api/terrain-tiles` reports `available: true` once it's there,
same pattern as offline map data above). It is not part of the
repository — build one locally from a DEM source (e.g. Copernicus
GLO-30 or SRTM) with a tool like
https://github.com/mapbox/rio-rgbify[rio-rgbify] (GeoTIFF ->
Terrarium-encoded PNG tiles in an `.mbtiles`), then
`pmtiles convert out.mbtiles data/terrain.pmtiles` with
https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
`data/offline.pmtiles`.
== GNSS position (gpsd) == GNSS position (gpsd)
The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at
+42
View File
@@ -34,6 +34,18 @@ html, body {
color: #bbb; color: #bbb;
} }
.topbar__offline-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.topbar__offline-status--ready {
background: #2a9d8f;
}
.topbar__auth { .topbar__auth {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -181,6 +193,36 @@ html, body {
white-space: nowrap; white-space: nowrap;
} }
.radar-control__forecast {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid #eee;
}
.radar-control__forecast-day {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0;
white-space: nowrap;
}
.radar-control__forecast-day span:first-child {
width: 2.2em;
color: #888;
}
.radar-control__forecast-day span:nth-child(3) {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.radar-control__forecast-stale {
margin: 4px 0 0;
color: #888;
font-size: 11px;
}
.basemap-panel { .basemap-panel {
position: fixed; position: fixed;
top: 0; top: 0;
+6
View File
@@ -7,6 +7,12 @@ export const LAYERS_ICON = `
<path d="M2 14l10 6 10-6"/> <path d="M2 14l10 6 10-6"/>
</svg>`; </svg>`;
export const HILLSHADE_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
<path d="M2 18 9 7l3.5 5.5L15 9l7 9z"/>
<path d="M2 18h20"/>
</svg>`;
export const GPX_ICON = ` export const GPX_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"> <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="5" cy="6" r="2"/>
+146 -2
View File
@@ -11,7 +11,7 @@ import { LiveTrackPanelControl } from "/js/livetrack.js";
import { TrackProfilePanel } from "/js/track_profile.js"; import { TrackProfilePanel } from "/js/track_profile.js";
import { MeasureControl } from "/js/measure.js"; import { MeasureControl } from "/js/measure.js";
import { LocateControl } from "/js/locate.js"; import { LocateControl } from "/js/locate.js";
import { LAYERS_ICON } from "/js/icons.js"; import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js";
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
import OverviewMap from "/vendor/npmap-overview/overview.esm.js"; import OverviewMap from "/vendor/npmap-overview/overview.esm.js";
@@ -104,6 +104,32 @@ function patchOnlineStyle(style) {
const patch = ONLINE_STYLE_PATCH[layer.id]; const patch = ONLINE_STYLE_PATCH[layer.id];
if (patch) Object.assign((layer.paint ??= {}), patch); 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; return style;
} }
@@ -181,8 +207,21 @@ const OFFLINE_THEME = {
pedestrian: "#e9e2d8", pedestrian: "#e9e2d8",
highway: "#fdefbe", // real: Park Tiles' primary/highway road color highway: "#fdefbe", // real: Park Tiles' primary/highway road color
major: "#fef7dd", // real: Park Tiles' secondary road color major: "#fef7dd", // real: Park Tiles' secondary road color
minor: "#fffdf8", // "minor" (singular) is never actually read by protomaps' own paint
// expressions — only the zoom-interpolated minor_a/minor_b pair is, so a
// plain "minor" key here was dead: ordinary streets were silently
// falling back to the base theme's default pale yellow instead of this
// app's cream.
minor_a: "#fffdf8",
minor_b: "#fffdf8",
link: "#fef7dd", link: "#fef7dd",
// Distinct brown, not cream: protomaps' "other"/"path" kind is what a
// camper actually needs called out — unpaved tracks and footpaths,
// separate from paved minor_road/service (minor_service, left at the
// base theme's default since it's real driveways/parking aisles, not
// unpaved). Already rendered dashed by the theme itself; this just picks
// a real "dirt" tone instead of unstyled white.
other: "#c9a66b",
boundaries: "#546619", // real: Park Tiles' park-boundary line color boundaries: "#546619", // real: Park Tiles' park-boundary line color
}; };
@@ -266,6 +305,100 @@ class LayerToggleControl {
} }
} }
const HILLSHADE_ID = "hillshade-dem";
const COLOR_RELIEF_ID = "elevation-tint";
// Hypsometric tint reusing this app's own palette (wood_a's green at
// valley floor, the road casing brown through the mid-slopes) rather than
// a generic rainbow ramp, so it reads as part of the same map rather than
// a bolted-on overlay. Stops picked for Central European terrain — the
// Tatras top out around 2600m — not a general-purpose world ramp.
const ELEVATION_COLOR_RAMP = [
"interpolate",
["linear"],
["raster-value"],
0, "#c3e8ab",
400, "#dcd08f",
900, "#c9a66b",
1600, "#a89a86",
2600, "#f9f6ef",
];
// Public, unauthenticated, no API key — the same open (SRTM/ArcticDEM-derived)
// elevation tiles OpenStreetMap's own hillshade layer and many other free
// tools use. Only reachable online; while the offline basemap is active this
// is skipped in favor of a local terrain.pmtiles if one's been generated
// (see docs/development.adoc), same optional-local-file story as
// offline.pmtiles itself.
const ONLINE_TERRAIN_TILES = ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"];
// A simple on/off sidebar toggle, not a panel — there's nothing to choose,
// just whether the shading shows.
class HillshadeControl {
constructor(map) {
this._map = map;
this._active = false;
this._offlineAvailable = false;
// setStyle() (switching Online/Offline) tears down all custom
// sources/layers — put hillshade back if it was on.
map.on("style.load", () => {
if (this._active) this._addLayer();
});
}
mount(sidebar) {
this._container = document.createElement("div");
this._container.className = "sidebar-item";
this._container.innerHTML = `<button type="button" class="sidebar-icon" title="Terrain shading" aria-label="Terrain shading" aria-pressed="false">${HILLSHADE_ICON}</button>`;
this._button = this._container.querySelector(".sidebar-icon");
this._button.addEventListener("click", () => this._toggle());
sidebar.appendChild(this._container);
}
setOfflineAvailable(available) {
this._offlineAvailable = available;
}
_toggle() {
this._active = !this._active;
this._button.classList.toggle("active", this._active);
this._button.setAttribute("aria-pressed", String(this._active));
if (this._active) this._addLayer();
else this._removeLayer();
}
_addLayer() {
if (this._map.getSource(HILLSHADE_ID)) return;
const source = this._offlineAvailable
? { type: "raster-dem", url: `pmtiles://${window.location.origin}/tiles/terrain.pmtiles`, encoding: "terrarium" }
: {
type: "raster-dem",
tiles: ONLINE_TERRAIN_TILES,
tileSize: 256,
encoding: "terrarium",
maxzoom: 15,
attribution: "Terrain tiles: Mapzen, SRTM, ArcticDEM",
};
this._map.addSource(HILLSHADE_ID, source);
// Elevation tint under, hillshade on top: the tint sets the base color
// per elevation band, hillshade multiplies slope-shading over it — the
// combined "characteristic relief look" the TODO asked for, not two
// independent layers.
this._map.addLayer({
id: COLOR_RELIEF_ID,
type: "color-relief",
source: HILLSHADE_ID,
paint: { "color-relief-color": ELEVATION_COLOR_RAMP, "color-relief-opacity": 0.7 },
});
this._map.addLayer({ id: HILLSHADE_ID, type: "hillshade", source: HILLSHADE_ID, paint: { "hillshade-exaggeration": 0.4 } });
}
_removeLayer() {
if (this._map.getLayer(HILLSHADE_ID)) this._map.removeLayer(HILLSHADE_ID);
if (this._map.getLayer(COLOR_RELIEF_ID)) this._map.removeLayer(COLOR_RELIEF_ID);
if (this._map.getSource(HILLSHADE_ID)) this._map.removeSource(HILLSHADE_ID);
}
}
// A small map-corner button (grouped with Home/zoom, see below) that's a // A small map-corner button (grouped with Home/zoom, see below) that's a
// second entry point to the same panel LayerToggleControl already manages // second entry point to the same panel LayerToggleControl already manages
// in the page's own sidebar — matching the National Park Service's own // in the page's own sidebar — matching the National Park Service's own
@@ -415,6 +548,8 @@ profilePanel.mount();
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`); sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Explorer"></div>`);
new GpxPanelControl(map, profilePanel).mount(sidebar); new GpxPanelControl(map, profilePanel).mount(sidebar);
const hillshadeControl = new HillshadeControl(map);
hillshadeControl.mount(sidebar);
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`); sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
layerToggle.mount(sidebar); layerToggle.mount(sidebar);
@@ -442,6 +577,7 @@ function applyStyle(name) {
// the Online/Offline switch itself doesn't make sense (there's nothing to // 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 // switch to), so it's removed entirely rather than left sitting there
// offering a choice that can't work. // offering a choice that can't work.
const offlineStatus = document.querySelector(".topbar__offline-status");
fetch("/api/offline-tiles") fetch("/api/offline-tiles")
.then((response) => response.json()) .then((response) => response.json())
.then(({ available }) => { .then(({ available }) => {
@@ -451,6 +587,9 @@ fetch("/api/offline-tiles")
layerToggle.remove(); layerToggle.remove();
map.removeControl(mapLayersButton); map.removeControl(mapLayersButton);
} }
offlineStatus.hidden = false;
offlineStatus.classList.toggle("topbar__offline-status--ready", available);
offlineStatus.title = available ? "Offline map data ready" : "No offline map data on this server";
}) })
.catch(() => { .catch(() => {
// Backend check failed; keep the online style and leave the switch as // Backend check failed; keep the online style and leave the switch as
@@ -458,6 +597,11 @@ fetch("/api/offline-tiles")
// failing, not evidence offline tiles are actually unavailable. // failing, not evidence offline tiles are actually unavailable.
}); });
fetch("/api/terrain-tiles")
.then((response) => response.json())
.then(({ available }) => hillshadeControl.setOfflineAvailable(available))
.catch(() => {});
// Alt+click anywhere shows every rendered feature at that point — base map // Alt+click anywhere shows every rendered feature at that point — base map
// (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it — // (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it —
// with its raw properties, inspired by NPMap4's infoboxControl (a generic // with its raw properties, inspired by NPMap4's infoboxControl (a generic
+141 -17
View File
@@ -2,6 +2,34 @@ import { RADAR_ICON } from "/js/icons.js";
const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json"; const RAINVIEWER_API = "https://api.rainviewer.com/public/weather-maps.json";
// Open-Meteo: free, no API key. Only the forecast (small JSON) is cached
// here, in localStorage — the radar tile images above aren't: actually
// serving those without a live network needs a Service Worker (intercepting
// the tile requests and answering from a cache), which is its own separate
// piece of infrastructure (registration, cache strategy, invalidation) and
// deliberately left for later rather than folded into this. A cached
// forecast still gives *something* useful once genuinely offline, which the
// radar animation above can't.
const FORECAST_API = "https://api.open-meteo.com/v1/forecast";
const FORECAST_DAYS = 3;
const FORECAST_CACHE_KEY = "maptool:forecast";
// A small subset of WMO weather codes (open-meteo's `weather_code`) mapped
// to one glyph each — not the full table, just enough to tell clear/cloudy/
// rain/snow/storm apart at a glance.
const WEATHER_EMOJI = [
[[0], "☀️"],
[[1, 2, 3], "⛅"],
[[45, 48], "🌫️"],
[[51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82], "🌧️"],
[[71, 73, 75, 77, 85, 86], "❄️"],
[[95, 96, 99], "⛈️"],
];
function weatherEmoji(code) {
return WEATHER_EMOJI.find(([codes]) => codes.includes(code))?.[1] ?? "🌡️";
}
// Matches the default window used by github.com/jpettitt/weather-radar-card // Matches the default window used by github.com/jpettitt/weather-radar-card
// (60 minutes of history at RainViewer's native 10-minute cadence = 7 // (60 minutes of history at RainViewer's native 10-minute cadence = 7
// frames, the last one being "now"). // frames, the last one being "now").
@@ -42,14 +70,17 @@ export class RadarControl {
<input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0"> <input type="range" class="radar-control__scrub" min="0" max="0" step="1" value="0">
<span class="radar-control__time"></span> <span class="radar-control__time"></span>
</div> </div>
<div class="radar-control__forecast"></div>
</div> </div>
`; `;
this._icon = this._container.querySelector(".sidebar-icon"); this._icon = this._container.querySelector(".sidebar-icon");
this._flyout = this._container.querySelector(".sidebar-flyout"); this._flyout = this._container.querySelector(".sidebar-flyout");
this._playback = this._container.querySelector(".radar-control__playback");
this._playButton = this._container.querySelector(".radar-control__play"); this._playButton = this._container.querySelector(".radar-control__play");
this._scrub = this._container.querySelector(".radar-control__scrub"); this._scrub = this._container.querySelector(".radar-control__scrub");
this._time = this._container.querySelector(".radar-control__time"); this._time = this._container.querySelector(".radar-control__time");
this._forecastEl = this._container.querySelector(".radar-control__forecast");
this._icon.addEventListener("click", () => this._setEnabled(!this._enabled)); this._icon.addEventListener("click", () => this._setEnabled(!this._enabled));
this._playButton.addEventListener("click", () => this._togglePlayback()); this._playButton.addEventListener("click", () => this._togglePlayback());
@@ -69,30 +100,87 @@ export class RadarControl {
this._removeLayers(); this._removeLayers();
this._frames = []; this._frames = [];
this._updateFrameUI(); this._updateFrameUI();
this._renderForecast(null);
return; return;
} }
this._icon.disabled = true; this._icon.disabled = true;
let frames; // Independent fetches: RainViewer being unreachable shouldn't hide a
try { // forecast that Open-Meteo still answered, or vice versa — especially
frames = await fetchFrames(); // since only the forecast has a cached fallback to fall back to.
} catch (err) { const [framesResult, forecastResult] = await Promise.allSettled([fetchFrames(), this._loadForecast()]);
this._enabled = false;
this._icon.classList.remove("active");
this._icon.disabled = false;
return;
}
this._icon.disabled = false; this._icon.disabled = false;
// The icon may have been clicked again (disabling) while the fetch was // The icon may have been clicked again (disabling) while the fetches
// in flight. // were in flight.
if (!this._enabled || !frames.length) return; if (!this._enabled) return;
const frames = framesResult.status === "fulfilled" ? framesResult.value : [];
const forecast = forecastResult.status === "fulfilled" ? forecastResult.value : null;
// Nothing at all came back (both offline, no cached forecast either) —
// same "turn back off" story the radar-only fetch used to have on its
// own failure.
if (frames.length === 0 && !forecast) {
this._enabled = false;
this._icon.classList.remove("active");
} else if (frames.length > 0) {
this._frames = frames;
this._frameIndex = frames.length - 1;
this._addLayers();
this._startAnimation();
}
this._frames = frames;
this._frameIndex = frames.length - 1;
this._addLayers();
this._updateFrameUI(); this._updateFrameUI();
this._startAnimation(); this._renderForecast(forecast);
}
// cachedAt is null for a fresh fetch, or the cache's own timestamp when
// falling back to it — _renderForecast uses that to show a "cached" note
// instead of claiming this is live data.
async _loadForecast() {
const { lat, lng } = this._map.getCenter();
try {
const data = await fetchForecast(lat, lng);
writeCachedForecast(data);
return { data, cachedAt: null };
} catch (err) {
const cached = readCachedForecast();
if (!cached) throw err;
return cached;
}
}
_renderForecast(result) {
if (!result) {
this._forecastEl.innerHTML = "";
return;
}
const { data, cachedAt } = result;
const days = data.daily.time.map((date, i) => ({
date,
code: data.daily.weather_code[i],
max: Math.round(data.daily.temperature_2m_max[i]),
min: Math.round(data.daily.temperature_2m_min[i]),
precip: data.daily.precipitation_probability_max[i],
}));
const rows = days
.map(
(day) => `
<div class="radar-control__forecast-day">
<span>${new Date(day.date).toLocaleDateString([], { weekday: "short" })}</span>
<span>${weatherEmoji(day.code)}</span>
<span>${day.max}° / ${day.min}°</span>
<span>${day.precip}%</span>
</div>`,
)
.join("");
const staleNote = cachedAt
? `<p class="radar-control__forecast-stale">Cached from ${new Date(cachedAt).toLocaleString([], { dateStyle: "short", timeStyle: "short" })}</p>`
: "";
this._forecastEl.innerHTML = rows + staleNote;
} }
_addLayers() { _addLayers() {
@@ -164,7 +252,10 @@ export class RadarControl {
} }
_updateFrameUI() { _updateFrameUI() {
this._flyout.hidden = this._frames.length === 0; // Shown whenever there's radar frames, a forecast, or both — either
// one on its own is still worth showing the flyout for.
this._flyout.hidden = !this._enabled;
this._playback.hidden = this._frames.length === 0;
this._scrub.max = String(Math.max(0, this._frames.length - 1)); this._scrub.max = String(Math.max(0, this._frames.length - 1));
this._scrub.value = String(this._frameIndex); this._scrub.value = String(this._frameIndex);
const frame = this._frames[this._frameIndex]; const frame = this._frames[this._frameIndex];
@@ -192,6 +283,39 @@ async function fetchFrames() {
return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host })); return past.slice(-FRAME_COUNT).map((frame) => ({ ...frame, host }));
} }
async function fetchForecast(lat, lon) {
const url = new URL(FORECAST_API);
url.searchParams.set("latitude", lat);
url.searchParams.set("longitude", lon);
url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max");
url.searchParams.set("timezone", "auto");
url.searchParams.set("forecast_days", String(FORECAST_DAYS));
const response = await fetch(url);
if (!response.ok) throw new Error(`Open-Meteo request failed: ${response.status}`);
return response.json();
}
// localStorage, not IndexedDB: this is one small JSON object (a few days of
// daily figures), not worth a heavier async storage API for. Wrapped since
// private browsing / storage-blocked contexts can throw on access, not just
// on being full — a forecast is a nice-to-have, never worth crashing over.
function writeCachedForecast(data) {
try {
localStorage.setItem(FORECAST_CACHE_KEY, JSON.stringify({ data, cachedAt: Date.now() }));
} catch (err) {
// Ignore — see above.
}
}
function readCachedForecast() {
try {
const raw = localStorage.getItem(FORECAST_CACHE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (err) {
return null;
}
}
function firstSymbolLayerId(map) { function firstSymbolLayerId(map) {
const layers = map.getStyle()?.layers || []; const layers = map.getStyle()?.layers || [];
const symbolLayer = layers.find((layer) => layer.type === "symbol"); const symbolLayer = layers.find((layer) => layer.type === "symbol");
+63
View File
@@ -0,0 +1,63 @@
// Caches RainViewer radar tile images so the last-seen frames stay visible
// once the network is actually gone — nothing else here. No app-shell
// precaching: the app itself is served by the same local server that's
// already the offline resource in the van, so there's no "app unreachable
// but should still work" scenario to solve, unlike the radar tiles (a
// third-party CDN).
const CACHE_VERSION = "v1";
const RADAR_CACHE = `radar-tiles-${CACHE_VERSION}`;
// ponytail: a rough cap, not real LRU/expiry — trims oldest-by-insertion via
// Cache API iteration order (stable in practice, not spec-guaranteed).
// Revisit with timestamp-based expiry if storage actually becomes a
// problem; 400 tiles is at most a few MB either way.
const MAX_RADAR_TILES = 400;
function isRadarTile(url) {
return url.hostname.endsWith("rainviewer.com") && url.pathname.includes("/v2/radar/");
}
self.addEventListener("install", (event) => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== RADAR_CACHE).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (!isRadarTile(url)) return;
event.respondWith(networkFallingBackToCache(event.request));
});
// Network first (frame ids rotate as new radar sweeps come in, so a cached
// tile can go stale) — cache is only the answer once the network actually
// fails, not a shortcut when it's merely slow.
async function networkFallingBackToCache(request) {
const cache = await caches.open(RADAR_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
trimRadarCache(cache);
}
return response;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
throw err;
}
}
async function trimRadarCache(cache) {
const keys = await cache.keys();
if (keys.length <= MAX_RADAR_TILES) return;
await Promise.all(keys.slice(0, keys.length - MAX_RADAR_TILES).map((key) => cache.delete(key)));
}
+1
View File
@@ -2,6 +2,7 @@
<header class="topbar"> <header class="topbar">
<span class="topbar__brand" title="rev <%= revision %>">MapTool</span> <span class="topbar__brand" title="rev <%= revision %>">MapTool</span>
<span class="topbar__coords"></span> <span class="topbar__coords"></span>
<span class="topbar__offline-status" hidden></span>
<div class="topbar__auth"> <div class="topbar__auth">
<% if logged_in %> <% if logged_in %>
<span><%= user %></span> <span><%= user %></span>
+10
View File
@@ -9,6 +9,16 @@
</head> </head>
<body> <body>
<%= yield %> <%= yield %>
<script>
// Caches radar tile images (see public/sw.js) so the last-seen frames
// stay visible once the network is actually gone. Silently a no-op
// where it can't register — insecure-context LAN access without a
// reverse proxy in front, for instance — same as radar caching simply
// not working there; nothing else on the page depends on this.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
</script>
<script src="/vendor/pmtiles/pmtiles.js"></script> <script src="/vendor/pmtiles/pmtiles.js"></script>
<script src="/vendor/protomaps-themes-base/protomaps-themes-base.js"></script> <script src="/vendor/protomaps-themes-base/protomaps-themes-base.js"></script>
<%# Vendored libs above don't change between deploys, so they're left <%# Vendored libs above don't change between deploys, so they're left