Add a base map switcher
OpenStreetMap, Humanitarian, CyclOSM, OpenTopoMap and Esri satellite, all usable without an API key. The choice is kept in localStorage. The dark theme inverts only the street maps, not the satellite imagery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mweb/src/map.rs
@@ -10,9 +10,10 @@ use wasm_bindgen::prelude::*;
use crate::{ago, fmt_time, http, now_secs};
const TILES: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const ATTRIBUTION: &str =
const OSM: &str =
"© <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors";
/// localStorage key for the chosen base map.
const LAYER_KEY: &str = "mapLayer";
const POLL: Duration = Duration::from_secs(10);
/// Positions older than this are drawn faded.
const STALE_SECS: i64 = 3600;
@@ -36,6 +37,15 @@ extern "C" {
#[wasm_bindgen(method, js_name = remove)]
fn remove_map(this: &LMap);
#[wasm_bindgen(method, js_name = on)]
fn on_map(this: &LMap, event: &str, f: &JsValue);
type Control;
#[wasm_bindgen(js_namespace = ["L", "control"], js_name = layers)]
fn layers_control(base: &JsValue, overlays: &JsValue, opts: &JsValue) -> Control;
#[wasm_bindgen(method, js_name = addTo)]
fn add_control_to(this: &Control, map: &LMap);
#[wasm_bindgen(js_namespace = L, js_name = tileLayer)]
fn tile_layer(url: &str, opts: &JsValue) -> Layer;
#[wasm_bindgen(js_namespace = L, js_name = circleMarker)]
@@ -69,6 +79,96 @@ fn obj(pairs: &[(&str, JsValue)]) -> JsValue {
o.into()
}
/// Street maps get the `street` class, which the dark theme inverts. None of these sources has a dark style.
fn tiles(url: &str, max_zoom: u8, attribution: &str, street: bool) -> Layer {
tile_layer(
url,
&obj(&[
("maxZoom", max_zoom.into()),
("attribution", attribution.into()),
("className", if street { "street" } else { "" }.into()),
]),
)
}
/// Adds the base maps and the control to switch them. Remembers the choice.
fn add_base_maps(m: &LMap) {
let storage = window().local_storage().ok().flatten();
let chosen = storage
.as_ref()
.and_then(|s| s.get_item(LAYER_KEY).ok().flatten());
let bases = [
(
"OpenStreetMap",
tiles(
"https://tile.openstreetmap.org/{z}/{x}/{y}.png",
19,
OSM,
true,
),
),
(
"Humanitarian",
tiles(
"https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
19,
&format!(
"{OSM}, style by the Humanitarian OpenStreetMap Team, hosted by OpenStreetMap France"
),
true,
),
),
(
"Cycling",
tiles(
"https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png",
20,
&format!("<a href=\"https://www.cyclosm.org\">CyclOSM</a>, map data {OSM}"),
true,
),
),
(
"Topographic",
tiles(
"https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
17,
&format!(
"Map data {OSM}, SRTM. Style © <a href=\"https://opentopomap.org\">OpenTopoMap</a> (CC-BY-SA)"
),
true,
),
),
(
"Satellite",
tiles(
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
19,
"Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community",
false,
),
),
];
let index = bases
.iter()
.position(|(name, _)| chosen.as_deref() == Some(*name))
.unwrap_or(0);
bases[index].1.add_to(m);
let control = Object::new();
for (name, layer) in &bases {
Reflect::set(&control, &(*name).into(), layer).unwrap();
}
layers_control(&control, &JsValue::UNDEFINED, &JsValue::UNDEFINED).add_control_to(m);
let save = Closure::<dyn Fn(JsValue)>::new(move |e: JsValue| {
let name = Reflect::get(&e, &"name".into())
.ok()
.and_then(|n| n.as_string());
if let (Some(s), Some(name)) = (&storage, name) {
let _ = s.set_item(LAYER_KEY, &name);
}
});
m.on_map("baselayerchange", &save.into_js_value());
}
fn latlng(lat: f64, lon: f64) -> Array {
Array::of2(&lat.into(), &lon.into())
}
@@ -284,11 +384,7 @@ pub fn MapPage(#[prop(optional)] guest: Option<GuestAuth>) -> impl IntoView {
}
let m = leaflet_map(&div);
m.set_view(&latlng(50.0, 10.0), 4.0);
tile_layer(
TILES,
&obj(&[("maxZoom", 19.into()), ("attribution", ATTRIBUTION.into())]),
)
.add_to(&m);
add_base_maps(&m);
map.set_value(Some(m));
ready.set(true);
});
Mweb/style.css
@@ -127,13 +127,14 @@ code.copy { word-break: break-all; user-select: all; }
button.theme { display: flex; align-items: center; padding: 0.4em; }
/* OSM only has light tiles. Inverting them gives a usable dark map. */
:root[data-theme="dark"] .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
/* The street maps only have light styles. Inverting them gives a usable dark map. */
:root[data-theme="dark"] .leaflet-tile-pane .street { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
:root:not([data-theme="light"]) .leaflet-tile-pane .street { filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); }
}
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-layers,
.leaflet-tooltip { background: var(--surface); color: var(--fg); border-color: var(--line); }
.leaflet-container .leaflet-control-attribution a { color: var(--accent-fg); }
@@ -147,3 +148,8 @@ button.theme { display: flex; align-items: center; padding: 0.4em; }
.settings .device-choice { padding-left: 1.6em; }
.settings .device-choice label:has(input:disabled) { color: var(--muted); }
.guest-title { flex: 1 1 auto; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Leaflet's own icon is an image file we do not ship. */
.leaflet-container .leaflet-control-layers-toggle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M12 3 2 8l10 5 10-5z'/%3E%3Cpath d='m2 13 10 5 10-5'/%3E%3C/svg%3E");
background-size: 24px 24px;
}