use std::collections::HashMap;
use std::f64::consts::PI;
use std::sync::{Arc, Mutex};
use crate::aes::Aesthetic;
use crate::coord::Coord;
use crate::data::{DataFrame, Value};
use crate::position::identity::PositionIdentity;
use crate::position::Position;
use crate::render::backend::DrawBackend;
use crate::render::{Rect, RenderError};
use crate::scale::ScaleSet;
use crate::stat::identity::StatIdentity;
use crate::stat::Stat;
use crate::theme::Theme;
use super::{Geom, GeomParams};
pub trait TileFetcher: Send + Sync {
fn fetch(&self, z: u32, x: u32, y: u32) -> Option<Vec<u8>>;
}
impl<F: Fn(u32, u32, u32) -> Option<Vec<u8>> + Send + Sync> TileFetcher for F {
fn fetch(&self, z: u32, x: u32, y: u32) -> Option<Vec<u8>> {
self(z, x, y)
}
}
#[derive(Clone, Debug)]
pub struct TileSource {
pub url_template: String,
pub attribution: String,
pub min_zoom: u32,
pub max_zoom: u32,
pub tile_size: f64,
}
impl TileSource {
pub fn xyz(url_template: impl Into<String>) -> Self {
TileSource {
url_template: url_template.into(),
attribution: String::new(),
min_zoom: 0,
max_zoom: 19,
tile_size: 256.0,
}
}
pub fn attribution(mut self, text: impl Into<String>) -> Self {
self.attribution = text.into();
self
}
pub fn zoom_range(mut self, min: u32, max: u32) -> Self {
self.min_zoom = min.min(max);
self.max_zoom = max;
self
}
pub fn url(&self, z: u32, x: u32, y: u32) -> String {
self.url_template
.replace("{z}", &z.to_string())
.replace("{x}", &x.to_string())
.replace("{y}", &y.to_string())
}
}
pub fn tile_xy(z: u32, mx: f64, my: f64) -> (f64, f64) {
let n = (1u64 << z) as f64;
((mx + PI) / (2.0 * PI) * n, (1.0 - my / PI) / 2.0 * n)
}
pub fn tile_bounds(z: u32, tx: i64, ty: i64) -> (f64, f64, f64, f64) {
let n = (1u64 << z) as f64;
let mx = |t: f64| t / n * 2.0 * PI - PI;
let my = |t: f64| (1.0 - 2.0 * t / n) * PI;
(
mx(tx as f64),
my(ty as f64),
mx(tx as f64 + 1.0),
my(ty as f64 + 1.0),
)
}
const MAX_TILES: usize = 64;
type TileCache = Arc<Mutex<HashMap<(u32, u32, u32), Option<Arc<Vec<u8>>>>>>;
#[derive(Clone)]
pub struct GeomMapTile {
pub source: TileSource,
fetcher: Arc<dyn TileFetcher>,
pub zoom: Option<u32>,
pub detail: f64,
pub alpha: f64,
cache: TileCache,
}
impl std::fmt::Debug for GeomMapTile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GeomMapTile")
.field("source", &self.source)
.field("zoom", &self.zoom)
.finish_non_exhaustive()
}
}
impl GeomMapTile {
pub fn new(source: TileSource, fetcher: impl TileFetcher + 'static) -> Self {
GeomMapTile {
source,
fetcher: Arc::new(fetcher),
zoom: None,
detail: 1.0,
alpha: 1.0,
cache: Arc::default(),
}
}
pub fn zoom(mut self, z: u32) -> Self {
self.zoom = Some(z);
self
}
pub fn detail(mut self, detail: f64) -> Self {
self.detail = detail;
self
}
pub fn alpha(mut self, alpha: f64) -> Self {
self.alpha = alpha;
self
}
fn tile(&self, z: u32, x: u32, y: u32) -> Option<Arc<Vec<u8>>> {
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
cache
.entry((z, x, y))
.or_insert_with(|| self.fetcher.fetch(z, x, y).map(Arc::new))
.clone()
}
pub(crate) fn pick_zoom(&self, span: (f64, f64), size_px: (f64, f64)) -> u32 {
let (lo, hi) = (self.source.min_zoom, self.source.max_zoom.min(30));
let mut z = match self.zoom {
Some(z) => z.clamp(lo, hi),
None => {
let px_per_unit = size_px.0 / span.0.max(1e-12);
let n = 2.0 * PI * px_per_unit * self.detail.max(0.01) / self.source.tile_size;
(n.max(1.0).log2().round() as u32).clamp(lo, hi)
}
};
let count = |z: u32| {
let n = (1u64 << z) as f64;
let tiles = |span: f64| (span / (2.0 * PI) * n).ceil() + 1.0;
tiles(span.0) * tiles(span.1)
};
while z > lo && count(z) > MAX_TILES as f64 {
z -= 1;
}
z
}
}
impl Geom for GeomMapTile {
fn draw(
&self,
_data: &DataFrame,
coord: &dyn Coord,
scales: &ScaleSet,
_theme: &Theme,
backend: &mut dyn DrawBackend,
) -> Result<(), RenderError> {
let (Some(xs), Some(ys)) = (scales.get(&Aesthetic::X), scales.get(&Aesthetic::Y)) else {
return Ok(());
};
let (Some((x0, x1)), Some((y0, y1))) = (xs.expanded_domain(), ys.expanded_domain()) else {
return Ok(());
};
let plot_area = backend.plot_area();
let view = coord
.data_rect(&plot_area)
.unwrap_or_else(|| plot_area.clone());
let px = |mx: f64, my: f64| {
coord.transform(
(xs.map(&Value::Float(mx)), ys.map(&Value::Float(my))),
&plot_area,
)
};
let z = self.pick_zoom((x1 - x0, y1 - y0), (view.width, view.height));
let n = 1i64 << z;
let (tx0, ty0) = tile_xy(z, x0, y1);
let (tx1, ty1) = tile_xy(z, x1, y0);
let ty_range = (ty0.floor().max(0.0) as i64)..=(ty1.ceil() as i64 - 1).min(n - 1);
for ty in ty_range {
for tx in tx0.floor() as i64..=tx1.ceil() as i64 - 1 {
let wx = tx.rem_euclid(n) as u32;
let Some(bytes) = self.tile(z, wx, ty as u32) else {
continue;
};
let (bx0, by0, bx1, by1) = tile_bounds(z, tx, ty);
let (a, b) = (px(bx0, by0), px(bx1, by1));
let rect = Rect {
x: a.0.min(b.0),
y: a.1.min(b.1),
width: (b.0 - a.0).abs(),
height: (b.1 - a.1).abs(),
};
backend.set_mark_attrs(vec![("data-tile".to_string(), format!("{z}/{wx}/{ty}"))]);
backend.draw_image(&bytes, &rect, &view, self.alpha)?;
}
}
backend.set_mark_attrs(Vec::new());
Ok(())
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![]
}
fn default_stat(&self) -> Box<dyn Stat> {
Box::new(StatIdentity)
}
fn default_position(&self) -> Box<dyn Position> {
Box::new(PositionIdentity)
}
fn default_params(&self) -> GeomParams {
GeomParams::default()
}
fn name(&self) -> &str {
"map_tile"
}
fn inherit_aes(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spatial::SfProjection;
#[test]
fn tile_grid_matches_the_slippy_map_convention() {
let [mx, my] = SfProjection::Mercator.project([13.4, 52.52]);
let (tx, ty) = tile_xy(8, mx, my);
assert_eq!((tx.floor(), ty.floor()), (137.0, 83.0));
let (x0, yt, x1, yb) = tile_bounds(8, 137, 83);
let (a, b) = (tile_xy(8, x0, yt), tile_xy(8, x1, yb));
assert!((a.0 - 137.0).abs() < 1e-9 && (a.1 - 83.0).abs() < 1e-9);
assert!((b.0 - 138.0).abs() < 1e-9 && (b.1 - 84.0).abs() < 1e-9);
assert_eq!(
TileSource::xyz("https://t/{z}/{x}/{y}.png").url(8, 137, 83),
"https://t/8/137/83.png"
);
}
#[test]
fn zoom_follows_the_view_and_is_capped() {
let geom = GeomMapTile::new(TileSource::xyz(""), |_, _, _| None);
assert_eq!(geom.pick_zoom((2.0 * PI, 2.0 * PI), (256.0, 256.0)), 0);
assert_eq!(geom.pick_zoom((2.0 * PI, 2.0 * PI), (1024.0, 1024.0)), 2);
let z = geom.pick_zoom((0.5, 0.5), (20_000.0, 20_000.0));
let n = (1u64 << z) as f64;
assert!((0.5 / (2.0 * PI) * n + 1.0).powi(2) <= MAX_TILES as f64);
assert_eq!(geom.clone().zoom(5).pick_zoom((1.0, 1.0), (10.0, 10.0)), 5);
}
#[test]
fn fetched_tiles_are_cached() {
let calls = Arc::new(Mutex::new(0));
let c = calls.clone();
let geom = GeomMapTile::new(TileSource::xyz(""), move |_, _, _| {
*c.lock().unwrap() += 1;
Some(vec![1, 2, 3])
});
assert!(geom.tile(1, 0, 0).is_some());
assert!(geom.tile(1, 0, 0).is_some());
assert_eq!(*calls.lock().unwrap(), 1);
}
#[test]
fn builder_knobs_and_debug() {
let source = TileSource::xyz("t").attribution("© x").zoom_range(9, 3);
assert_eq!((source.min_zoom, source.max_zoom), (3, 3));
let geom = GeomMapTile::new(source, |_, _, _| None)
.detail(2.0)
.alpha(0.5);
assert_eq!((geom.detail, geom.alpha), (2.0, 0.5));
assert!(format!("{geom:?}").contains("GeomMapTile"));
assert_eq!(geom.name(), "map_tile");
assert!(!geom.inherit_aes());
assert!(geom.required_aes().is_empty());
assert_eq!(geom.pick_zoom((2.0 * PI, 2.0 * PI), (256.0, 256.0)), 3);
}
#[test]
fn nothing_is_drawn_without_trained_position_scales() {
let geom = GeomMapTile::new(TileSource::xyz(""), |_, _, _| Some(vec![1]));
let area = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
};
let mut svg = crate::render::svg_backend::SvgBackend::new(100, 100, area);
geom.draw(
&DataFrame::new(),
&crate::coord::cartesian::CoordCartesian::default(),
&ScaleSet::new(),
&Theme::default(),
&mut svg,
)
.unwrap();
assert!(!svg.body().contains("<image"));
}
}