diff --git a/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md b/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md index 4fbd2a89a..139a7b107 100644 --- a/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md +++ b/claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md @@ -80,3 +80,20 @@ the render on mobile; the full grid is the desktop birdview. both reuse the DEM+skin pipeline; Havel is flat lowland → skin-dominant. - **Sentinel-2 skin** — swap the ESRI source for `GrandCanyon_S2_20260620.tif` (source-agnostic; the skin just comes from the demgrid rgb). + +## /osm dual basemap (2026-07-10, follow-up shipped) +- The `/osm` slippy cockpit gets a **basemap toggle**: OSM map ↔ ESRI World Imagery + satellite — the SAME keyless imagery the ver-9 terrain skins drape from, so the flat + map and the 3D scenes share one imagery truth. Same tile addresses, same HHTL keys — + two skins. +- Server (`osm_tiles.rs`): `SAT_TILE_URL` + `sat_tile_url()` (ESRI is **z/y/x** — row + before column; the shared `fill_template` encodes the swap), both locate/tile-meta + responses now report `sat_tile_url`/`sat_source` (additive JSON). Test asserts the + axis order. +- Page (`osm.rs`): `BASEMAPS` table (src template + attribution + next), `sat`/`osm` + toggle button in the map controls, attribution swaps with the source (OSM + contributors ↔ Esri/Maxar/Earthstar). +- Verified headless on the extracted page: toggle OSM→sat→OSM, srcs swap with the + z/y/x order CORRECT, attribution follows. cockpit-server itself cannot compile in + this container (rusty_v8 static-lib download blocked by egress policy) — Rust side + mirrors the existing tested pattern; CI runs the tests. diff --git a/crates/cockpit-server/src/osm.rs b/crates/cockpit-server/src/osm.rs index e5cc03391..bc7e4ae88 100644 --- a/crates/cockpit-server/src/osm.rs +++ b/crates/cockpit-server/src/osm.rs @@ -57,9 +57,9 @@ const PAGE: &str = r##"
-
+
drag to pan · click a point for its HHTL key
-
© OpenStreetMap contributors
+
© OpenStreetMap contributors

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