use std::{
any::Any,
collections::{HashMap, HashSet, VecDeque},
sync::Arc,
};
use anyhow::{Result, anyhow, bail};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style as TStyle},
widgets::Widget,
};
use serde_json::Value as Json;
use unicode_width::UnicodeWidthStr;
use crate::{
raster::{RasterSource, RasterStore, RasterTile},
expr::{Ctx, Rgba, Val},
geojson,
layer::{CustomLayer, DrawContext, PickContext, render_with},
mvt::GeomType,
pick::{Pick, hit, stats},
render::{Camera, Viewport, to_lonlat, to_world},
source::Source,
spec::{Kind, StyleLayer},
store::{self, TileId},
};
const TILE_CACHE: usize = 512;
const LAYER_PRIORITY: i32 = 10_000;
pub enum MapSource {
Vector(VectorSource),
GeoJson(GeoJsonSource),
Raster(RasterTiles),
}
impl MapSource {
pub fn vector(src: Source) -> Self {
let attribution = src.attribution.as_deref().map(crate::source::plain_attribution);
MapSource::Vector(VectorSource { min_zoom: src.min_zoom, max_zoom: src.max_zoom, attribution, promote_id: None, fetch: Some(Arc::new(src)), tiles: Tiles::default() })
}
pub fn vector_tiles(min_zoom: u8, max_zoom: u8) -> Self {
MapSource::Vector(VectorSource { min_zoom, max_zoom, attribution: None, promote_id: None, fetch: None, tiles: Tiles::default() })
}
pub fn promote_id(&self) -> Option<&str> {
match self {
MapSource::Vector(v) => v.promote_id.as_deref(),
MapSource::GeoJson(g) => g.promote_id.as_deref(),
MapSource::Raster(_) => None,
}
}
pub fn with_cluster(mut self, options: crate::cluster::ClusterOptions) -> Self {
if let MapSource::GeoJson(g) = &mut self {
g.set_cluster(Some(options));
}
self
}
pub fn with_promote_id(mut self, prop: impl Into<String>) -> Self {
match &mut self {
MapSource::Vector(v) => v.promote_id = Some(prop.into()),
MapSource::GeoJson(g) => g.promote_id = Some(prop.into()),
MapSource::Raster(_) => {}
}
self
}
pub fn attribution(&self) -> Option<&str> {
match self {
MapSource::Vector(v) => v.attribution.as_deref(),
MapSource::GeoJson(g) => g.attribution.as_deref(),
MapSource::Raster(r) => Some(r.src.attribution.as_str()),
}
.filter(|a| !a.trim().is_empty())
}
pub fn geojson(doc: &Json) -> Result<Self> {
Ok(MapSource::GeoJson(GeoJsonSource::parse(doc)?))
}
pub fn raster(src: Arc<RasterSource>) -> Self {
MapSource::Raster(RasterTiles { src, tiles: RasterStore::default() })
}
}
pub struct RasterTiles {
pub src: Arc<RasterSource>,
tiles: RasterStore,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum RasterMode {
#[default]
Cells,
Kitty {
cell_px: (f64, f64),
},
}
pub struct TileRequest {
pub source: String,
pub id: TileId,
fetch: Fetch,
}
enum Fetch {
Vector(Arc<Source>),
Raster(Arc<RasterSource>),
}
pub struct Loaded(LoadedTile);
enum LoadedTile {
Vector(Vec<store::Feature>),
Raster(Option<RasterTile>),
}
impl TileRequest {
pub fn is_raster(&self) -> bool {
matches!(self.fetch, Fetch::Raster(_))
}
pub async fn load(&self) -> Result<Loaded> {
let (z, x, y) = self.id;
Ok(Loaded(match &self.fetch {
Fetch::Vector(src) => match src.tile(z, x, y).await? {
Some(b) => LoadedTile::Vector(tokio::task::spawn_blocking(move || store::decode_features(&b)).await??),
None => LoadedTile::Vector(Vec::new()),
},
Fetch::Raster(src) => match src.tile(z, x, y).await? {
Some(b) => LoadedTile::Raster(Some(tokio::task::spawn_blocking(move || crate::raster::decode(&b)).await??)),
None => LoadedTile::Raster(None),
},
}))
}
}
pub struct KittyState {
sent: HashMap<u32, (usize, u64)>,
frame: u64,
pub budget: usize,
}
impl Default for KittyState {
fn default() -> Self {
Self { sent: HashMap::new(), frame: 0, budget: 48 << 20 }
}
}
impl KittyState {
pub fn bytes(&self) -> usize {
self.sent.values().map(|(b, _)| b).sum()
}
}
impl From<geojson::GeoJson> for MapSource {
fn from(g: geojson::GeoJson) -> Self {
MapSource::GeoJson(GeoJsonSource::new(g.features, g.bbox))
}
}
pub struct VectorSource {
pub min_zoom: u8,
pub max_zoom: u8,
pub promote_id: Option<String>,
pub attribution: Option<String>,
fetch: Option<Arc<Source>>,
tiles: Tiles,
}
pub struct GeoJsonSource {
data: crate::cluster::Level,
pub bbox: [f64; 4],
pub attribution: Option<String>,
pub promote_id: Option<String>,
clusters: Option<crate::cluster::Clusters>,
}
impl GeoJsonSource {
pub fn new(features: Vec<geojson::Feature>, bbox: [f64; 4]) -> Self {
Self { data: crate::cluster::Level::new(features), bbox, attribution: None, promote_id: None, clusters: None }
}
pub fn parse(doc: &Json) -> Result<Self> {
let (features, bbox) = geojson::features(doc)?;
Ok(Self::new(features, bbox))
}
pub fn features(&self) -> &[geojson::Feature] {
&self.data.features
}
pub fn set_cluster(&mut self, options: Option<crate::cluster::ClusterOptions>) {
self.clusters = options.map(|o| crate::cluster::Clusters::build(&self.data.features, o));
}
pub fn cluster_options(&self) -> Option<crate::cluster::ClusterOptions> {
self.clusters.as_ref().map(|c| c.options)
}
fn at(&self, zoom: f64) -> &crate::cluster::Level {
self.clusters.as_ref().and_then(|c| c.at(zoom)).unwrap_or(&self.data)
}
fn near(&self, zoom: f64, q: crate::index::BBox) -> impl Iterator<Item = &geojson::Feature> {
let level = self.at(zoom);
level.index.search(q).into_iter().map(move |i| &level.features[i])
}
}
#[derive(Clone, Debug)]
enum Resolved {
Fill { color: Option<Color>, outline: Option<Color> },
Line { color: Color, width: u8, dash: Option<Vec<f64>> },
Point { glyph: String, style: TStyle, label: Option<(String, TStyle)>, priority: i32 },
Text { text: String, style: TStyle, anchors: Vec<(i8, i8)>, offset: (f64, f64), along_line: bool, spacing: f64, priority: i32 },
}
struct Item {
layer: u16,
feature: u32,
paint: Resolved,
}
struct MapTile {
features: Arc<Vec<store::Feature>>,
items: Vec<Item>,
by_layer: HashMap<Arc<str>, Vec<u32>>,
zoomed: std::sync::Mutex<ZoomedItems>,
}
type ZoomedItems = HashMap<u16, (ZoomKey, u64, Arc<Vec<Item>>)>;
type ZoomKey = (i64, i64);
fn zoom_key(zoom: f64) -> ZoomKey {
(zoom.floor() as i64, (zoom * 20.0).round() as i64)
}
impl MapTile {
fn new(features: Arc<Vec<store::Feature>>, items: Vec<Item>) -> Self {
let mut by_layer: HashMap<Arc<str>, Vec<u32>> = HashMap::new();
for (i, f) in features.iter().enumerate() {
by_layer.entry(f.layer.clone()).or_default().push(i as u32);
}
Self { features, items, by_layer, zoomed: Default::default() }
}
fn items_of(&self, layer: usize) -> &[Item] {
let lo = self.items.partition_point(|i| (i.layer as usize) < layer);
let hi = self.items.partition_point(|i| (i.layer as usize) <= layer);
&self.items[lo..hi]
}
fn layer_items(&self, i: usize, l: &StyleLayer, zoom: f64, bg: Option<Rgba>, env: StateEnv) -> LayerItems<'_> {
let stateful = l.uses_state();
if !stateful && !l.uses_zoom() {
return LayerItems::Cached(self.items_of(i));
}
let key = zoom_key(zoom);
let version = if stateful { env.version } else { 0 };
let mut cache = self.zoomed.lock().unwrap_or_else(|e| e.into_inner());
if let Some((k, v, items)) = cache.get(&(i as u16))
&& *k == key
&& *v == version
{
return LayerItems::Zoomed(items.clone());
}
let mut items = Vec::new();
if let Some(fs) = l.source_layer.as_deref().and_then(|s| self.by_layer.get(s)) {
for &fi in fs {
let f = &self.features[fi as usize];
let c = Ctx { zoom: zoom.floor(), geom: f.geom, props: &f.props, state: env.of(&f.id, &f.props) };
if let Some(paint) = resolve(l, &c, zoom, bg) {
items.push(Item { layer: i as u16, feature: fi, paint });
}
}
}
let items = Arc::new(items);
cache.insert(i as u16, (key, version, items.clone()));
LayerItems::Zoomed(items)
}
}
enum LayerItems<'a> {
Cached(&'a [Item]),
Zoomed(Arc<Vec<Item>>),
}
impl std::ops::Deref for LayerItems<'_> {
type Target = [Item];
fn deref(&self) -> &[Item] {
match self {
LayerItems::Cached(s) => s,
LayerItems::Zoomed(v) => v,
}
}
}
#[derive(Default)]
struct Tiles {
map: HashMap<TileId, Arc<MapTile>>,
order: VecDeque<TileId>,
}
impl Tiles {
fn insert(&mut self, id: TileId, t: MapTile) {
if self.map.insert(id, Arc::new(t)).is_none() {
self.order.push_back(id);
}
while self.order.len() > TILE_CACHE {
if let Some(old) = self.order.pop_front() {
self.map.remove(&old);
}
}
}
fn get_or_ancestor(&self, id: TileId) -> Option<(TileId, Arc<MapTile>)> {
let (z, x, y) = id;
(0..=4u8.min(z)).find_map(|k| {
let a = (z - k, x >> k, y >> k);
self.map.get(&a).map(|t| (a, t.clone()))
})
}
fn visible(&self, vp: &Viewport, z: u8) -> Vec<(TileId, Arc<MapTile>)> {
let mut seen = HashSet::new();
let mut out: Vec<(TileId, Arc<MapTile>)> =
vp.tiles(z).into_iter().filter_map(|id| self.get_or_ancestor(id)).filter(|(id, _)| seen.insert(*id)).collect();
out.sort_by_key(|(id, _)| id.0);
out
}
}
trait OwnedCustom: CustomLayer + Any + Send {}
impl<T: CustomLayer + Any + Send> OwnedCustom for T {}
enum Entry {
Style(StyleLayer),
Custom { id: String, layer: Box<dyn OwnedCustom> },
}
impl Entry {
fn id(&self) -> &str {
match self {
Entry::Style(l) => &l.id,
Entry::Custom { id, .. } => id,
}
}
}
#[derive(Default)]
pub struct Map {
sources: Vec<(String, MapSource)>,
layers: Vec<Entry>,
warnings: Vec<String>,
pub raster_mode: RasterMode,
pub center: Option<(f64, f64, f64)>,
pub retry: RetryPolicy,
failed: HashMap<(String, TileId), (u32, std::time::Instant)>,
states: HashMap<String, HashMap<String, Vec<(String, Val)>>>,
state_version: u64,
}
fn feature_id(promote: Option<&str>, id: &Option<crate::mvt::Value>, props: &[(String, crate::mvt::Value)]) -> Option<crate::mvt::Value> {
match promote {
Some(p) => props.iter().find(|(k, _)| k == p).map(|(_, v)| v.clone()),
None => id.clone(),
}
}
#[derive(Clone, Copy)]
struct StateEnv<'a> {
states: Option<&'a HashMap<String, Vec<(String, Val)>>>,
promote: Option<&'a str>,
version: u64,
}
impl<'a> StateEnv<'a> {
fn of(&self, id: &Option<crate::mvt::Value>, props: &[(String, crate::mvt::Value)]) -> &'a [(String, Val)] {
let Some(states) = self.states else { return &[] };
feature_id(self.promote, id, props).and_then(|id| states.get(&id.to_string())).map_or(&[], |v| v.as_slice())
}
}
#[derive(Clone, Copy, Debug)]
pub struct RetryPolicy {
pub base: std::time::Duration,
pub max: std::time::Duration,
pub attempts: u32,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self { base: std::time::Duration::from_secs(2), max: std::time::Duration::from_secs(60), attempts: 5 }
}
}
impl Map {
pub fn new() -> Self {
Self::default()
}
pub fn add_source(&mut self, id: impl Into<String>, src: MapSource) -> Result<()> {
let id = id.into();
if self.source(&id).is_some() {
bail!("source \"{id}\" already exists");
}
self.sources.push((id, src));
self.restyle();
Ok(())
}
pub fn add_map(&mut self, other: Map, before: Option<&str>) -> Result<Vec<String>> {
for (id, _) in &other.sources {
if self.source(id).is_some() {
bail!("source \"{id}\" already exists");
}
}
for e in &other.layers {
if self.index(e.id()).is_some() {
bail!("layer \"{}\" already exists", e.id());
}
}
let at = match before {
Some(b) => self.index(b).ok_or_else(|| anyhow!("no layer \"{b}\" to add before"))?,
None => self.layers.len(),
};
if self.layers.len() + other.layers.len() >= u16::MAX as usize {
bail!("too many layers");
}
let ids: Vec<String> = other.layers.iter().map(|e| e.id().to_string()).collect();
self.sources.extend(other.sources);
self.layers.splice(at..at, other.layers);
self.warnings.extend(other.warnings);
self.restyle();
Ok(ids)
}
pub fn remove_source(&mut self, id: &str) -> bool {
let n = self.sources.len();
self.sources.retain(|(s, _)| s != id);
n != self.sources.len()
}
pub fn sources(&self) -> impl Iterator<Item = (&str, &MapSource)> {
self.sources.iter().map(|(id, s)| (id.as_str(), s))
}
pub fn source(&self, id: &str) -> Option<&MapSource> {
self.sources.iter().find(|(s, _)| s == id).map(|(_, s)| s)
}
pub fn set_attribution(&mut self, id: &str, text: &str) -> Result<()> {
let text = crate::source::plain_attribution(text);
let value = (!text.is_empty()).then_some(text);
match self.sources.iter_mut().find(|(s, _)| s == id) {
Some((_, MapSource::Vector(v))) => v.attribution = value,
Some((_, MapSource::GeoJson(g))) => g.attribution = value,
Some((_, MapSource::Raster(r))) => match Arc::get_mut(&mut r.src) {
Some(src) => src.attribution = value.unwrap_or_default(),
None => bail!("raster source \"{id}\" is shared; set its attribution before adding it"),
},
None => bail!("no source \"{id}\""),
}
Ok(())
}
pub fn set_feature_state(&mut self, source: &str, id: &crate::mvt::Value, state: &Json) -> Result<()> {
if self.source(source).is_none() {
bail!("no source \"{source}\"");
}
let Some(obj) = state.as_object() else { bail!("feature state must be a JSON object") };
let entry = self.states.entry(source.to_string()).or_default().entry(id.to_string()).or_default();
for (k, v) in obj {
let v = crate::expr::Expr::parse(&serde_json::json!(["literal", v])).map(|e| e.eval(&Ctx { zoom: 0.0, geom: GeomType::Unknown, props: &[], state: &[] }))?;
match entry.iter_mut().find(|(key, _)| key == k) {
Some(slot) => slot.1 = v,
None => entry.push((k.clone(), v)),
}
}
self.state_version += 1;
Ok(())
}
pub fn remove_feature_state(&mut self, source: &str, id: Option<&crate::mvt::Value>, key: Option<&str>) {
let Some(by_id) = self.states.get_mut(source) else { return };
match (id, key) {
(None, _) => {
self.states.remove(source);
}
(Some(id), None) => {
by_id.remove(&id.to_string());
}
(Some(id), Some(k)) => {
if let Some(st) = by_id.get_mut(&id.to_string()) {
st.retain(|(key, _)| key != k);
}
}
}
self.state_version += 1;
}
pub fn feature_state(&self, source: &str, id: &crate::mvt::Value) -> Json {
let mut m = serde_json::Map::new();
if let Some(st) = self.states.get(source).and_then(|s| s.get(&id.to_string())) {
for (k, v) in st {
m.insert(k.clone(), val_json(v));
}
}
Json::Object(m)
}
fn state_env(&self, source: &str) -> StateEnv<'_> {
StateEnv { states: self.states.get(source), promote: self.source(source).and_then(MapSource::promote_id), version: self.state_version }
}
pub fn attributions(&self, zoom: f64) -> Vec<String> {
let used: HashSet<&str> = self
.layers
.iter()
.filter_map(|e| match e {
Entry::Style(l) if l.shown_at(zoom) && l.kind != Kind::Background => l.source.as_deref(),
_ => None,
})
.collect();
let mut out: Vec<String> = Vec::new();
for (id, s) in &self.sources {
if let Some(a) = s.attribution().filter(|_| used.contains(id.as_str()))
&& !out.iter().any(|o| o == a)
{
out.push(a.to_string());
}
}
out
}
pub fn cluster_expansion_zoom(&self, source: &str, cluster_id: u64) -> Option<f64> {
match self.source(source)? {
MapSource::GeoJson(g) => g.clusters.as_ref()?.expansion_zoom(cluster_id).map(f64::from),
_ => None,
}
}
pub fn set_data(&mut self, id: &str, doc: &Json) -> Result<()> {
let mut data = GeoJsonSource::parse(doc)?;
match self.sources.iter_mut().find(|(s, _)| s == id) {
Some((_, MapSource::GeoJson(g))) => {
data.attribution = g.attribution.take();
data.promote_id = g.promote_id.take();
data.set_cluster(g.cluster_options());
*g = data;
}
Some(_) => bail!("source \"{id}\" is not a GeoJSON source"),
None => bail!("no source \"{id}\""),
}
Ok(())
}
pub fn source_bounds(&self, id: &str) -> Option<(f64, f64, f64, f64)> {
match self.source(id)? {
MapSource::GeoJson(g) if !g.features().is_empty() => {
let (w, n) = to_lonlat(g.bbox[0], g.bbox[1]);
let (e, s) = to_lonlat(g.bbox[2], g.bbox[3]);
Some((w, s, e, n))
}
_ => None,
}
}
pub fn tile_requests(&self, cam: &Camera, area: Rect) -> Vec<TileRequest> {
let vp = Viewport::new(cam, area);
let used: HashSet<&str> = self
.layers
.iter()
.filter_map(|e| match e {
Entry::Style(l) if l.shown_at(cam.zoom) => l.source.as_deref(),
_ => None,
})
.collect();
let mut out = Vec::new();
for (id, src) in &self.sources {
if !used.contains(id.as_str()) {
continue;
}
match src {
MapSource::Vector(v) => {
let Some(f) = &v.fetch else { continue };
let z = cam.tile_zoom(v.min_zoom, v.max_zoom);
out.extend(vp.tiles(z).into_iter().filter(|t| !v.tiles.map.contains_key(t) && self.due(id, t)).map(|t| TileRequest {
source: id.clone(),
id: t,
fetch: Fetch::Vector(f.clone()),
}));
}
MapSource::Raster(r) => {
let rz = self.raster_zoom(&r.src, cam);
out.extend(crate::raster::tiles_for(cam, area, rz).into_iter().filter(|t| !r.tiles.contains(t) && self.due(id, t)).map(|t| TileRequest {
source: id.clone(),
id: t,
fetch: Fetch::Raster(r.src.clone()),
}));
}
MapSource::GeoJson(_) => {}
}
}
out
}
fn raster_zoom(&self, src: &RasterSource, cam: &Camera) -> u8 {
let dots_per_px = match self.raster_mode {
RasterMode::Kitty { cell_px } => 2.0 / (cell_px.0 / 2.0).max(0.5),
RasterMode::Cells => 2.0,
};
crate::raster::raster_zoom(cam, src.tile_px, dots_per_px, src.max_zoom)
}
pub fn insert_loaded(&mut self, source: &str, id: TileId, loaded: Result<Loaded>) -> Result<()> {
let is_raster = matches!(self.source(source), Some(MapSource::Raster(_)));
let key = (source.to_string(), id);
let (tile, err) = match loaded {
Ok(Loaded(t)) => {
self.failed.remove(&key);
(t, None)
}
Err(e) => {
let e = e.context(format!("{source} tile {}/{}/{}", id.0, id.1, id.2));
let attempts = self.failed.get(&key).map_or(0, |f| f.0) + 1;
if attempts < self.retry.attempts {
let wait = self.retry.base.saturating_mul(1 << (attempts - 1).min(16)).min(self.retry.max);
self.failed.insert(key, (attempts, std::time::Instant::now() + wait));
return Err(e);
}
self.failed.remove(&key);
(if is_raster { LoadedTile::Raster(None) } else { LoadedTile::Vector(Vec::new()) }, Some(e))
}
};
match tile {
LoadedTile::Vector(features) => self.insert_features(source, id, features)?,
LoadedTile::Raster(t) => match self.sources.iter_mut().find(|(s, _)| s == source) {
Some((_, MapSource::Raster(r))) => r.tiles.insert(id, t),
_ => bail!("no raster source \"{source}\""),
},
}
err.map_or(Ok(()), Err)
}
fn due(&self, source: &str, id: &TileId) -> bool {
self.failed.get(&(source.to_string(), *id)).is_none_or(|(_, at)| std::time::Instant::now() >= *at)
}
pub async fn load_tiles(&mut self, cam: &Camera, area: Rect) -> Result<()> {
let mut first_err = None;
for req in self.tile_requests(cam, area) {
let loaded = req.load().await;
if let Err(e) = self.insert_loaded(&req.source, req.id, loaded) {
first_err.get_or_insert(e);
}
}
first_err.map_or(Ok(()), Err)
}
pub fn zoom_range(&self) -> (u8, u8) {
self.sources
.iter()
.filter_map(|(_, s)| match s {
MapSource::Vector(v) => Some((v.min_zoom, v.max_zoom)),
_ => None,
})
.reduce(|a, b| (a.0.min(b.0), a.1.max(b.1)))
.unwrap_or((0, 14))
}
pub fn kitty_commands(&mut self, cam: &Camera, area: Rect, state: &mut KittyState) -> String {
use crate::kitty;
let RasterMode::Kitty { .. } = self.raster_mode else { return String::new() };
state.frame += 1;
let mut cmds = String::new();
for (_, s) in &mut self.sources {
if let MapSource::Raster(r) = s {
for id in r.tiles.take_evicted() {
if state.sent.remove(&id).is_some() {
cmds.push_str(&kitty::free(id));
}
}
}
}
let mut pieces = Vec::new();
for (i, e) in self.layers.iter().enumerate() {
let Entry::Style(l) = e else { continue };
if l.kind != Kind::Raster || !l.shown_at(cam.zoom) {
continue;
}
let Some(MapSource::Raster(r)) = l.source.as_deref().and_then(|s| self.source(s)) else { continue };
let rz = self.raster_zoom(&r.src, cam);
pieces.extend(crate::raster::layout_max(&r.tiles, cam, area, rz, (32, 16)).into_iter().map(|b| (i, b)));
}
if pieces.is_empty() && state.sent.is_empty() {
return cmds;
}
let needed: HashSet<u32> = pieces.iter().map(|(_, b)| b.tile.id).collect();
let mut new_bytes = 0;
let mut counted = HashSet::new();
for (_, b) in &pieces {
if !state.sent.contains_key(&b.tile.id) && counted.insert(b.tile.id) {
let (w, h) = b.tile.size();
new_bytes += w as usize * h as usize * 4;
}
}
let mut total = state.bytes();
if total + new_bytes > state.budget {
let mut old: Vec<(u64, u32, usize)> =
state.sent.iter().filter(|(id, _)| !needed.contains(id)).map(|(id, (b, f))| (*f, *id, *b)).collect();
old.sort_unstable();
for (_, id, b) in old {
if total + new_bytes <= state.budget {
break;
}
state.sent.remove(&id);
cmds.push_str(&kitty::free(id));
total -= b;
}
}
cmds.push_str(kitty::CLEAR_PLACEMENTS);
for (placement, (i, b)) in pieces.iter().enumerate() {
let (w, h) = b.tile.size();
let frame = state.frame;
match state.sent.get_mut(&b.tile.id) {
Some(entry) => entry.1 = frame,
None => {
state.sent.insert(b.tile.id, (w as usize * h as usize * 4, frame));
cmds.push_str(&kitty::transmit(b.tile.id, &b.tile.png));
}
}
cmds.push_str(&kitty::place(b.tile.id, placement as u32 + 1, b.cells.x, b.cells.y, b.src, b.cells.width, b.cells.height, -1000 + *i as i32));
}
cmds
}
pub fn insert_tile(&mut self, source: &str, id: TileId, bytes: Option<&[u8]>) -> Result<()> {
let features = match bytes {
Some(b) => store::decode_features(b),
None => Ok(Vec::new()),
};
let (features, err) = match features {
Ok(f) => (f, None),
Err(e) => (Vec::new(), Some(e)),
};
self.insert_features(source, id, features)?;
err.map_or(Ok(()), |e| Err(e.context(format!("tile {}/{}/{}", id.0, id.1, id.2))))
}
fn insert_features(&mut self, source: &str, id: TileId, features: Vec<store::Feature>) -> Result<()> {
let features = Arc::new(features);
let items = style_tile(&self.layers, source, &features, id.0 as f64);
match self.sources.iter_mut().find(|(s, _)| s == source) {
Some((_, MapSource::Vector(v))) => v.tiles.insert(id, MapTile::new(features, items)),
_ => bail!("no vector source \"{source}\""),
}
Ok(())
}
pub fn add_layer(&mut self, layer: StyleLayer) -> Result<()> {
self.insert_entry(Entry::Style(layer), None)
}
pub fn add_layer_before(&mut self, layer: StyleLayer, before: &str) -> Result<()> {
self.insert_entry(Entry::Style(layer), Some(before))
}
pub fn add_custom_layer<T: CustomLayer + Any + Send>(&mut self, id: impl Into<String>, layer: T, before: Option<&str>) -> Result<()> {
self.insert_entry(Entry::Custom { id: id.into(), layer: Box::new(layer) }, before)
}
pub fn add_layers(&mut self, layers: Vec<StyleLayer>, before: Option<&str>) -> Result<()> {
let mut ids = HashSet::new();
for l in &layers {
if self.index(&l.id).is_some() || !ids.insert(l.id.as_str()) {
bail!("layer \"{}\" already exists", l.id);
}
}
if self.layers.len() + layers.len() >= u16::MAX as usize {
bail!("too many layers");
}
let at = match before {
Some(b) => self.index(b).ok_or_else(|| anyhow!("no layer \"{b}\" to add before"))?,
None => self.layers.len(),
};
self.layers.splice(at..at, layers.into_iter().map(Entry::Style));
self.restyle();
Ok(())
}
pub fn remove_layers(&mut self, ids: &[&str]) -> usize {
let n = self.layers.len();
self.layers.retain(|e| !ids.contains(&e.id()));
let removed = n - self.layers.len();
if removed > 0 {
self.restyle();
}
removed
}
fn insert_entry(&mut self, e: Entry, before: Option<&str>) -> Result<()> {
if self.index(e.id()).is_some() {
bail!("layer \"{}\" already exists", e.id());
}
if self.layers.len() >= u16::MAX as usize {
bail!("too many layers");
}
let at = match before {
Some(b) => self.index(b).ok_or_else(|| anyhow!("no layer \"{b}\" to add before"))?,
None => self.layers.len(),
};
self.layers.insert(at, e);
self.restyle();
Ok(())
}
pub fn remove_layer(&mut self, id: &str) -> bool {
let Some(i) = self.index(id) else { return false };
self.layers.remove(i);
self.restyle();
true
}
pub fn move_layer(&mut self, id: &str, before: Option<&str>) -> Result<()> {
let i = self.index(id).ok_or_else(|| anyhow!("no layer \"{id}\""))?;
let e = self.layers.remove(i);
let at = match before {
Some(b) => match self.index(b) {
Some(at) => at,
None => {
self.layers.insert(i, e);
bail!("no layer \"{b}\"");
}
},
None => self.layers.len(),
};
self.layers.insert(at, e);
self.restyle();
Ok(())
}
pub fn layer_ids(&self) -> Vec<&str> {
self.layers.iter().map(Entry::id).collect()
}
pub fn layer(&self, id: &str) -> Option<&StyleLayer> {
match &self.layers[self.index(id)?] {
Entry::Style(l) => Some(l),
Entry::Custom { .. } => None,
}
}
pub fn custom_layer_mut<T: CustomLayer + Any + Send>(&mut self, id: &str) -> Option<&mut T> {
let i = self.index(id)?;
match &mut self.layers[i] {
Entry::Custom { layer, .. } => (&mut **layer as &mut dyn Any).downcast_mut::<T>(),
Entry::Style(_) => None,
}
}
pub fn set_paint(&mut self, id: &str, name: &str, v: &Json) -> Result<()> {
self.style_mut(id)?.set_paint(name, v)?;
self.restyle();
Ok(())
}
pub fn set_layout(&mut self, id: &str, name: &str, v: &Json) -> Result<()> {
self.style_mut(id)?.set_layout(name, v)?;
self.restyle();
Ok(())
}
pub fn set_filter(&mut self, id: &str, f: Option<&Json>) -> Result<()> {
let e = f.map(crate::expr::Expr::parse).transpose()?;
self.style_mut(id)?.filter = e;
self.restyle();
Ok(())
}
pub fn set_visibility(&mut self, id: &str, visible: bool) -> Result<()> {
self.style_mut(id)?.visible = visible;
Ok(())
}
pub fn set_zoom_range(&mut self, id: &str, min: f64, max: f64) -> Result<()> {
let l = self.style_mut(id)?;
(l.min_zoom, l.max_zoom) = (min, max);
Ok(())
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub(crate) fn warn(&mut self, w: String) {
self.warnings.push(w);
}
fn index(&self, id: &str) -> Option<usize> {
self.layers.iter().position(|e| e.id() == id)
}
fn style_mut(&mut self, id: &str) -> Result<&mut StyleLayer> {
let i = self.index(id).ok_or_else(|| anyhow!("no layer \"{id}\""))?;
match &mut self.layers[i] {
Entry::Style(l) => Ok(l),
Entry::Custom { .. } => bail!("\"{id}\" is a custom layer"),
}
}
fn restyle(&mut self) {
let layers = &self.layers;
for (sid, src) in &mut self.sources {
if let MapSource::Vector(v) = src {
let ids: Vec<TileId> = v.tiles.map.keys().copied().collect();
for id in ids {
let features = v.tiles.map[&id].features.clone();
let items = style_tile(layers, sid, &features, id.0 as f64);
v.tiles.map.insert(id, Arc::new(MapTile::new(features, items)));
}
}
}
}
pub fn view<'a>(&'a self, camera: &'a Camera) -> MapView<'a> {
MapView { map: self, camera }
}
pub fn query_rendered_features(&self, cam: &Camera, area: Rect, target: (f64, f64), layers: Option<&[&str]>) -> Vec<Pick> {
let (w, h) = (area.width as f64 * 2.0, area.height as f64 * 4.0);
if target.0 < 0.0 || target.1 < 0.0 || target.0 >= w || target.1 >= h {
return Vec::new();
}
let vp = Viewport::new(cam, area);
let bg = self.background(cam.zoom);
let mut out = Vec::new();
for (i, entry) in self.layers.iter().enumerate().rev() {
if layers.is_some_and(|ids| !ids.contains(&entry.id())) {
continue;
}
let l = match entry {
Entry::Style(l) => l,
Entry::Custom { id, layer } => {
let ctx = PickContext { camera: cam, area, viewport: vp, target };
for mut p in layer.pick(&ctx) {
p.style_layer.get_or_insert_with(|| Arc::from(id.as_str()));
out.push(p);
}
continue;
}
};
if !l.shown_at(cam.zoom) || matches!(l.kind, Kind::Background | Kind::Raster | Kind::Other(_)) {
continue;
}
let Some(sid) = l.source.as_deref() else { continue };
let env = self.state_env(sid);
let style_layer: Arc<str> = Arc::from(l.id.as_str());
let source: Arc<str> = Arc::from(sid);
let edges = l.kind == Kind::Line;
let mut hits: Vec<(u8, f64, Pick)> = Vec::new();
let mut push = |geom: GeomType, dots: &[Vec<(f64, f64)>], lonlat: Vec<Vec<(f64, f64)>>, layer: Arc<str>, props: &[(String, crate::mvt::Value)], tile: Option<TileId>, id: Option<crate::mvt::Value>| {
if let Some((rank, score)) = hit(geom, dots, target, edges) {
let st = stats(geom, &lonlat);
let score = if geom == GeomType::Polygon { st.area_km2.unwrap_or(0.0) } else { score };
let pick = Pick {
tile,
layer,
geom,
props: props.to_vec(),
lonlat,
drawn: true,
stats: st,
source: Some(source.clone()),
style_layer: Some(style_layer.clone()),
id,
};
hits.push((rank, score, pick));
}
};
match self.source(sid) {
Some(MapSource::Vector(v)) => {
let z = cam.tile_zoom(v.min_zoom, v.max_zoom);
let n = (1u64 << z) as f64;
let (wx, wy) = {
let (lon, lat) = vp.dot_to_lonlat(target.0, target.1);
to_world(lon, lat)
};
let id: TileId = (z, (wx * n).floor().clamp(0.0, n - 1.0) as u32, (wy * n).floor().clamp(0.0, n - 1.0) as u32);
let Some((tid, tile)) = v.tiles.get_or_ancestor(id) else { continue };
let tn = (1u64 << tid.0) as f64;
let world = |p: &[f32; 2]| ((tid.1 as f64 + p[0] as f64) / tn, (tid.2 as f64 + p[1] as f64) / tn);
let mut seen = HashSet::new();
for it in tile.layer_items(i, l, cam.zoom, bg, env).iter() {
let f = &tile.features[it.feature as usize];
let dots: Vec<Vec<(f64, f64)>> = f.parts.iter().map(|part| part.iter().map(|p| { let (x, y) = world(p); vp.dot(x, y) }).collect()).collect();
if hit(f.geom, &dots, target, edges).is_none() || !seen.insert(format!("{:?}{:?}", f.geom, f.props)) {
continue;
}
let lonlat = f.parts.iter().map(|part| part.iter().map(|p| { let (x, y) = world(p); to_lonlat(x, y) }).collect()).collect();
push(f.geom, &dots, lonlat, f.layer.clone(), &f.props, Some(tid), feature_id(env.promote, &f.id, &f.props));
}
}
Some(MapSource::GeoJson(g)) => {
for f in g.near(cam.zoom, near_box(&vp, target)) {
let c = Ctx { zoom: cam.zoom.floor(), geom: f.geom, props: &f.props, state: env.of(&f.id, &f.props) };
if resolve(l, &c, cam.zoom, bg).is_none() {
continue;
}
let dots: Vec<Vec<(f64, f64)>> = f.parts.iter().map(|p| p.iter().map(|&(x, y)| vp.dot(x, y)).collect()).collect();
let lonlat = f.parts.iter().map(|p| p.iter().map(|&(x, y)| to_lonlat(x, y)).collect()).collect();
push(f.geom, &dots, lonlat, source.clone(), &f.props, None, feature_id(env.promote, &f.id, &f.props));
}
}
_ => {}
}
hits.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
out.extend(hits.into_iter().map(|(_, _, p)| p));
}
out
}
pub fn query_features(&self, cam: &Camera, area: Rect, target: (f64, f64)) -> Vec<Pick> {
let (w, h) = (area.width as f64 * 2.0, area.height as f64 * 4.0);
if target.0 < 0.0 || target.1 < 0.0 || target.0 >= w || target.1 >= h {
return Vec::new();
}
let vp = Viewport::new(cam, area);
let bg = self.background(cam.zoom);
let top_layer = |sid: &str| {
self.layers.iter().rposition(|e| matches!(e, Entry::Style(l) if l.source.as_deref() == Some(sid)))
};
let mut order: Vec<&(String, MapSource)> = self.sources.iter().collect();
order.sort_by_key(|(id, _)| std::cmp::Reverse(top_layer(id).map_or(-1, |i| i as i64)));
let mut out = Vec::new();
for (sid, src) in order {
let source: Arc<str> = Arc::from(sid.as_str());
let env = self.state_env(sid);
let mut hits: Vec<(u8, f64, Pick)> = Vec::new();
let mut push = |geom: GeomType, rank: u8, score: f64, lonlat: Vec<Vec<(f64, f64)>>, layer: Arc<str>, props: &[(String, crate::mvt::Value)], tile: Option<TileId>, by: Option<usize>, id: Option<crate::mvt::Value>| {
let st = stats(geom, &lonlat);
let score = if geom == GeomType::Polygon { st.area_km2.unwrap_or(0.0) } else { score };
let style_layer = by.map(|i| Arc::from(self.layers[i].id()));
let pick = Pick { tile, layer, geom, props: props.to_vec(), lonlat, drawn: by.is_some(), stats: st, source: Some(source.clone()), style_layer, id };
hits.push((rank, score, pick));
};
match src {
MapSource::Vector(v) => {
let z = cam.tile_zoom(v.min_zoom, v.max_zoom);
let n = (1u64 << z) as f64;
let (wx, wy) = {
let (lon, lat) = vp.dot_to_lonlat(target.0, target.1);
to_world(lon, lat)
};
let id: TileId = (z, (wx * n).floor().clamp(0.0, n - 1.0) as u32, (wy * n).floor().clamp(0.0, n - 1.0) as u32);
let Some((tid, tile)) = v.tiles.get_or_ancestor(id) else { continue };
let mut drawn_by: HashMap<u32, usize> = HashMap::new();
for (i, e) in self.layers.iter().enumerate() {
if let Entry::Style(l) = e
&& l.source.as_deref() == Some(sid.as_str())
&& l.shown_at(cam.zoom)
{
for it in tile.layer_items(i, l, cam.zoom, bg, env).iter() {
drawn_by.insert(it.feature, i);
}
}
}
let tn = (1u64 << tid.0) as f64;
let world = |p: &[f32; 2]| ((tid.1 as f64 + p[0] as f64) / tn, (tid.2 as f64 + p[1] as f64) / tn);
let mut seen = HashSet::new();
for (fi, f) in tile.features.iter().enumerate() {
let dots: Vec<Vec<(f64, f64)>> = f.parts.iter().map(|part| part.iter().map(|p| { let (x, y) = world(p); vp.dot(x, y) }).collect()).collect();
let Some((rank, score)) = hit(f.geom, &dots, target, false) else { continue };
if !seen.insert(format!("{}|{:?}|{:?}", f.layer, f.geom, f.props)) {
continue;
}
let lonlat = f.parts.iter().map(|part| part.iter().map(|p| { let (x, y) = world(p); to_lonlat(x, y) }).collect()).collect();
push(f.geom, rank, score, lonlat, f.layer.clone(), &f.props, Some(tid), drawn_by.get(&(fi as u32)).copied(), feature_id(env.promote, &f.id, &f.props));
}
}
MapSource::GeoJson(g) => {
for f in g.near(cam.zoom, near_box(&vp, target)) {
let dots: Vec<Vec<(f64, f64)>> = f.parts.iter().map(|p| p.iter().map(|&(x, y)| vp.dot(x, y)).collect()).collect();
let Some((rank, score)) = hit(f.geom, &dots, target, true) else { continue };
let c = Ctx { zoom: cam.zoom.floor(), geom: f.geom, props: &f.props, state: env.of(&f.id, &f.props) };
let by = self.layers.iter().rposition(|e| {
matches!(e, Entry::Style(l) if l.source.as_deref() == Some(sid.as_str()) && l.shown_at(cam.zoom) && resolve(l, &c, cam.zoom, bg).is_some())
});
let lonlat = f.parts.iter().map(|p| p.iter().map(|&(x, y)| to_lonlat(x, y)).collect()).collect();
push(f.geom, rank, score, lonlat, source.clone(), &f.props, None, by, feature_id(env.promote, &f.id, &f.props));
}
}
MapSource::Raster(_) => {}
}
hits.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)));
out.extend(hits.into_iter().map(|(_, _, p)| p));
}
out
}
fn background(&self, zoom: f64) -> Option<Rgba> {
self.layers.iter().rev().find_map(|e| match e {
Entry::Style(l) if l.kind == Kind::Background && l.shown_at(zoom) => {
let c = Ctx { zoom, geom: GeomType::Unknown, props: &[], state: &[] };
l.paint_expr("background-color").and_then(|e| e.eval(&c).as_color())
}
_ => None,
})
}
}
fn style_tile(layers: &[Entry], source: &str, features: &[store::Feature], zoom: f64) -> Vec<Item> {
let mut by_layer: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, f) in features.iter().enumerate() {
by_layer.entry(&f.layer).or_default().push(i);
}
let bg = layers.iter().rev().find_map(|e| match e {
Entry::Style(l) if l.kind == Kind::Background && l.shown_at(zoom) => {
l.paint_expr("background-color").and_then(|e| e.eval(&Ctx { zoom, geom: GeomType::Unknown, props: &[], state: &[] }).as_color())
}
_ => None,
});
let mut items = Vec::new();
for (li, e) in layers.iter().enumerate() {
let Entry::Style(l) = e else { continue };
if l.source.as_deref() != Some(source) {
continue;
}
if l.uses_zoom() || l.uses_state() {
continue;
}
let Some(fs) = l.source_layer.as_deref().and_then(|s| by_layer.get(s)) else { continue };
for &fi in fs {
let f = &features[fi];
if let Some(paint) = resolve(l, &Ctx { zoom, geom: f.geom, props: &f.props, state: &[] }, zoom, bg) {
items.push(Item { layer: li as u16, feature: fi as u32, paint });
}
}
}
items
}
fn term_color(c: Rgba, opacity: f64, bg: Option<Rgba>) -> Option<Color> {
let alpha = c.a * opacity;
if alpha <= 0.05 {
return None;
}
let c = match bg {
Some(b) if alpha < 0.999 => c.over(b, alpha),
_ => c,
};
let (r, g, b) = c.to_u8();
Some(Color::Rgb(r, g, b))
}
const BLACK: Rgba = Rgba::rgb(0, 0, 0);
fn resolve(l: &StyleLayer, c: &Ctx, paint_zoom: f64, bg: Option<Rgba>) -> Option<Resolved> {
if let Some(f) = &l.filter
&& !f.eval(c).as_bool()
{
return None;
}
let cp = Ctx { zoom: paint_zoom, geom: c.geom, props: c.props, state: c.state };
let color = |prop: &str, opacity: &str, default: Option<Rgba>| -> Option<Color> {
let rgba = l.paint_expr(prop).and_then(|e| e.eval(&cp).as_color()).or(default)?;
let o = l.paint_expr(opacity).and_then(|e| e.eval(&cp).as_num()).unwrap_or(1.0);
term_color(rgba, o, bg)
};
let layout_str = |name: &str| l.layout_expr(name).map(|e| e.eval(c).to_text()).filter(|s| !s.is_empty());
let sort = || {
let k = l.layout_expr("symbol-sort-key").and_then(|e| e.eval(c).as_num()).unwrap_or(0.0);
LAYER_PRIORITY / 2 - (k.round() as i32).clamp(-(LAYER_PRIORITY / 2 - 1), LAYER_PRIORITY / 2 - 1)
};
match l.kind {
Kind::Fill if c.geom == GeomType::Polygon => {
let fill = color("fill-color", "fill-opacity", Some(BLACK));
let outline = color("fill-outline-color", "fill-opacity", None);
(fill.is_some() || outline.is_some()).then_some(Resolved::Fill { color: fill, outline })
}
Kind::Line if c.geom != GeomType::Point => {
let color = color("line-color", "line-opacity", Some(BLACK))?;
let px = l.paint_expr("line-width").and_then(|e| e.eval(&cp).as_num()).unwrap_or(1.0);
if px <= 0.0 {
return None;
}
let width = if px < 6.0 { 1 } else if px < 14.0 { 2 } else { 3 };
let dash = match l.paint_expr("line-dasharray").map(|e| e.eval(&cp)) {
Some(crate::expr::Val::Array(a)) => {
let v: Vec<f64> = a.iter().filter_map(|x| x.as_num()).map(|n| (n * px.max(1.0) / 4.0).max(1.0)).collect();
let gaps = a.iter().skip(1).step_by(2).any(|x| x.as_num().is_some_and(|n| n > 0.0));
(v.len() >= 2 && gaps).then_some(v)
}
_ => None,
};
Some(Resolved::Line { color, width, dash })
}
Kind::Circle if c.geom == GeomType::Point => {
let fg = color("circle-color", "circle-opacity", Some(BLACK))?;
let glyph = layout_str("mapterm:glyph").unwrap_or_else(|| "●".into());
Some(Resolved::Point { glyph, style: TStyle::new().fg(fg), label: None, priority: sort() })
}
Kind::Symbol => {
let fg = color("text-color", "text-opacity", Some(BLACK))?;
let mut style = TStyle::new().fg(fg);
let font = l.layout_expr("text-font").map(|e| e.eval(c).to_text().to_lowercase()).unwrap_or_default();
if font.contains("bold") {
style = style.add_modifier(Modifier::BOLD);
}
if font.contains("italic") {
style = style.add_modifier(Modifier::ITALIC);
}
let text = layout_str("text-field")
.and_then(|t| t.lines().map(str::trim).find(|s| !s.is_empty()).map(str::to_string))
.map(|t| match layout_str("text-transform").as_deref() {
Some("uppercase") => t.to_uppercase(),
Some("lowercase") => t.to_lowercase(),
_ => t,
});
let glyph = layout_str("mapterm:glyph").or_else(|| {
(c.geom == GeomType::Point).then(|| layout_str("icon-image")).flatten().and_then(|n| crate::icons::glyph(&n)).map(str::to_string)
});
let priority = sort();
match (glyph, text) {
(Some(glyph), text) if c.geom == GeomType::Point => {
let icon = color("icon-color", "icon-opacity", None).map_or(style, |c| TStyle::new().fg(c));
Some(Resolved::Point { glyph, style: icon, label: text.map(|t| (t, style)), priority })
}
(_, Some(text)) => {
let variable: Vec<(i8, i8)> = match l.layout_expr("text-variable-anchor").map(|e| e.eval(c)) {
Some(crate::expr::Val::Array(a)) => a.iter().map(|v| anchor_of(&v.to_text())).collect(),
_ => Vec::new(),
};
let anchors = if variable.is_empty() { vec![anchor_of(layout_str("text-anchor").as_deref().unwrap_or("center"))] } else { variable };
let offset = match l.layout_expr("text-offset").map(|e| e.eval(c)) {
Some(crate::expr::Val::Array(a)) if a.len() == 2 => (a[0].as_num().unwrap_or(0.0) * 4.0, a[1].as_num().unwrap_or(0.0) * 4.0),
_ => (0.0, 0.0),
};
let spacing = (l.layout_expr("symbol-spacing").and_then(|e| e.eval(c).as_num()).unwrap_or(250.0) / 4.0).max(8.0);
let along_line = c.geom != GeomType::Point && layout_str("symbol-placement").is_some_and(|p| p.starts_with("line"));
Some(Resolved::Text { text, style, anchors, offset, along_line, spacing, priority })
}
_ => None,
}
}
_ => None,
}
}
fn draw_resolved(ctx: &mut DrawContext, r: &Resolved, geom: GeomType, dots: &[Vec<(f64, f64)>]) {
match r {
Resolved::Fill { color, outline } => {
if let Some(c) = color {
ctx.fill_dots(dots, *c);
}
if let Some(c) = outline {
for ring in dots {
ctx.line_dots(ring, *c);
}
}
}
Resolved::Line { color, width, dash } => {
for part in dots {
ctx.line_styled(part, *color, *width, dash.as_deref());
}
}
Resolved::Point { glyph, style, label, priority } => {
for &p in dots.iter().flatten() {
ctx.marker_dot(p, glyph, *style, *priority, label.clone());
}
}
Resolved::Text { text, style, anchors, offset, along_line, spacing, priority } => {
let place = |p: (f64, f64)| -> Vec<(f64, f64)> {
let half = text.width() as f64 + 2.0;
anchors.iter().map(|a| (p.0 + a.0 as f64 * half + offset.0, p.1 + a.1 as f64 * 4.0 + offset.1)).collect()
};
if geom == GeomType::Point {
for &p in dots.iter().flatten() {
ctx.label_dots(place(p), text, *style, *priority);
}
} else if *along_line || geom == GeomType::Line {
let scale = 2f64.powf(ctx.camera.zoom - ctx.camera.zoom.floor());
let repeat = (spacing * scale * 0.8).max(crate::layer::REPEAT_DOTS);
for part in dots {
for p in along(part, spacing * scale) {
if ctx.inside(p) {
ctx.label_spaced(place(p), text, *style, *priority, repeat);
}
}
}
} else {
if let Some(ring) = dots.first().filter(|r| !r.is_empty()) {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(x, y) in ring {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
ctx.label_dots(place(((x0 + x1) / 2.0, (y0 + y1) / 2.0)), text, *style, *priority);
}
}
}
}
}
fn anchor_of(name: &str) -> (i8, i8) {
match name {
"left" => (1, 0),
"right" => (-1, 0),
"top" => (0, 1),
"bottom" => (0, -1),
"top-left" => (1, 1),
"top-right" => (-1, 1),
"bottom-left" => (1, -1),
"bottom-right" => (-1, -1),
_ => (0, 0),
}
}
fn along(part: &[(f64, f64)], spacing: f64) -> Vec<(f64, f64)> {
let lens: Vec<f64> = part.windows(2).map(|w| (w[1].0 - w[0].0).hypot(w[1].1 - w[0].1)).collect();
let total: f64 = lens.iter().sum();
if total <= 0.0 || part.len() < 2 {
return part.first().copied().into_iter().collect();
}
let mut targets = Vec::new();
if total < spacing {
targets.push(total / 2.0);
} else {
let mut s = spacing / 2.0;
while s < total {
targets.push(s);
s += spacing;
}
}
let mut out = Vec::with_capacity(targets.len());
let (mut seg, mut start) = (0usize, 0.0);
for t in targets {
while seg < lens.len() - 1 && start + lens[seg] < t {
start += lens[seg];
seg += 1;
}
let k = if lens[seg] > 0.0 { ((t - start) / lens[seg]).clamp(0.0, 1.0) } else { 0.0 };
let (a, b) = (part[seg], part[seg + 1]);
out.push((a.0 + (b.0 - a.0) * k, a.1 + (b.1 - a.1) * k));
}
out
}
fn near_box(vp: &Viewport, target: (f64, f64)) -> crate::index::BBox {
let tol = 6.0;
let (w, n) = vp.dot_to_lonlat(target.0 - tol, target.1 - tol);
let (e, s) = vp.dot_to_lonlat(target.0 + tol, target.1 + tol);
let (x0, y0) = to_world(w, n);
let (x1, y1) = to_world(e, s);
[x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]
}
fn val_json(v: &Val) -> Json {
match v {
Val::Null => Json::Null,
Val::Bool(b) => Json::Bool(*b),
Val::Num(n) => serde_json::Number::from_f64(*n).map_or(Json::Null, Json::Number),
Val::Str(s) => Json::String(s.clone()),
Val::Color(c) => Json::String(Val::Color(*c).to_text()),
Val::Array(a) => Json::Array(a.iter().map(val_json).collect()),
}
}
pub struct MapView<'a> {
pub map: &'a Map,
pub camera: &'a Camera,
}
impl Widget for MapView<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (map, cam) = (self.map, self.camera);
let vp = Viewport::new(cam, area);
let bg = map.background(cam.zoom);
let tiles: HashMap<&str, Vec<(TileId, Arc<MapTile>)>> = map
.sources
.iter()
.filter_map(|(id, s)| match s {
MapSource::Vector(v) => Some((id.as_str(), v.tiles.visible(&vp, cam.tile_zoom(v.min_zoom, v.max_zoom)))),
_ => None,
})
.collect();
render_with(cam, area, buf, |ctx| {
for (i, entry) in map.layers.iter().enumerate() {
ctx.base = i as i32 * LAYER_PRIORITY;
let l = match entry {
Entry::Style(l) => l,
Entry::Custom { layer, .. } => {
layer.draw(ctx);
continue;
}
};
if !l.shown_at(cam.zoom) {
continue;
}
if l.kind == Kind::Background {
if let Some(c) = l.paint_expr("background-color").and_then(|e| e.eval(&Ctx { zoom: cam.zoom, geom: GeomType::Unknown, props: &[], state: &[] }).as_color())
&& let Some(c) = term_color(c, l.paint_expr("background-opacity").and_then(|e| e.eval(&Ctx { zoom: cam.zoom, geom: GeomType::Unknown, props: &[], state: &[] }).as_num()).unwrap_or(1.0), None)
{
let (w, h) = (area.width as f64 * 2.0, area.height as f64 * 4.0);
ctx.fill_dots(&[vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, 0.0)]], c);
}
continue;
}
let Some(sid) = l.source.as_deref() else { continue };
let env = map.state_env(sid);
match map.source(sid) {
Some(MapSource::Vector(_)) => {
for ((z, tx, ty), tile) in tiles.get(sid).into_iter().flatten() {
let n = (1u64 << z) as f64;
for it in tile.layer_items(i, l, cam.zoom, bg, env).iter() {
let f = &tile.features[it.feature as usize];
let dots: Vec<Vec<(f64, f64)>> = f
.parts
.iter()
.map(|part| part.iter().map(|p| vp.dot((*tx as f64 + p[0] as f64) / n, (*ty as f64 + p[1] as f64) / n)).collect())
.collect();
draw_resolved(ctx, &it.paint, f.geom, &dots);
}
}
}
Some(MapSource::GeoJson(g)) => {
for f in g.near(cam.zoom, ctx.world_view()) {
if !ctx.visible(f.bbox) {
continue;
}
let c = Ctx { zoom: cam.zoom.floor(), geom: f.geom, props: &f.props, state: env.of(&f.id, &f.props) };
let Some(r) = resolve(l, &c, cam.zoom, bg) else { continue };
let dots: Vec<Vec<(f64, f64)>> = f.parts.iter().map(|p| p.iter().map(|&(x, y)| vp.dot(x, y)).collect()).collect();
draw_resolved(ctx, &r, f.geom, &dots);
}
}
Some(MapSource::Raster(r)) if map.raster_mode == RasterMode::Cells => {
let opacity = l
.paint_expr("raster-opacity")
.and_then(|e| e.eval(&Ctx { zoom: cam.zoom, geom: GeomType::Unknown, props: &[], state: &[] }).as_num())
.unwrap_or(1.0)
.clamp(0.0, 1.0);
if opacity <= 0.05 {
continue;
}
let blend = |c: Color| match (c, bg) {
(Color::Rgb(r, g, b), Some(under)) if opacity < 0.999 => {
let (r, g, b) = Rgba::rgb(r, g, b).over(under, opacity).to_u8();
Color::Rgb(r, g, b)
}
(c, _) => c,
};
let rz = map.raster_zoom(&r.src, cam);
for b in crate::raster::layout(&r.tiles, cam, area, rz) {
let (sx, sy, sw, sh) = b.src;
let (cw, ch) = (sw as f64 / b.cells.width as f64, sh as f64 / b.cells.height as f64);
for row in 0..b.cells.height {
for col in 0..b.cells.width {
let (x, y) = (sx + (col as f64 * cw) as u32, sy + (row as f64 * ch) as u32);
let c = b.tile.average(x, y, cw.ceil() as u32, ch.ceil() as u32);
ctx.cell_bg(b.cells.x - area.x + col, b.cells.y - area.y + row, blend(c));
}
}
}
}
_ => {}
}
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::DrawContext;
use serde_json::json;
fn sample() -> Map {
let mut map = Map::new();
map.add_source(
"sites",
MapSource::geojson(&json!({"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"name": "Box", "kind": "zone"},
"geometry": {"type": "Polygon", "coordinates": [[[-77.05, 38.88], [-77.02, 38.88], [-77.02, 38.90], [-77.05, 38.90], [-77.05, 38.88]]]}},
{"type": "Feature", "properties": {"name": "Pin", "kind": "site"},
"geometry": {"type": "Point", "coordinates": [-77.0353, 38.8895]}}
]}))
.unwrap(),
)
.unwrap();
map.add_layer(StyleLayer::background("bg").color("#000010")).unwrap();
map.add_layer(StyleLayer::fill("zones", "sites").filter(json!(["==", ["get", "kind"], "zone"])).color("#204060")).unwrap();
map.add_layer(StyleLayer::symbol("pins", "sites").filter(json!(["==", "kind", "site"])).glyph("◆").text(json!(["get", "name"])).color("#ff0")).unwrap();
map
}
fn render(map: &Map, cam: &Camera, area: Rect) -> Buffer {
let mut buf = Buffer::empty(area);
map.view(cam).render(area, &mut buf);
buf
}
#[test]
fn draws_geojson_through_style_layers() {
let map = sample();
let cam = Camera::new(-77.0353, 38.8895, 15.0);
let area = Rect::new(0, 0, 60, 20);
let buf = render(&map, &cam, area);
let text: String = (0..area.height).map(|y| (0..area.width).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>()).collect::<Vec<_>>().join("\n");
assert!(text.contains("◆ Pin"), "{text}");
assert_eq!(buf[(30, 10)].symbol(), "◆");
assert_eq!(buf[(30, 10)].fg, Color::Rgb(255, 255, 0));
assert_eq!(buf[(2, 2)].bg, Color::Rgb(0x20, 0x40, 0x60), "inside the zone");
let mut map = map;
map.set_visibility("zones", false).unwrap();
assert_eq!(render(&map, &cam, area)[(2, 2)].bg, Color::Rgb(0, 0, 0x10));
}
#[test]
fn queries_by_layer_top_first() {
let map = sample();
let cam = Camera::new(-77.0353, 38.8895, 15.0);
let area = Rect::new(0, 0, 60, 20);
let center = (60.0, 40.0);
let ids: Vec<(String, String)> = map
.query_rendered_features(&cam, area, center, None)
.iter()
.map(|p| (p.style_layer.as_deref().unwrap().to_string(), p.title()))
.collect();
assert_eq!(ids, vec![("pins".into(), "Pin".into()), ("zones".into(), "Box".into())]);
let only = map.query_rendered_features(&cam, area, center, Some(&["zones"]));
assert_eq!(only.len(), 1);
assert_eq!(only[0].source.as_deref(), Some("sites"));
}
#[test]
fn layer_order_and_set_data() {
let mut map = sample();
assert_eq!(map.layer_ids(), vec!["bg", "zones", "pins"]);
map.move_layer("pins", Some("zones")).unwrap();
assert_eq!(map.layer_ids(), vec!["bg", "pins", "zones"]);
assert!(map.add_layer(StyleLayer::line("zones", "sites")).is_err(), "duplicate id");
assert!(map.add_layer_before(StyleLayer::line("x", "sites"), "nope").is_err());
map.set_data("sites", &json!({"type": "Point", "coordinates": [0, 0]})).unwrap();
let (w, s, e, n) = map.source_bounds("sites").unwrap();
assert!(w.abs() < 1e-9 && s.abs() < 1e-9 && e.abs() < 1e-9 && n.abs() < 1e-9);
assert!(!map.remove_layer("x") && map.remove_layer("zones"));
}
struct Counter {
hits: usize,
}
impl CustomLayer for Counter {
fn name(&self) -> &str {
"counter"
}
fn draw(&self, ctx: &mut DrawContext) {
ctx.marker(-77.0353, 38.8895, "X", TStyle::new(), 0, None);
}
}
#[test]
fn custom_layers_draw_in_order_and_downcast() {
let mut map = sample();
map.add_custom_layer("count", Counter { hits: 0 }, None).unwrap();
map.custom_layer_mut::<Counter>("count").unwrap().hits += 1;
assert_eq!(map.custom_layer_mut::<Counter>("count").unwrap().hits, 1);
assert!(map.custom_layer_mut::<Counter>("pins").is_none());
let cam = Camera::new(-77.0353, 38.8895, 15.0);
let buf = render(&map, &cam, Rect::new(0, 0, 60, 20));
assert_eq!(buf[(30, 10)].symbol(), "X");
}
#[test]
fn vector_tiles_are_styled_on_insert_and_restyled() {
let mvt = crate::mvt::tests_support::square_tile("water", 4096);
let mut map = Map::new();
map.add_source("v", MapSource::vector_tiles(0, 14)).unwrap();
map.add_layer(StyleLayer::fill("water", "v").source_layer("water").color("#0000ff")).unwrap();
map.insert_tile("v", (0, 0, 0), Some(&mvt)).unwrap();
let cam = Camera::new(0.0, 0.0, 0.0);
let area = Rect::new(0, 0, 40, 20);
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(0, 0, 255));
map.set_paint("water", "fill-color", &json!("#ff0000")).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(255, 0, 0));
map.set_filter("water", Some(&json!(["==", "class", "lake"]))).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Reset);
assert!(map.tile_requests(&cam, area).is_empty(), "app-supplied tiles are never requested");
}
fn raster_map() -> Map {
let mut map = Map::new();
let src = Arc::new(crate::raster::RasterSource::new("https://t.example/{z}/{x}/{y}.png", false, 19).unwrap());
map.add_source("img", MapSource::raster(src)).unwrap();
map.add_layer(StyleLayer::raster("img", "img")).unwrap();
map
}
fn solid_tile(r: u8, g: u8, b: u8) -> RasterTile {
let rgba = image::RgbaImage::from_pixel(256, 256, image::Rgba([r, g, b, 255]));
RasterTile { id: 4242 + r as u32, png: vec![1, 2, 3], rgba }
}
#[test]
fn raster_layers_draw_cells_or_kitty_images() {
let mut map = raster_map();
let cam = Camera::new(0.0, 0.0, 0.0);
let area = Rect::new(0, 0, 40, 20);
let reqs = map.tile_requests(&cam, area);
assert_eq!(reqs.len(), 1);
assert!(reqs[0].is_raster() && reqs[0].id == (0, 0, 0));
map.insert_loaded("img", (0, 0, 0), Ok(Loaded(LoadedTile::Raster(Some(solid_tile(10, 200, 30)))))).unwrap();
assert!(map.tile_requests(&cam, area).is_empty());
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(10, 200, 30));
map.raster_mode = RasterMode::Kitty { cell_px: (10.0, 20.0) };
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Reset);
let mut k = KittyState::default();
let first = map.kitty_commands(&cam, area, &mut k);
assert!(first.starts_with(crate::kitty::CLEAR_PLACEMENTS) && first.contains("a=t,f=100") && first.contains("z=-1000"));
let second = map.kitty_commands(&cam, area, &mut k);
assert!(!second.contains("a=t,") && second.contains("a=p,"));
map.set_visibility("img", false).unwrap();
assert!(!map.kitty_commands(&cam, area, &mut k).contains("a=p,"), "hidden layers aren't placed");
}
#[test]
fn kitty_images_stay_under_the_budget() {
let mut map = raster_map();
map.raster_mode = RasterMode::Kitty { cell_px: (10.0, 20.0) };
let area = Rect::new(0, 0, 40, 20);
for (i, id) in [(1, 0, 0), (1, 1, 0), (1, 0, 1), (1, 1, 1)].into_iter().enumerate() {
let mut t = solid_tile(10, 20, 30);
t.id = 9000 + i as u32;
map.insert_loaded("img", id, Ok(Loaded(LoadedTile::Raster(Some(t))))).unwrap();
}
let mut k = KittyState { budget: 2 * 256 * 1024, ..KittyState::default() };
let nw = Camera::new(-90.0, 45.0, 3.0);
let c1 = map.kitty_commands(&nw, area, &mut k);
assert_eq!(c1.matches("a=t,").count(), 1);
let ne = Camera::new(90.0, 45.0, 3.0);
let c2 = map.kitty_commands(&ne, area, &mut k);
assert!(c2.matches("a=t,").count() == 1 && !c2.contains("a=d,d=I"));
let sw = Camera::new(-90.0, -45.0, 3.0);
let c3 = map.kitty_commands(&sw, area, &mut k);
assert!(c3.contains("a=d,d=I,i=9000,"), "{c3}");
assert!(c3.find("a=d,d=I").unwrap() < c3.find("a=t,").unwrap(), "freed before sending");
assert!(k.bytes() <= k.budget);
assert_eq!(map.kitty_commands(&nw, area, &mut k).matches("a=t,").count(), 1);
}
#[test]
fn failed_loads_are_retried_then_given_up() {
use std::time::Duration;
let mut map = raster_map();
let cam = Camera::new(0.0, 0.0, 0.0);
let area = Rect::new(0, 0, 40, 20);
let fail = |map: &mut Map| map.insert_loaded("img", (0, 0, 0), Err(anyhow!("HTTP 500"))).unwrap_err();
map.retry = RetryPolicy { base: Duration::from_secs(3600), max: Duration::from_secs(3600), attempts: 3 };
assert!(fail(&mut map).to_string().contains("img tile 0/0/0"));
assert!(map.tile_requests(&cam, area).is_empty(), "waiting before the retry");
map.retry.base = Duration::ZERO;
map.failed.values_mut().for_each(|f| f.1 = std::time::Instant::now());
assert_eq!(map.tile_requests(&cam, area).len(), 1);
fail(&mut map);
assert_eq!(map.tile_requests(&cam, area).len(), 1);
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Reset);
fail(&mut map);
assert!(map.tile_requests(&cam, area).is_empty());
let mut map = raster_map();
map.retry.base = Duration::ZERO;
fail(&mut map);
map.insert_loaded("img", (0, 0, 0), Ok(Loaded(LoadedTile::Raster(Some(solid_tile(1, 2, 3)))))).unwrap();
assert!(map.failed.is_empty() && map.tile_requests(&cam, area).is_empty());
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(1, 2, 3));
}
#[test]
fn vector_failures_are_retried_too() {
let mut map = Map::new();
map.retry.base = std::time::Duration::ZERO;
let src = crate::source::Source::xyz_for_tests();
map.add_source("v", MapSource::vector(src)).unwrap();
map.add_layer(StyleLayer::fill("water", "v").source_layer("water").color("#00f")).unwrap();
let (cam, area) = (Camera::new(0.0, 0.0, 0.0), Rect::new(0, 0, 40, 20));
let req = map.tile_requests(&cam, area).remove(0);
assert!(map.insert_loaded(&req.source, req.id, Err(anyhow!("timed out"))).is_err());
assert_eq!(map.tile_requests(&cam, area).len(), 1, "asked for again, not stored empty");
}
#[test]
fn query_features_lists_drawn_and_undrawn() {
let mvt = crate::mvt::tests_support::square_tile("water", 4096);
let mut map = sample();
map.add_source("v", MapSource::vector_tiles(0, 0)).unwrap();
map.add_layers(vec![StyleLayer::fill("lakes", "v").source_layer("water").filter(json!(["==", "class", "lake"])).color("#00f")], Some("zones")).unwrap();
map.insert_tile("v", (0, 0, 0), Some(&mvt)).unwrap();
let cam = Camera::new(-77.0353, 38.8895, 15.0);
let area = Rect::new(0, 0, 60, 20);
let picks = map.query_features(&cam, area, (60.0, 40.0));
let got: Vec<(String, Option<String>, bool)> =
picks.iter().map(|p| (p.title(), p.style_layer.as_deref().map(str::to_string), p.drawn)).collect();
assert_eq!(
got,
vec![
("Pin".into(), Some("pins".into()), true),
("Box".into(), Some("zones".into()), true),
("water".into(), None, false),
],
"GeoJSON (top layers) first; the unstyled water square is listed but not drawn"
);
map.set_visibility("pins", false).unwrap();
let pin = &map.query_features(&cam, area, (60.0, 40.0))[0];
assert!(pin.title() == "Pin" && !pin.drawn, "hidden layers don't count as drawing");
}
#[test]
fn geojson_overlay_layers() {
let mut map = Map::new();
map.add_source("mine", MapSource::geojson(&json!({"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"name": "Pin"}, "geometry": {"type": "Point", "coordinates": [-77.0353, 38.8895]}}]})).unwrap()).unwrap();
map.add_layers(crate::geojson::overlay_layers("mine", Color::Rgb(255, 99, 132), true), None).unwrap();
assert_eq!(map.layer_ids(), vec!["mine-fill", "mine-line", "mine-point", "mine-cluster", "mine-label"]);
let cam = Camera::new(-77.0353, 38.8895, 15.0);
let buf = render(&map, &cam, Rect::new(0, 0, 60, 20));
let row: String = (0..60).map(|x| buf[(x, 10)].symbol().to_string()).collect();
assert!(row.contains("● Pin"), "{row}");
assert_eq!(buf[(30, 10)].fg, Color::Rgb(255, 99, 132));
}
fn water_map(max_zoom: u8, tile: TileId, layer: StyleLayer) -> Map {
let mvt = crate::mvt::tests_support::square_tile("water", 4096);
let mut map = Map::new();
map.add_source("v", MapSource::vector_tiles(0, max_zoom)).unwrap();
map.add_layer(layer).unwrap();
map.insert_tile("v", tile, Some(&mvt)).unwrap();
map
}
#[test]
fn paint_follows_the_fractional_camera_zoom() {
let layer = StyleLayer::fill("water", "v").source_layer("water").color(json!(["interpolate", ["linear"], ["zoom"], 2, "#000000", 4, "#ffffff"]));
let map = water_map(2, (2, 1, 1), layer);
let at = |z: f64| render(&map, &Camera::new(-45.0, 40.979898, z), Rect::new(0, 0, 40, 20))[(20, 10)].bg;
assert_eq!(at(2.0), Color::Rgb(0, 0, 0));
assert_eq!(at(2.5), Color::Rgb(64, 64, 64), "a quarter of the way at z2.5, not the tile's z2");
assert_eq!(at(3.0), Color::Rgb(128, 128, 128));
assert_eq!(at(4.0), Color::Rgb(255, 255, 255), "past the source's last zoom");
}
#[test]
fn filters_use_the_whole_zoom_level_past_the_source_max() {
let layer = StyleLayer::fill("water", "v").source_layer("water").filter(json!([">=", ["zoom"], 16])).color("#0000ff");
let map = water_map(14, (14, 4686, 6268), layer);
let center = |z: f64| Camera::new(-77.025146484375, 38.882481197550796, z);
let area = Rect::new(0, 0, 40, 20);
assert_eq!(render(&map, ¢er(15.9), area)[(20, 10)].bg, Color::Reset, "z15.9 is level 15");
assert_eq!(render(&map, ¢er(16.0), area)[(20, 10)].bg, Color::Rgb(0, 0, 255));
assert_eq!(map.query_rendered_features(¢er(16.2), area, (40.0, 40.0), None).len(), 1);
assert!(map.query_rendered_features(¢er(15.2), area, (40.0, 40.0), None).is_empty());
let picks = map.query_features(¢er(15.2), area, (40.0, 40.0));
assert!(picks.len() == 1 && !picks[0].drawn, "listed by inspect, but not drawn at z15");
}
#[test]
fn attributions_of_drawn_sources() {
let mut map = sample();
assert!(map.attributions(15.0).is_empty(), "no credits given");
map.set_attribution("sites", "<a href='https://example.org'>© Example Sites</a>").unwrap();
map.add_source("other", MapSource::geojson(&json!({"type": "Point", "coordinates": [0, 0]})).unwrap()).unwrap();
map.set_attribution("other", "© Example Sites").unwrap();
map.add_layer(StyleLayer::circle("other-dots", "other").min_zoom(16.0)).unwrap();
assert_eq!(map.attributions(15.0), vec!["© Example Sites"]);
assert_eq!(map.attributions(16.0), vec!["© Example Sites"], "the same credit once");
map.set_attribution("other", "Other Data").unwrap();
assert_eq!(map.attributions(16.0), vec!["© Example Sites", "Other Data"]);
assert_eq!(map.attributions(15.0), vec!["© Example Sites"], "layers not shown at this zoom don't credit");
for id in ["zones", "pins"] {
map.set_visibility(id, false).unwrap();
}
assert_eq!(map.attributions(16.0), vec!["Other Data"], "hidden layers don't credit");
map.set_data("other", &json!({"type": "Point", "coordinates": [1, 1]})).unwrap();
assert_eq!(map.attributions(16.0), vec!["Other Data"], "new data keeps the credit");
assert!(map.set_attribution("nope", "x").is_err());
}
#[test]
fn add_map_merges_sources_and_layers_below_before() {
let mut map = sample();
let mut other = Map::new();
other.add_source("v", MapSource::vector_tiles(0, 14)).unwrap();
other.add_layer(StyleLayer::fill("land", "v").source_layer("earth").color("#222")).unwrap();
other.add_layer(StyleLayer::line("roads", "v").source_layer("roads").color("#888")).unwrap();
let added = map.add_map(other, Some("zones")).unwrap();
assert_eq!(added, vec!["land", "roads"]);
assert_eq!(map.layer_ids(), vec!["bg", "land", "roads", "zones", "pins"]);
assert!(map.source("v").is_some());
let mut again = Map::new();
again.add_source("v", MapSource::vector_tiles(0, 14)).unwrap();
assert!(map.add_map(again, None).is_err(), "source id taken");
fn is_send<T: Send>() {}
is_send::<Map>();
}
fn text_rows(buf: &Buffer) -> Vec<String> {
let a = buf.area;
(a.y..a.bottom()).map(|y| (a.x..a.right()).map(|x| buf[(x, y)].symbol().to_string()).collect::<String>()).collect()
}
#[test]
fn points_along_a_line() {
let line = [(0.0, 0.0), (60.0, 0.0), (100.0, 0.0)];
assert_eq!(along(&line, 30.0), vec![(15.0, 0.0), (45.0, 0.0), (75.0, 0.0)]);
assert_eq!(along(&line, 300.0), vec![(50.0, 0.0)], "a short line gets its middle");
assert_eq!(along(&[(0.0, 0.0), (0.0, 40.0)], 40.0), vec![(0.0, 20.0)]);
}
#[test]
fn long_roads_repeat_their_name_and_close_copies_merge() {
let mut map = Map::new();
map.add_source("r", MapSource::geojson(&json!({"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"name": "Long Road"}, "geometry": {"type": "LineString", "coordinates": [[-0.5, 0], [0.5, 0]]}},
{"type": "Feature", "properties": {"name": "Pump"}, "geometry": {"type": "Point", "coordinates": [-0.2, 0.1]}},
{"type": "Feature", "properties": {"name": "Pump"}, "geometry": {"type": "Point", "coordinates": [-0.195, 0.1]}},
{"type": "Feature", "properties": {"name": "Pump"}, "geometry": {"type": "Point", "coordinates": [0.25, 0.1]}}
]})).unwrap()).unwrap();
map.add_layer(StyleLayer::symbol("road", "r").filter(json!(["==", ["geometry-type"], "LineString"])).text("{name}").color("#fff")).unwrap();
map.add_layer(StyleLayer::symbol("pumps", "r").filter(json!(["==", ["geometry-type"], "Point"])).text("{name}").color("#fff")).unwrap();
let buf = render(&map, &Camera::new(0.0, 0.0, 9.0), Rect::new(0, 0, 160, 20));
let all = text_rows(&buf).join("\n");
let roads = all.matches("Long Road").count();
assert!(roads >= 3, "repeated along the road: {roads}\n{all}");
assert_eq!(all.matches("Pump").count(), 2, "the close copy merges, the far one stays\n{all}");
map.set_layout("road", "symbol-spacing", &json!(1000)).unwrap();
let fewer = text_rows(&render(&map, &Camera::new(0.0, 0.0, 9.0), Rect::new(0, 0, 160, 20))).join("\n").matches("Long Road").count();
assert!(fewer < roads, "{fewer} < {roads}");
}
#[test]
fn variable_anchor_and_offset() {
let mut map = Map::new();
map.add_source("p", MapSource::geojson(&json!({"type": "FeatureCollection", "features": [
{"type": "Feature", "properties": {"name": "Blocker"}, "geometry": {"type": "Point", "coordinates": [0, 0]}},
{"type": "Feature", "properties": {"name": "Here"}, "geometry": {"type": "Point", "coordinates": [0, 0]}}
]})).unwrap()).unwrap();
map.add_layer(StyleLayer::symbol("here", "p").filter(json!(["==", "name", "Here"])).text("{name}").color("#fff")
.layout("text-variable-anchor", json!(["center", "top"]))).unwrap();
map.add_layer(StyleLayer::symbol("blocker", "p").filter(json!(["==", "name", "Blocker"])).text("{name}").color("#fff")).unwrap();
let area = Rect::new(0, 0, 40, 11);
let rows = text_rows(&render(&map, &Camera::new(0.0, 0.0, 5.0), area));
assert!(rows[5].contains("Blocker") && rows[6].contains("Here"), "{rows:#?}");
map.set_layout("here", "text-variable-anchor", &json!(["center"])).unwrap();
let rows = text_rows(&render(&map, &Camera::new(0.0, 0.0, 5.0), area));
assert!(!rows.join("").contains("Here"));
map.set_layout("here", "text-offset", &json!([0, 2])).unwrap();
let rows = text_rows(&render(&map, &Camera::new(0.0, 0.0, 5.0), area));
assert!(rows[7].contains("Here"), "{rows:#?}");
}
fn dot_count(buf: &Buffer) -> u32 {
let a = buf.area;
let mut n = 0;
for y in a.y..a.bottom() {
for x in a.x..a.right() {
if let Some(ch) = buf[(x, y)].symbol().chars().next()
&& ('\u{2800}'..='\u{28FF}').contains(&ch)
{
n += (ch as u32 - 0x2800).count_ones();
}
}
}
n
}
#[test]
fn line_widths_and_dashes() {
let line_map = |layer: StyleLayer| {
let mut map = Map::new();
map.add_source("l", MapSource::geojson(&json!({"type": "LineString", "coordinates": [[-0.2, 0], [0.2, 0]]})).unwrap()).unwrap();
map.add_layer(layer).unwrap();
dot_count(&render(&map, &Camera::new(0.0, 0.0, 9.0), Rect::new(0, 0, 80, 10)))
};
let solid = line_map(StyleLayer::line("l", "l").color("#fff"));
assert!(solid > 100, "{solid}");
let dashed = line_map(StyleLayer::line("l", "l").color("#fff").paint("line-dasharray", json!([4, 4])));
assert!(dashed * 10 > solid * 4 && dashed * 10 < solid * 6, "about half the dots: {dashed} of {solid}");
let no_gaps = line_map(StyleLayer::line("l", "l").color("#fff").paint("line-dasharray", json!([12, 0])));
assert_eq!(no_gaps, solid, "no gaps is a solid line");
let thick = line_map(StyleLayer::line("l", "l").color("#fff").paint("line-width", 10));
assert!(thick >= solid * 2 - 4, "two dots thick: {thick} vs {solid}");
let thicker = line_map(StyleLayer::line("l", "l").color("#fff").paint("line-width", 40));
assert!(thicker >= solid * 3 - 6 && thicker <= solid * 3 + 6, "three at most: {thicker}");
assert_eq!(line_map(StyleLayer::line("l", "l").color("#fff").paint("line-width", 0)), 0, "zero width draws nothing");
assert!(!StyleLayer::line("l", "l").paint("line-width", json!(["interpolate", ["linear"], ["zoom"], 5, 1, 15, 8])).uses_zoom());
}
#[test]
fn raster_opacity_blends_with_the_background() {
let mut map = raster_map();
map.add_layer_before(StyleLayer::background("bg").color("#000000"), "img").unwrap();
map.insert_loaded("img", (0, 0, 0), Ok(Loaded(LoadedTile::Raster(Some(solid_tile(200, 100, 0)))))).unwrap();
let cam = Camera::new(0.0, 0.0, 0.0);
let area = Rect::new(0, 0, 40, 20);
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(200, 100, 0));
map.set_paint("img", "raster-opacity", &json!(0.5)).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(100, 50, 0));
map.set_paint("img", "raster-opacity", &json!(0.0)).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(0, 0, 0), "invisible: the background shows");
}
#[test]
fn feature_state_by_id_and_promote_id() {
let mvt = crate::mvt::tests_support::square_tile_with_id("water", 4096, Some(42));
let mut map = Map::new();
map.add_source("v", MapSource::vector_tiles(0, 14)).unwrap();
map.add_layer(StyleLayer::fill("water", "v").source_layer("water")
.color(json!(["case", ["boolean", ["feature-state", "picked"], false], "#ff0000", "#0000ff"]))).unwrap();
map.insert_tile("v", (0, 0, 0), Some(&mvt)).unwrap();
let (cam, area) = (Camera::new(0.0, 0.0, 0.0), Rect::new(0, 0, 40, 20));
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(0, 0, 255));
let id = crate::mvt::Value::Num(42.0);
map.set_feature_state("v", &id, &json!({"picked": true})).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(255, 0, 0), "restyled when the state changes");
assert_eq!(map.feature_state("v", &id), json!({"picked": true}));
assert_eq!(map.query_rendered_features(&cam, area, (40.0, 40.0), None)[0].id, Some(id.clone()));
map.remove_feature_state("v", Some(&id), Some("picked"));
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(0, 0, 255));
assert!(map.set_feature_state("nope", &id, &json!({})).is_err());
let mut map = Map::new();
map.add_source("g", MapSource::geojson(&json!({"type": "Feature", "properties": {"code": "DC"},
"geometry": {"type": "Polygon", "coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]]}})).unwrap()
.with_promote_id("code")).unwrap();
map.add_layer(StyleLayer::fill("g", "g").color(json!(["case", ["boolean", ["feature-state", "on"], false], "#00ff00", "#000000"]))).unwrap();
let cam = Camera::new(0.0, 0.0, 6.0);
map.set_feature_state("g", &crate::mvt::Value::Str("DC".into()), &json!({"on": true})).unwrap();
assert_eq!(render(&map, &cam, area)[(20, 10)].bg, Color::Rgb(0, 255, 0));
assert_eq!(map.query_features(&cam, area, (40.0, 40.0))[0].id, Some(crate::mvt::Value::Str("DC".into())));
}
#[test]
fn clustered_geojson_source() {
let mut feats: Vec<Json> = (0..50).map(|i| json!({"type": "Feature", "properties": {"name": format!("p{i}")},
"geometry": {"type": "Point", "coordinates": [-77.03 + i as f64 * 1e-5, 38.89]}})).collect();
feats.push(json!({"type": "Feature", "properties": {"name": "far"}, "geometry": {"type": "Point", "coordinates": [-76.0, 38.89]}}));
let doc = json!({"type": "FeatureCollection", "features": feats});
let mut map = Map::new();
map.add_source("pts", MapSource::geojson(&doc).unwrap().with_cluster(crate::cluster::ClusterOptions::default())).unwrap();
map.add_layers(crate::geojson::overlay_layers("pts", Color::Rgb(255, 99, 132), false), None).unwrap();
let area = Rect::new(0, 0, 80, 20);
let cam = Camera::new(-77.03, 38.89, 10.0);
let rows = text_rows(&render(&map, &cam, area)).join("\n");
assert!(rows.contains("◉ 50"), "one cluster of 50: {rows}");
let picks = map.query_rendered_features(&cam, area, (80.0, 40.0), Some(&["pts-cluster"]));
assert_eq!(picks.len(), 1);
let Some(crate::mvt::Value::Num(id)) = picks[0].prop("cluster_id").cloned() else { panic!("{:?}", picks[0]) };
let z = map.cluster_expansion_zoom("pts", id as u64).unwrap();
assert!(z > 10.0, "{z}");
let close = Camera::new(-77.03, 38.89, 15.5);
assert!(!text_rows(&render(&map, &close, area)).join("").contains('◉'));
map.set_data("pts", &doc).unwrap();
assert!(text_rows(&render(&map, &cam, area)).join("").contains("◉ 50"));
}
}