OSM cockpit
@@ -91,6 +91,19 @@ const map=document.getElementById('map'), tilesEl=document.getElementById('tiles
let z=12; // Berlin-ish default view
let cx=lon2x(13.404954,z), cy=lat2y(52.520008,z); // center in fractional tile units
+// ── dual map: OSM (z/x/y) ↔ ESRI World Imagery satellite (z/y/x — row first!).
+// Same slippy addresses, same HHTL keys — two skins. Mirrors cockpit-server::
+// osm_tiles::{OSM_TILE_URL, SAT_TILE_URL}; the locate API reports both URLs.
+const BASEMAPS={
+ osm:{ src:(z,x,y)=>`https://tile.openstreetmap.org/${z}/${x}/${y}.png`,
+ attr:'©
OpenStreetMap contributors',
+ next:'sat' },
+ sat:{ src:(z,x,y)=>`https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${z}/${y}/${x}`,
+ attr:'Powered by
Esri — Source: Esri, Maxar, Earthstar Geographics',
+ next:'osm' },
+};
+let basemap='osm';
+
function render(){
const w=map.clientWidth, h=map.clientHeight, n=Math.pow(2,z);
tilesEl.innerHTML='';
@@ -99,10 +112,11 @@ function render(){
tilesEl.style.transform=`translate(${ox}px,${oy}px)`;
const x0=Math.floor(cx - w/512)-1, x1=Math.floor(cx + w/512)+1;
const y0=Math.floor(cy - h/512)-1, y1=Math.floor(cy + h/512)+1;
+ const bm=BASEMAPS[basemap];
for(let ty=y0;ty<=y1;ty++) for(let tx=x0;tx<=x1;tx++){
const wx=((tx%n)+n)%n; if(ty<0||ty>=n) continue;
const img=new Image();
- img.src=`https://tile.openstreetmap.org/${z}/${wx}/${ty}.png`;
+ img.src=bm.src(z,wx,ty);
img.style.left=(tx*256)+'px'; img.style.top=(ty*256)+'px';
tilesEl.appendChild(img);
}
@@ -123,6 +137,13 @@ function zoom(d){ const nz=Math.max(0,Math.min(19,z+d)); if(nz===z) return;
const f=Math.pow(2,nz-z); cx*=f; cy*=f; z=nz; render(); }
document.getElementById('zin').onclick=e=>{ e.stopPropagation(); zoom(1); };
document.getElementById('zout').onclick=e=>{ e.stopPropagation(); zoom(-1); };
+// basemap toggle — the button names the OTHER skin (what you'll switch TO);
+// attribution follows the active source (OSM contributors ↔ Esri).
+document.getElementById('base').onclick=e=>{ e.stopPropagation();
+ basemap=BASEMAPS[basemap].next;
+ e.target.textContent=BASEMAPS[basemap].next;
+ document.getElementById('attr').innerHTML=BASEMAPS[basemap].attr;
+ render(); };
map.addEventListener('wheel',e=>{ e.preventDefault(); zoom(e.deltaY<0?1:-1); },{passive:false});
// ── click → server-side HHTL key ──
@@ -144,7 +165,9 @@ map.addEventListener('click',async e=>{
document.getElementById('heel').textContent='0x'+d.hhtl.heel.toString(16).padStart(4,'0');
document.getElementById('hip').textContent='0x'+d.hhtl.hip.toString(16).padStart(4,'0');
document.getElementById('twig').textContent='0x'+d.hhtl.twig.toString(16).padStart(4,'0');
- document.getElementById('src').textContent=d.tile_url;
+ // tile source follows the ACTIVE basemap (the locate API reports both URLs) —
+ // on satellite, showing the OSM URL would mismatch the visible tiles.
+ document.getElementById('src').textContent=basemap==='sat'?d.sat_tile_url:d.tile_url;
}catch(err){ document.getElementById('src').textContent='locate failed: '+err; }
});
diff --git a/crates/cockpit-server/src/osm_tiles.rs b/crates/cockpit-server/src/osm_tiles.rs
index a828e2002..c061f2283 100644
--- a/crates/cockpit-server/src/osm_tiles.rs
+++ b/crates/cockpit-server/src/osm_tiles.rs
@@ -27,18 +27,40 @@ use std::f64::consts::PI;
/// by the client; the cockpit only computes the address.
pub const OSM_TILE_URL: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
+/// The satellite basemap source — ESRI World Imagery, the SAME keyless slippy
+/// grid the DEM/skin bakes drape from (`scripts/fetch_iceland_dem.py`), so the
+/// dual map (OSM ↔ satellite) and the ver-9 terrain skins share one imagery
+/// truth. NOTE the path order: ESRI is **z/y/x** (row before column), unlike
+/// the OSM z/x/y — the template placeholders encode that swap.
+pub const SAT_TILE_URL: &str =
+ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}";
+
/// Native HHTL depth: 3 tiers × 8 levels = 24 quadtree levels (zoom 0..=24).
pub const HHTL_DEPTH: u32 = 24;
-/// The concrete tile-source URL for a `z/x/y` address.
-#[must_use]
-pub fn tile_url(z: u32, x: u32, y: u32) -> String {
- OSM_TILE_URL
+/// Fill a slippy-tile URL template (`{z}`/`{x}`/`{y}`) for an address. The
+/// template carries the axis order, so OSM (z/x/y) and ESRI (z/y/x) both fill
+/// correctly through the same call.
+fn fill_template(template: &str, z: u32, x: u32, y: u32) -> String {
+ template
.replace("{z}", &z.to_string())
.replace("{x}", &x.to_string())
.replace("{y}", &y.to_string())
}
+/// The concrete OSM tile-source URL for a `z/x/y` address.
+#[must_use]
+pub fn tile_url(z: u32, x: u32, y: u32) -> String {
+ fill_template(OSM_TILE_URL, z, x, y)
+}
+
+/// The concrete SATELLITE (ESRI World Imagery) tile URL for the same `z/x/y`
+/// address — the dual-map partner of [`tile_url`].
+#[must_use]
+pub fn sat_tile_url(z: u32, x: u32, y: u32) -> String {
+ fill_template(SAT_TILE_URL, z, x, y)
+}
+
/// WebMercator (EPSG:3857) forward: geographic `(lon, lat)` → slippy tile
/// `(x, y)` at zoom `z`. `x` grows east, `y` grows south (TMS-flipped is the
/// caller's concern). Clamped to the valid `0..2^z` range.
@@ -151,6 +173,10 @@ pub async fn osm_locate_handler(Query(q): Query
) -> Json