Compare commits
7
Commits
53b0e8961b
...
5caf865fc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5caf865fc8 | ||
|
|
a98b8dbd47 | ||
|
|
8d090b0774 | ||
|
|
707e71caf1 | ||
|
|
77a4c96b1c | ||
|
|
f49e44800a | ||
|
|
8ca5ca9bc1 |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+139
-15
@@ -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,32 +100,89 @@ 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._icon.disabled = false;
|
||||||
|
|
||||||
|
// The icon may have been clicked again (disabling) while the fetches
|
||||||
|
// were in flight.
|
||||||
|
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._enabled = false;
|
||||||
this._icon.classList.remove("active");
|
this._icon.classList.remove("active");
|
||||||
this._icon.disabled = false;
|
} else if (frames.length > 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._icon.disabled = false;
|
|
||||||
|
|
||||||
// The icon may have been clicked again (disabling) while the fetch was
|
|
||||||
// in flight.
|
|
||||||
if (!this._enabled || !frames.length) return;
|
|
||||||
|
|
||||||
this._frames = frames;
|
this._frames = frames;
|
||||||
this._frameIndex = frames.length - 1;
|
this._frameIndex = frames.length - 1;
|
||||||
this._addLayers();
|
this._addLayers();
|
||||||
this._updateFrameUI();
|
|
||||||
this._startAnimation();
|
this._startAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._updateFrameUI();
|
||||||
|
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() {
|
||||||
const beforeId = firstSymbolLayerId(this._map);
|
const beforeId = firstSymbolLayerId(this._map);
|
||||||
this._frames.forEach((frame, i) => {
|
this._frames.forEach((frame, i) => {
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user