use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct VectorLayerMeta {
pub id: String,
pub description: Option<String>,
pub min_zoom: Option<u8>,
pub max_zoom: Option<u8>,
}
impl VectorLayerMeta {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
description: None,
min_zoom: None,
max_zoom: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TileJson {
pub tilejson: String,
pub name: Option<String>,
pub description: Option<String>,
pub version: Option<String>,
pub attribution: Option<String>,
pub tiles: Vec<String>,
pub min_zoom: u8,
pub max_zoom: u8,
pub bounds: Option<[f64; 4]>,
pub center: Option<[f64; 3]>,
pub scheme: TileScheme,
pub vector_layers: Vec<VectorLayerMeta>,
}
impl Default for TileJson {
fn default() -> Self {
Self {
tilejson: "3.0.0".into(),
name: None,
description: None,
version: None,
attribution: None,
tiles: Vec::new(),
min_zoom: 0,
max_zoom: 22,
bounds: None,
center: None,
scheme: TileScheme::Xyz,
vector_layers: Vec::new(),
}
}
}
impl TileJson {
pub fn with_tiles(tiles: Vec<String>) -> Self {
Self {
tiles,
..Self::default()
}
}
#[inline]
pub fn first_tile_url(&self) -> Option<&str> {
self.tiles.first().map(String::as_str)
}
#[inline]
pub fn is_vector(&self) -> bool {
!self.vector_layers.is_empty()
}
pub fn source_layer_names(&self) -> Vec<&str> {
self.vector_layers.iter().map(|vl| vl.id.as_str()).collect()
}
pub fn contains_point(&self, lon: f64, lat: f64) -> bool {
match self.bounds {
Some([west, south, east, north]) => {
lon >= west && lon <= east && lat >= south && lat <= north
}
None => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TileScheme {
#[default]
Xyz,
Tms,
}
impl fmt::Display for TileScheme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TileScheme::Xyz => write!(f, "xyz"),
TileScheme::Tms => write!(f, "tms"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TileJsonError {
InvalidJson(String),
MissingField(&'static str),
InvalidField {
field: &'static str,
reason: String,
},
}
impl fmt::Display for TileJsonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TileJsonError::InvalidJson(msg) => write!(f, "invalid TileJSON: {msg}"),
TileJsonError::MissingField(field) => {
write!(f, "missing required TileJSON field: `{field}`")
}
TileJsonError::InvalidField { field, reason } => {
write!(f, "invalid TileJSON field `{field}`: {reason}")
}
}
}
}
impl std::error::Error for TileJsonError {}
#[cfg(feature = "style-json")]
mod parsing {
use super::*;
use serde_json::Value;
pub fn parse_tilejson(bytes: &[u8]) -> Result<TileJson, TileJsonError> {
let value: Value =
serde_json::from_slice(bytes).map_err(|e| TileJsonError::InvalidJson(e.to_string()))?;
parse_tilejson_value(&value)
}
pub fn parse_tilejson_value(value: &Value) -> Result<TileJson, TileJsonError> {
let obj = value
.as_object()
.ok_or(TileJsonError::InvalidJson("root is not an object".into()))?;
let tilejson = obj
.get("tilejson")
.and_then(|v| v.as_str())
.unwrap_or("3.0.0")
.to_owned();
let tiles = obj
.get("tiles")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if tiles.is_empty() {
return Err(TileJsonError::MissingField("tiles"));
}
let min_zoom = obj
.get("minzoom")
.and_then(|v| v.as_u64())
.map(|v| v.min(30) as u8)
.unwrap_or(0);
let max_zoom = obj
.get("maxzoom")
.and_then(|v| v.as_u64())
.map(|v| v.min(30) as u8)
.unwrap_or(22);
let bounds = obj.get("bounds").and_then(|v| {
let arr = v.as_array()?;
if arr.len() >= 4 {
Some([
arr[0].as_f64()?,
arr[1].as_f64()?,
arr[2].as_f64()?,
arr[3].as_f64()?,
])
} else {
None
}
});
let center = obj.get("center").and_then(|v| {
let arr = v.as_array()?;
if arr.len() >= 3 {
Some([arr[0].as_f64()?, arr[1].as_f64()?, arr[2].as_f64()?])
} else {
None
}
});
let scheme = obj
.get("scheme")
.and_then(|v| v.as_str())
.map(|s| match s {
"tms" => TileScheme::Tms,
_ => TileScheme::Xyz,
})
.unwrap_or(TileScheme::Xyz);
let name = obj
.get("name")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let description = obj
.get("description")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let version = obj
.get("version")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let attribution = obj
.get("attribution")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let vector_layers = obj
.get("vector_layers")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(parse_vector_layer_meta).collect())
.unwrap_or_default();
Ok(TileJson {
tilejson,
name,
description,
version,
attribution,
tiles,
min_zoom,
max_zoom,
bounds,
center,
scheme,
vector_layers,
})
}
fn parse_vector_layer_meta(value: &Value) -> Option<VectorLayerMeta> {
let obj = value.as_object()?;
let id = obj.get("id")?.as_str()?.to_owned();
let description = obj
.get("description")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let min_zoom = obj
.get("minzoom")
.and_then(|v| v.as_u64())
.map(|v| v.min(30) as u8);
let max_zoom = obj
.get("maxzoom")
.and_then(|v| v.as_u64())
.map(|v| v.min(30) as u8);
Some(VectorLayerMeta {
id,
description,
min_zoom,
max_zoom,
})
}
}
#[cfg(feature = "style-json")]
pub use parsing::{parse_tilejson, parse_tilejson_value};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_tilejson_has_sensible_values() {
let tj = TileJson::default();
assert_eq!(tj.tilejson, "3.0.0");
assert_eq!(tj.min_zoom, 0);
assert_eq!(tj.max_zoom, 22);
assert!(tj.tiles.is_empty());
assert!(!tj.is_vector());
assert!(tj.source_layer_names().is_empty());
}
#[test]
fn with_tiles_constructor() {
let tj = TileJson::with_tiles(vec!["https://example.com/{z}/{x}/{y}.pbf".into()]);
assert_eq!(tj.tiles.len(), 1);
assert_eq!(
tj.first_tile_url(),
Some("https://example.com/{z}/{x}/{y}.pbf")
);
}
#[test]
fn is_vector_when_layers_present() {
let mut tj = TileJson::default();
assert!(!tj.is_vector());
tj.vector_layers.push(VectorLayerMeta::new("water"));
assert!(tj.is_vector());
assert_eq!(tj.source_layer_names(), vec!["water"]);
}
#[test]
fn contains_point_unbounded() {
let tj = TileJson::default();
assert!(tj.contains_point(0.0, 0.0));
assert!(tj.contains_point(180.0, 90.0));
}
#[test]
fn contains_point_bounded() {
let tj = TileJson {
bounds: Some([-10.0, -20.0, 30.0, 40.0]),
..TileJson::default()
};
assert!(tj.contains_point(0.0, 0.0));
assert!(tj.contains_point(-10.0, -20.0));
assert!(tj.contains_point(30.0, 40.0));
assert!(!tj.contains_point(-11.0, 0.0));
assert!(!tj.contains_point(0.0, 41.0));
}
#[test]
fn tile_scheme_display() {
assert_eq!(TileScheme::Xyz.to_string(), "xyz");
assert_eq!(TileScheme::Tms.to_string(), "tms");
}
#[test]
fn tilejson_error_display() {
let err = TileJsonError::MissingField("tiles");
assert!(err.to_string().contains("tiles"));
}
#[cfg(feature = "style-json")]
mod json_parsing {
use super::*;
#[test]
fn parse_minimal_vector_tilejson() {
let json = br#"{
"tilejson": "3.0.0",
"tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
"minzoom": 0,
"maxzoom": 14,
"vector_layers": [
{"id": "water", "minzoom": 0, "maxzoom": 14},
{"id": "roads", "description": "Road network"}
]
}"#;
let tj = parse_tilejson(json).expect("valid tilejson");
assert_eq!(tj.tilejson, "3.0.0");
assert_eq!(tj.tiles.len(), 1);
assert_eq!(tj.min_zoom, 0);
assert_eq!(tj.max_zoom, 14);
assert!(tj.is_vector());
assert_eq!(tj.vector_layers.len(), 2);
assert_eq!(tj.vector_layers[0].id, "water");
assert_eq!(tj.vector_layers[0].min_zoom, Some(0));
assert_eq!(tj.vector_layers[0].max_zoom, Some(14));
assert_eq!(tj.vector_layers[1].id, "roads");
assert_eq!(
tj.vector_layers[1].description.as_deref(),
Some("Road network")
);
}
#[test]
fn parse_raster_tilejson() {
let json = br#"{
"tilejson": "2.2.0",
"tiles": ["https://tile.example.com/{z}/{x}/{y}.png"],
"minzoom": 0,
"maxzoom": 18,
"bounds": [-180, -85.05, 180, 85.05],
"center": [0, 0, 2],
"name": "OpenStreetMap",
"attribution": "© OSM contributors"
}"#;
let tj = parse_tilejson(json).expect("valid tilejson");
assert_eq!(tj.tilejson, "2.2.0");
assert!(!tj.is_vector());
assert_eq!(tj.name.as_deref(), Some("OpenStreetMap"));
assert!(tj.attribution.is_some());
assert!(tj.bounds.is_some());
assert!(tj.center.is_some());
let bounds = tj.bounds.expect("bounds");
assert!((bounds[0] - (-180.0)).abs() < 1e-9);
}
#[test]
fn parse_tilejson_missing_tiles_fails() {
let json = br#"{"tilejson": "3.0.0"}"#;
let err = parse_tilejson(json).expect_err("should fail");
assert!(matches!(err, TileJsonError::MissingField("tiles")));
}
#[test]
fn parse_tilejson_invalid_json() {
let err = parse_tilejson(b"not json").expect_err("should fail");
assert!(matches!(err, TileJsonError::InvalidJson(_)));
}
#[test]
fn parse_tilejson_with_scheme() {
let json = br#"{
"tiles": ["https://example.com/{z}/{x}/{y}.pbf"],
"scheme": "tms"
}"#;
let tj = parse_tilejson(json).expect("valid tilejson");
assert_eq!(tj.scheme, TileScheme::Tms);
}
}
}