Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions claude-notes/plans/2026-07-10-garmin-dem-satellite-skin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 27 additions & 4 deletions crates/cockpit-server/src/osm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ const PAGE: &str = r##"<!doctype html>
<div id="app">
<div id="map">
<div id="tiles"></div>
<div class="ctl"><button id="zin">+</button><button id="zout">−</button></div>
<div class="ctl"><button id="zin">+</button><button id="zout">−</button><button id="base" title="basemap: OSM map ↔ ESRI satellite (same tiles, same HHTL address — two skins)" style="width:auto;padding:0 8px;font-size:12px">sat</button></div>
<div class="hint">drag to pan · click a point for its HHTL key</div>
<div class="attr">© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors</div>
<div class="attr" id="attr">© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors</div>
</div>
<div id="panel">
<h1>OSM cockpit</h1>
Expand Down Expand Up @@ -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:'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 <a href="https://www.esri.com/">Esri</a> — 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='';
Expand All @@ -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);
}
Expand All @@ -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(); };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
map.addEventListener('wheel',e=>{ e.preventDefault(); zoom(e.deltaY<0?1:-1); },{passive:false});

// ── click → server-side HHTL key ──
Expand All @@ -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; }
});

Expand Down
46 changes: 42 additions & 4 deletions crates/cockpit-server/src/osm_tiles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -151,6 +173,10 @@ pub async fn osm_locate_handler(Query(q): Query<LocateQuery>) -> Json<serde_json
"lon": q.lon, "lat": q.lat, "z": z, "x": x, "y": y,
"tile_url": tile_url(z, x, y),
"source": OSM_TILE_URL,
// Dual map: the SAME address on the satellite basemap (ESRI World
// Imagery, z/y/x) — one HHTL key, two skins.
"sat_tile_url": sat_tile_url(z, x, y),
"sat_source": SAT_TILE_URL,
"hhtl": hhtl,
"geo_domain": "0x0F",
}))
Expand All @@ -169,6 +195,8 @@ pub async fn osm_tile_meta_handler(
"z": z, "x": x, "y": y,
"tile_url": tile_url(z, x, y),
"source": OSM_TILE_URL,
"sat_tile_url": sat_tile_url(z, x, y),
"sat_source": SAT_TILE_URL,
"hhtl": hhtl,
"resolved": { "z": rz, "x": rx, "y": ry },
"geo_domain": "0x0F",
Expand All @@ -187,6 +215,16 @@ mod tests {
);
}

#[test]
fn sat_tile_url_swaps_to_esri_z_y_x_order() {
// Same address, satellite skin — and the ESRI path is z/y/x (row before
// column), the classic trap the template encodes: y=5677 precedes x=8749.
assert_eq!(
sat_tile_url(14, 8749, 5677),
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/14/5677/8749"
);
}

#[test]
fn null_island_is_the_center_tile() {
// lon=0, lat=0 at z=1 → the 2×2 grid's (1,1) corner (center of the world).
Expand Down