use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use martin_tile_utils::{decode_gzip, decode_zstd};
use mbtiles::Mbtiles;
use mlt_core::geo_types::{Coord, Geometry, Polygon};
use mlt_core::geojson::FeatureCollection;
use mlt_core::mvt::mvt_to_feature_collection;
use rstar::{AABB, PointDistance, RTree, RTreeObject};
use super::group_by_layer;
use super::state::LayerGroup;
type DecodedTile = (FeatureCollection, u32, Vec<LayerGroup>, RTree<MbtGeoEntry>);
type BestHover = Option<(f64, (u8, u32, u32), usize, usize)>;
pub(crate) enum TileLoadRequest {
Load { z: u8, x: u32, y: u32 },
}
pub(crate) struct TileLoadResult {
pub z: u8,
pub x: u32,
pub y: u32,
pub data: Result<Option<Vec<u8>>, String>,
}
pub(crate) struct MbtGeoEntry {
pub layer: usize,
pub feat: usize,
pub vertices: Vec<[f64; 2]>,
}
impl RTreeObject for MbtGeoEntry {
type Envelope = AABB<[f64; 2]>;
fn envelope(&self) -> Self::Envelope {
if self.vertices.is_empty() {
return AABB::from_point([0.0, 0.0]);
}
let (ax, ay, bx, by) = self.vertices.iter().fold(
(
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
),
|(ax, ay, bx, by), v| (ax.min(v[0]), ay.min(v[1]), bx.max(v[0]), by.max(v[1])),
);
AABB::from_corners([ax, ay], [bx, by])
}
}
impl PointDistance for MbtGeoEntry {
fn distance_2(&self, point: &[f64; 2]) -> f64 {
self.vertices
.iter()
.map(|v| {
let dx = v[0] - point[0];
let dy = v[1] - point[1];
dx * dx + dy * dy
})
.fold(f64::INFINITY, f64::min)
}
}
pub(crate) struct TileTransform {
n: f64, tx: f64, ty: f64, ext: f64, }
impl TileTransform {
pub(crate) fn new(z: u8, tile_x: u32, tile_y: u32, extent: u32) -> Self {
Self {
n: f64::from(1u32 << z),
tx: f64::from(tile_x),
ty: f64::from(tile_y),
ext: f64::from(extent),
}
}
#[inline]
pub(crate) fn to_world(&self, c: Coord<i32>) -> [f64; 2] {
[
(self.tx + f64::from(c.x) / self.ext) / self.n,
(self.ty + f64::from(c.y) / self.ext) / self.n,
]
}
pub(crate) fn poly_verts(&self, poly: &Polygon<i32>) -> Vec<[f64; 2]> {
poly.exterior()
.0
.iter()
.copied()
.chain(poly.interiors().iter().flat_map(|r| r.0.iter().copied()))
.map(|c| self.to_world(c))
.collect()
}
pub(crate) fn geom_verts(&self, geom: &Geometry<i32>) -> Vec<[f64; 2]> {
match geom {
Geometry::<i32>::Point(p) => vec![self.to_world(p.0)],
Geometry::<i32>::LineString(ls) => {
ls.0.iter().copied().map(|c| self.to_world(c)).collect()
}
Geometry::<i32>::MultiPoint(mp) => mp.iter().map(|p| self.to_world(p.0)).collect(),
Geometry::<i32>::Polygon(poly) => self.poly_verts(poly),
Geometry::<i32>::MultiLineString(mls) => mls
.iter()
.flat_map(|ls| ls.0.iter().copied().map(|c| self.to_world(c)))
.collect(),
Geometry::<i32>::MultiPolygon(mpoly) => {
mpoly.iter().flat_map(|p| self.poly_verts(p)).collect()
}
_ => vec![],
}
}
}
pub(crate) enum MbtTileData {
Loading,
Empty,
#[allow(dead_code)]
Error(String),
Loaded {
fc: FeatureCollection,
extent: u32,
layer_groups: Vec<LayerGroup>,
geo_index: RTree<MbtGeoEntry>,
},
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct MbtHoveredInfo {
pub tile: (u8, u32, u32),
pub layer_idx: usize,
pub feat_idx: usize,
}
pub(crate) struct MbtilesState {
#[allow(dead_code)]
pub path: PathBuf,
pub vp_x0: f64,
pub vp_x1: f64,
pub vp_y0: f64,
pub vp_y1: f64,
pub tiles: HashMap<(u8, u32, u32), MbtTileData>,
pub hovered: Option<MbtHoveredInfo>,
pub zoom_f: f64,
pub map_drag_last: Option<(u16, u16)>,
loading: HashSet<(u8, u32, u32)>,
request_tx: mpsc::SyncSender<TileLoadRequest>,
pub result_rx: mpsc::Receiver<TileLoadResult>,
loader_init_rx: Option<mpsc::Receiver<Result<(), String>>>,
loader_fatal: Option<String>,
}
impl MbtilesState {
pub(crate) fn new(path: PathBuf) -> Self {
let (req_tx, req_rx) = mpsc::sync_channel::<TileLoadRequest>(200);
let (res_tx, res_rx) = mpsc::sync_channel::<TileLoadResult>(200);
let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let path_clone = path.clone();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.expect("tokio runtime");
rt.block_on(async move {
let mbt = match Mbtiles::new(&path_clone) {
Ok(m) => m,
Err(e) => {
let _ = init_tx.send(Err(format!("Failed to open mbtiles: {e}")));
return;
}
};
let mut conn = match mbt.open_readonly().await {
Ok(c) => c,
Err(e) => {
let _ =
init_tx.send(Err(format!("MBTiles read-only connection failed: {e}")));
return;
}
};
if init_tx.send(Ok(())).is_err() {
return;
}
drop(init_tx);
while let Ok(TileLoadRequest::Load { z, x, y }) = req_rx.recv() {
let result = mbt
.get_tile(&mut conn, z, x, y)
.await
.map_err(|e| e.to_string());
let _ = res_tx.send(TileLoadResult {
z,
x,
y,
data: result,
});
}
});
});
Self {
path,
vp_x0: 0.0,
vp_x1: 1.0,
vp_y0: 0.0,
vp_y1: 1.0,
tiles: HashMap::new(),
hovered: None,
zoom_f: 0.0,
map_drag_last: None,
loading: HashSet::new(),
request_tx: req_tx,
result_rx: res_rx,
loader_init_rx: Some(init_rx),
loader_fatal: None,
}
}
pub(crate) fn take_loader_fatal(&mut self) -> Option<String> {
self.loader_fatal.take()
}
pub(crate) fn zoom_level(&self) -> u8 {
let vp_w = self.vp_x1 - self.vp_x0;
if vp_w <= 0.0 {
return 0;
}
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let z = (-vp_w.log2()).floor().clamp(0.0, 22.0) as u8;
z
}
fn view_tile_scale(&self) -> (u8, f64, u32) {
let z = self.zoom_level();
let n = 1u32 << z;
(z, f64::from(n), n)
}
pub(crate) fn world_to_tile_xy(z: u8, wx: f64, wy: f64) -> (u32, u32) {
let n = 1u32 << z;
let nf = f64::from(n);
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let tx = (wx * nf).max(0.0).floor() as u32;
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let ty = (wy * nf).max(0.0).floor() as u32;
let tx = tx.min(n.saturating_sub(1));
let ty = ty.min(n.saturating_sub(1));
(tx, ty)
}
pub(crate) fn viewport_world_at_fracs(&self, rx: f64, ry: f64) -> (f64, f64) {
let wx = self.vp_x0 + rx * (self.vp_x1 - self.vp_x0);
let wy = self.vp_y0 + ry * (self.vp_y1 - self.vp_y0);
(wx, wy)
}
fn sync_zoom_f_from_vp(&mut self) {
let vp_w = self.vp_x1 - self.vp_x0;
if vp_w > 0.0 {
self.zoom_f = (-vp_w.log2()).clamp(0.0, 22.0);
}
}
pub(crate) fn visible_tiles(&self) -> Vec<(u8, u32, u32)> {
let (z, nf, n) = self.view_tile_scale();
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let x_min = (self.vp_x0 * nf).max(0.0).floor() as u32;
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let x_max = ((self.vp_x1 * nf).ceil() as u32)
.saturating_sub(1)
.min(n.saturating_sub(1));
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let y_min = (self.vp_y0 * nf).max(0.0).floor() as u32;
#[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let y_max = ((self.vp_y1 * nf).ceil() as u32)
.saturating_sub(1)
.min(n.saturating_sub(1));
let mut tiles = Vec::new();
for ty in y_min..=y_max {
for tx in x_min..=x_max {
tiles.push((z, tx, ty));
}
}
tiles
}
pub(crate) fn center_tile_xyz(&self) -> (u8, u32, u32) {
let z = self.zoom_level();
let wx = f64::midpoint(self.vp_x0, self.vp_x1);
let wy = f64::midpoint(self.vp_y0, self.vp_y1);
let (tx, ty) = Self::world_to_tile_xy(z, wx, wy);
(z, tx, ty)
}
pub(crate) fn set_viewport_to_tile(&mut self, z: u8, tx: u32, ty: u32) -> Result<(), String> {
let n = 1u32
.checked_shl(u32::from(z))
.ok_or_else(|| format!("zoom z={z} is too large"))?;
if tx >= n || ty >= n {
return Err(format!(
"tile index out of range for z={z}: x={tx} y={ty} (max {})",
n.saturating_sub(1)
));
}
let nf = f64::from(n);
self.vp_x0 = f64::from(tx) / nf;
self.vp_x1 = f64::from(tx + 1) / nf;
self.vp_y0 = f64::from(ty) / nf;
self.vp_y1 = f64::from(ty + 1) / nf;
self.sync_zoom_f_from_vp();
Ok(())
}
pub(crate) fn request_tile(&mut self, z: u8, x: u32, y: u32) {
let key = (z, x, y);
if !self.tiles.contains_key(&key) && !self.loading.contains(&key) {
self.loading.insert(key);
self.tiles.insert(key, MbtTileData::Loading);
if self
.request_tx
.try_send(TileLoadRequest::Load { z, x, y })
.is_err()
{
self.loading.remove(&key);
self.tiles.insert(
key,
MbtTileData::Error(
"Could not enqueue tile load (channel full or loader stopped)".into(),
),
);
}
}
}
pub(crate) fn request_tile_with_ancestors(&mut self, z: u8, x: u32, y: u32) {
let mut cz = z;
let mut cx = x;
let mut cy = y;
loop {
self.request_tile(cz, cx, cy);
if cz == 0 {
break;
}
cz -= 1;
cx >>= 1;
cy >>= 1;
}
}
pub(crate) fn find_overzoom_source(&self, z: u8, tx: u32, ty: u32) -> Option<(u8, u32, u32)> {
for k in 1..=z {
let pz = z - k;
let shift = u32::from(k);
let px = tx >> shift;
let py = ty >> shift;
let key = (pz, px, py);
if matches!(self.tiles.get(&key), Some(MbtTileData::Loaded { .. })) {
return Some(key);
}
}
None
}
pub(crate) fn effective_tile_key(&self, z: u8, tx: u32, ty: u32) -> Option<(u8, u32, u32)> {
let key = (z, tx, ty);
match self.tiles.get(&key) {
Some(MbtTileData::Loaded { .. }) => Some(key),
_ => self.find_overzoom_source(z, tx, ty),
}
}
pub(crate) fn process_results(&mut self) -> bool {
let mut changed = false;
if let Some(rx) = self.loader_init_rx.take() {
match rx.try_recv() {
Ok(Ok(())) => {
}
Ok(Err(msg)) => {
self.loader_fatal = Some(msg);
changed = true;
}
Err(TryRecvError::Empty) => {
self.loader_init_rx = Some(rx);
}
Err(TryRecvError::Disconnected) => {
if self.loader_fatal.is_none() {
self.loader_fatal =
Some("MBTiles loader thread exited during initialization".into());
}
changed = true;
}
}
}
while let Ok(res) = self.result_rx.try_recv() {
let key = (res.z, res.x, res.y);
self.loading.remove(&key);
let entry = match res.data {
Err(e) => MbtTileData::Error(e),
Ok(None) => MbtTileData::Empty,
Ok(Some(raw)) => match decode_and_parse(res.z, res.x, res.y, raw) {
Ok(Some((fc, extent, layer_groups, geo_index))) => MbtTileData::Loaded {
fc,
extent,
layer_groups,
geo_index,
},
Ok(None) => MbtTileData::Empty,
Err(e) => MbtTileData::Error(e.to_string()),
},
};
self.tiles.insert(key, entry);
changed = true;
}
changed
}
pub(crate) fn zoom_wheel_at(&mut self, wx: f64, wy: f64, zoom_in: bool) {
let dz = if zoom_in { 0.5 } else { -0.5 };
let new_z = (self.zoom_f + dz).clamp(0.0, 22.0);
if (new_z - self.zoom_f).abs() < 1e-12 {
return;
}
let scale = 2_f64.powf(-(new_z - self.zoom_f));
if !self.zoom_viewport_at(wx, wy, scale) {
return;
}
self.sync_zoom_f_from_vp();
}
fn zoom_viewport_at(&mut self, wx: f64, wy: f64, scale: f64) -> bool {
let x0 = wx + (self.vp_x0 - wx) * scale;
let x1 = wx + (self.vp_x1 - wx) * scale;
let y0 = wy + (self.vp_y0 - wy) * scale;
let y1 = wy + (self.vp_y1 - wy) * scale;
let min_size = 2_f64.powf(-22.0);
if x1 - x0 < min_size || y1 - y0 < min_size {
return false;
}
self.vp_x0 = x0;
self.vp_x1 = x1;
self.vp_y0 = y0;
self.vp_y1 = y1;
true
}
pub(crate) fn pan_by_pixels(&mut self, area_w: u16, area_h: u16, d_col: i32, d_row: i32) {
if area_w == 0 || area_h == 0 {
return;
}
let wf = f64::from(area_w);
let hf = f64::from(area_h);
let vp_w = self.vp_x1 - self.vp_x0;
let vp_h = self.vp_y1 - self.vp_y0;
let dx = f64::from(d_col) / wf * vp_w;
let dy = f64::from(d_row) / hf * vp_h;
self.vp_x0 -= dx;
self.vp_x1 -= dx;
self.vp_y0 -= dy;
self.vp_y1 -= dy;
self.sync_zoom_f_from_vp();
}
pub(crate) fn find_hovered(&mut self, wx: f64, wy: f64) {
let z = self.zoom_level();
let threshold = (self.vp_x1 - self.vp_x0) * 0.02;
let thresh_sq = threshold * threshold;
let pt = [wx, wy];
let mut best: BestHover = None;
let mut seen_src: HashSet<(u8, u32, u32)> = HashSet::new();
let visible = self.visible_tiles();
for (tz, tx, ty) in visible {
if tz != z {
continue;
}
let Some(src_key) = self.effective_tile_key(tz, tx, ty) else {
continue;
};
if !seen_src.insert(src_key) {
continue;
}
let Some(MbtTileData::Loaded { geo_index, .. }) = self.tiles.get(&src_key) else {
continue;
};
for e in geo_index.nearest_neighbor_iter(pt) {
let d = e.distance_2(&pt);
if d > thresh_sq {
break;
}
if best.is_none_or(|(bd, ..)| d < bd) {
best = Some((d, src_key, e.layer, e.feat));
}
}
}
let new_hov = best.map(|(_, tile, layer, feat)| MbtHoveredInfo {
tile,
layer_idx: layer,
feat_idx: feat,
});
self.hovered = new_hov;
}
fn keep_tile_keys(&self) -> HashSet<(u8, u32, u32)> {
let z = self.zoom_level();
let Some(n) = 1u32.checked_shl(u32::from(z)) else {
return HashSet::new();
};
let mut out = HashSet::new();
for (_zv, tx, ty) in self.visible_tiles() {
for dx in -1..=1 {
for dy in -1..=1 {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let nx = (i64::from(tx) + i64::from(dx))
.clamp(0, i64::from(n.saturating_sub(1)))
as u32;
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let ny = (i64::from(ty) + i64::from(dy))
.clamp(0, i64::from(n.saturating_sub(1)))
as u32;
let mut cz = z;
let mut cx = nx;
let mut cy = ny;
loop {
out.insert((cz, cx, cy));
if cz == 0 {
break;
}
cz -= 1;
cx >>= 1;
cy >>= 1;
}
}
}
}
out
}
pub(crate) fn prune_tile_cache_if_needed(&mut self) {
const MAX: usize = 256;
if self.tiles.len() <= MAX {
return;
}
let keep = self.keep_tile_keys();
let stale: Vec<(u8, u32, u32)> = self
.tiles
.keys()
.filter(|k| !keep.contains(k))
.copied()
.collect();
let stale_set: HashSet<_> = stale.iter().copied().collect();
if self
.hovered
.as_ref()
.is_some_and(|h| stale_set.contains(&h.tile))
{
self.hovered = None;
}
for k in stale {
self.loading.remove(&k);
self.tiles.remove(&k);
}
}
}
fn decode_and_parse(z: u8, tx: u32, ty: u32, raw: Vec<u8>) -> anyhow::Result<Option<DecodedTile>> {
let buf = decompress(raw)?;
if buf.is_empty() {
return Ok(None);
}
let fc = mvt_to_feature_collection(buf)?;
if fc.features.is_empty() {
return Ok(None);
}
let extent = fc
.features
.first()
.and_then(|f| f.properties.get("_extent"))
.and_then(serde_json::Value::as_u64)
.map_or(4096, |v| u32::try_from(v).unwrap_or(4096));
let layer_groups = group_by_layer(&fc);
let geo_index = build_world_geo_index(z, tx, ty, &fc, &layer_groups, extent);
Ok(Some((fc, extent, layer_groups, geo_index)))
}
fn decompress(raw: Vec<u8>) -> anyhow::Result<Vec<u8>> {
if raw.len() >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
Ok(decode_gzip(&raw)?)
} else if raw.len() >= 4 && raw[0] == 0x28 && raw[1] == 0xb5 && raw[2] == 0x2f && raw[3] == 0xfd
{
Ok(decode_zstd(&raw)?)
} else {
Ok(raw)
}
}
fn build_world_geo_index(
z: u8,
tx: u32,
ty: u32,
fc: &FeatureCollection,
layer_groups: &[LayerGroup],
extent: u32,
) -> RTree<MbtGeoEntry> {
let transform = TileTransform::new(z, tx, ty, extent);
let mut entries = Vec::new();
for (li, group) in layer_groups.iter().enumerate() {
for (fi, &gi) in group.feature_indices.iter().enumerate() {
let geom = &fc.features[gi].geometry;
let vertices = transform.geom_verts(geom);
if !vertices.is_empty() {
entries.push(MbtGeoEntry {
layer: li,
feat: fi,
vertices,
});
}
}
}
RTree::bulk_load(entries)
}