98 lines
2.8 KiB
Rust
98 lines
2.8 KiB
Rust
//! Satellite tile fetcher — XYZ/TMS tile download with caching.
|
|
|
|
use crate::cache::TileCache;
|
|
use crate::coord;
|
|
use crate::types::{GeoBBox, RasterTile, TileCoord};
|
|
use anyhow::Result;
|
|
|
|
/// Tile provider (all free, no API keys).
|
|
pub enum TileProvider {
|
|
/// Sentinel-2 cloudless mosaic (EOX, 10m, CC-BY-4.0)
|
|
Sentinel2Cloudless,
|
|
/// ESRI World Imagery (sub-meter, free tier)
|
|
EsriWorldImagery,
|
|
/// OpenStreetMap (map tiles, not satellite)
|
|
Osm,
|
|
}
|
|
|
|
impl TileProvider {
|
|
pub fn url(&self, coord: &TileCoord) -> String {
|
|
match self {
|
|
Self::Sentinel2Cloudless => format!(
|
|
"https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2021_3857/default/g/{}/{}/{}.jpg",
|
|
coord.z, coord.y, coord.x
|
|
),
|
|
Self::EsriWorldImagery => format!(
|
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{}/{}/{}",
|
|
coord.z, coord.y, coord.x
|
|
),
|
|
Self::Osm => format!(
|
|
"https://tile.openstreetmap.org/{}/{}/{}.png",
|
|
coord.z, coord.x, coord.y
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn name(&self) -> &str {
|
|
match self {
|
|
Self::Sentinel2Cloudless => "sentinel2",
|
|
Self::EsriWorldImagery => "esri",
|
|
Self::Osm => "osm",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch a single tile with caching.
|
|
pub async fn fetch_tile(
|
|
provider: &TileProvider,
|
|
coord: &TileCoord,
|
|
cache: &TileCache,
|
|
) -> Result<RasterTile> {
|
|
let cache_key = format!("tiles_{}_{}_{}.dat", coord.z, coord.x, coord.y);
|
|
|
|
if let Some(data) = cache.get(&cache_key) {
|
|
return Ok(RasterTile {
|
|
coord: coord.clone(),
|
|
data,
|
|
bounds: coord::tile_bounds(coord),
|
|
});
|
|
}
|
|
|
|
let url = provider.url(coord);
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.user_agent("RuView/0.1 (https://github.com/ruvnet/RuView)")
|
|
.build()?;
|
|
|
|
let resp = client.get(&url).send().await?;
|
|
if !resp.status().is_success() {
|
|
anyhow::bail!("Tile fetch failed: {} → {}", url, resp.status());
|
|
}
|
|
let data = resp.bytes().await?.to_vec();
|
|
cache.put(&cache_key, &data)?;
|
|
|
|
Ok(RasterTile {
|
|
coord: coord.clone(),
|
|
data,
|
|
bounds: coord::tile_bounds(coord),
|
|
})
|
|
}
|
|
|
|
/// Fetch all tiles covering a bounding box.
|
|
pub async fn fetch_area(
|
|
provider: &TileProvider,
|
|
bbox: &GeoBBox,
|
|
zoom: u8,
|
|
cache: &TileCache,
|
|
) -> Result<Vec<RasterTile>> {
|
|
let coords = coord::tiles_for_bbox(bbox, zoom);
|
|
let mut tiles = Vec::with_capacity(coords.len());
|
|
for c in &coords {
|
|
match fetch_tile(provider, c, cache).await {
|
|
Ok(t) => tiles.push(t),
|
|
Err(e) => eprintln!(" Tile {}/{}/{} failed: {}", c.z, c.x, c.y, e),
|
|
}
|
|
}
|
|
Ok(tiles)
|
|
}
|