use crate::error::{Error, Result};
use crate::types::{BoundingBox, TimeRange};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryScheme {
H3,
Quadbin,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SummaryAggregation {
Count,
Sum,
Mean,
Min,
Max,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryColumn {
pub name: String,
pub agg: SummaryAggregation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryTier {
pub scheme: SummaryScheme,
pub min_zoom: u8,
pub max_zoom: u8,
pub cell_resolution_per_zoom: Vec<u8>,
pub columns: Vec<SummaryColumn>,
#[serde(default = "default_summary_layer_name")]
pub layer_name: String,
#[serde(default = "default_sub_buckets")]
pub sub_buckets: u32,
}
fn default_sub_buckets() -> u32 {
1
}
fn default_summary_layer_name() -> String {
"summary".to_string()
}
impl SummaryTier {
pub fn resolution_for_zoom(&self, zoom: u8) -> u8 {
if self.cell_resolution_per_zoom.is_empty() {
return zoom;
}
if zoom <= self.min_zoom {
return self.cell_resolution_per_zoom[0];
}
if zoom >= self.max_zoom {
return *self
.cell_resolution_per_zoom
.last()
.expect("non-empty per check");
}
let idx = (zoom - self.min_zoom) as usize;
self.cell_resolution_per_zoom
.get(idx)
.copied()
.unwrap_or_else(|| {
*self.cell_resolution_per_zoom.last().unwrap()
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HeatmapClassDomain {
pub id: String,
pub min: f64,
pub max: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub property: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HeatmapDomain {
pub classes: Vec<HeatmapClassDomain>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PropertyStyleHint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p50: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p90: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p95: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p97: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p99: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_domain: Option<[f64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cardinality: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StyleHints {
pub version: u32,
pub properties: Vec<PropertyStyleHint>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_playback_seconds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layer_hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TemporalLodLevel {
pub bucket_ms: u64,
pub max_zoom_level: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Metadata {
pub name: String,
pub description: String,
pub attribution: String,
pub bounds: BoundingBox,
pub time_range: TimeRange,
pub min_zoom: u8,
pub max_zoom: u8,
pub tile_count: u64,
pub feature_count: u64,
pub layers: Vec<String>,
pub properties: HashMap<String, String>,
pub temporal_bucket_ms: u64,
#[serde(default)]
pub summary_tier: Option<SummaryTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temporal_lod: Option<Vec<TemporalLodLevel>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub heatmap_domain: Option<HeatmapDomain>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_hints: Option<StyleHints>,
}
impl Default for Metadata {
fn default() -> Self {
Self {
name: String::new(),
description: String::new(),
attribution: String::new(),
bounds: BoundingBox {
min_lon: -180.0,
min_lat: -85.0511,
max_lon: 180.0,
max_lat: 85.0511,
},
time_range: TimeRange::new(0, 0),
min_zoom: 0,
max_zoom: 14,
tile_count: 0,
feature_count: 0,
layers: vec!["default".to_string()],
properties: HashMap::new(),
temporal_bucket_ms: 3600 * 1000, summary_tier: None,
temporal_lod: None,
heatmap_domain: None,
style_hints: None,
}
}
}
impl Metadata {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
self.attribution = attribution.into();
self
}
pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
self.bounds = bounds;
self
}
pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
self.time_range = time_range;
self
}
pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
self.min_zoom = min_zoom;
self.max_zoom = max_zoom;
self
}
pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
self.temporal_bucket_ms = temporal_bucket_ms;
self
}
pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
Ok(self)
}
pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
let levels = self.temporal_lod.as_ref()?;
levels
.iter()
.filter(|l| zoom <= l.max_zoom_level)
.max_by_key(|l| l.bucket_ms)
}
pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.properties.insert(key.into(), value.into());
self
}
pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
self.summary_tier = Some(tier);
self
}
pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
self.heatmap_domain = Some(domain);
self
}
pub fn with_style_hints(mut self, hints: StyleHints) -> Self {
self.style_hints = Some(hints);
self
}
pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
serde_json::to_vec(self)
.map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
}
pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
serde_json::from_slice(bytes)
.map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
}
pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
use serde_json::json;
let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
let to_iso = |ms: u64| -> serde_json::Value {
if ms > i64::MAX as u64 {
return serde_json::Value::Null;
}
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
.map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
.unwrap_or(serde_json::Value::Null)
};
let vector_layers: Vec<serde_json::Value> = self
.layers
.iter()
.map(|name| {
json!({
"id": name,
"fields": {},
"minzoom": self.min_zoom,
"maxzoom": self.max_zoom,
})
})
.collect();
let mut temporal = json!({
"interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
"bucket_ms": self.temporal_bucket_ms,
});
if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
temporal["step"] = json!(step);
}
if let Some(levels) = &self.temporal_lod {
temporal["lod"] = json!(levels
.iter()
.map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
.collect::<Vec<_>>());
}
json!({
"tilejson": "3.0.0",
"tiles": [tiles],
"name": self.name,
"description": self.description,
"attribution": self.attribution,
"scheme": "xyz",
"version": "1.0.0",
"minzoom": self.min_zoom,
"maxzoom": self.max_zoom,
"bounds": [
self.bounds.min_lon,
self.bounds.min_lat,
self.bounds.max_lon,
self.bounds.max_lat
],
"center": [center_lon, center_lat, self.min_zoom],
"vector_layers": vector_layers,
"temporal": temporal,
})
}
}
fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
if base_bucket_ms == 0 {
return Err(Error::Other(
"temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
));
}
let mut prev: Option<u64> = None;
for (i, level) in levels.iter().enumerate() {
if level.bucket_ms == 0 {
return Err(Error::Other(format!(
"temporal_lod[{i}].bucket_ms must be non-zero"
)));
}
if level.bucket_ms <= base_bucket_ms {
return Err(Error::Other(format!(
"temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
level.bucket_ms, base_bucket_ms
)));
}
if level.bucket_ms % base_bucket_ms != 0 {
return Err(Error::Other(format!(
"temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
level.bucket_ms, base_bucket_ms
)));
}
if let Some(p) = prev {
if level.bucket_ms <= p {
return Err(Error::Other(format!(
"temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
level.bucket_ms, p
)));
}
}
prev = Some(level.bucket_ms);
}
Ok(())
}
fn iso8601_duration(ms: u64) -> Option<String> {
if ms == 0 {
return None;
}
Some(if ms % 86_400_000 == 0 {
format!("P{}D", ms / 86_400_000)
} else if ms % 3_600_000 == 0 {
format!("PT{}H", ms / 3_600_000)
} else if ms % 60_000 == 0 {
format!("PT{}M", ms / 60_000)
} else if ms % 1000 == 0 {
format!("PT{}S", ms / 1000)
} else {
format!("PT{:.3}S", ms as f64 / 1000.0)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metadata_json_roundtrip() {
let metadata = Metadata::new("json-test")
.with_description("desc")
.with_zoom_levels(2, 12)
.with_temporal_bucket_ms(3_600_000)
.with_property("source", "unit-test");
let bytes = metadata.to_json_bytes().unwrap();
let decoded = Metadata::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded.name, "json-test");
assert_eq!(decoded.min_zoom, 2);
assert_eq!(decoded.max_zoom, 12);
assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
}
#[test]
fn test_metadata_summary_tier_roundtrip() {
let tier = SummaryTier {
scheme: SummaryScheme::H3,
min_zoom: 0,
max_zoom: 4,
cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
columns: vec![
SummaryColumn {
name: "magnitude".to_string(),
agg: SummaryAggregation::Mean,
},
SummaryColumn {
name: "magnitude".to_string(),
agg: SummaryAggregation::Max,
},
],
layer_name: "summary".to_string(),
sub_buckets: 1,
};
let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
let bytes = metadata.to_json_bytes().unwrap();
let decoded = Metadata::from_json_bytes(&bytes).unwrap();
let dt = decoded.summary_tier.unwrap();
assert_eq!(dt.scheme, SummaryScheme::H3);
assert_eq!(dt.min_zoom, 0);
assert_eq!(dt.max_zoom, 4);
assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
assert_eq!(dt.columns.len(), 2);
assert_eq!(dt.resolution_for_zoom(2), 2);
assert_eq!(dt.resolution_for_zoom(10), 4);
}
#[test]
fn test_metadata_ignores_unknown_fields() {
let metadata = Metadata::new("fwd");
let mut v: serde_json::Value =
serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
assert_eq!(decoded.name, "fwd");
}
#[test]
fn test_heatmap_domain_roundtrip() {
let domain = HeatmapDomain {
classes: vec![
HeatmapClassDomain {
id: "pickup".to_string(),
min: 0.0,
max: 7.5,
property: Some("intensity".to_string()),
},
HeatmapClassDomain {
id: "dropoff".to_string(),
min: 0.0,
max: 9.0,
property: None,
},
],
};
let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
let bytes = metadata.to_json_bytes().unwrap();
let decoded = Metadata::from_json_bytes(&bytes).unwrap();
let d = decoded.heatmap_domain.unwrap();
assert_eq!(d.classes.len(), 2);
assert_eq!(d.classes[0].id, "pickup");
assert_eq!(d.classes[0].max, 7.5);
assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
assert_eq!(d.classes[1].id, "dropoff");
assert_eq!(d.classes[1].property, None);
}
#[test]
fn test_heatmap_domain_field_omitted_when_unset() {
let metadata = Metadata::new("no-heat");
let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
assert!(!s.contains("heatmap_domain"), "got: {s}");
}
#[test]
fn test_style_hints_roundtrip() {
let hints = StyleHints {
version: 1,
properties: vec![
PropertyStyleHint {
name: "magnitude".to_string(),
min: Some(0.1),
p50: Some(2.0),
p90: Some(4.1),
p95: Some(4.9),
p97: Some(5.3),
p99: Some(6.2),
max: Some(9.1),
suggested_domain: Some([0.1, 5.3]),
cardinality: None,
},
PropertyStyleHint {
name: "category".to_string(),
cardinality: Some(7),
..Default::default()
},
],
suggested_playback_seconds: Some(45),
layer_hint: Some("points".to_string()),
};
let metadata = Metadata::new("hints-test").with_style_hints(hints.clone());
let bytes = metadata.to_json_bytes().unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let cat = &v["style_hints"]["properties"][1];
assert_eq!(cat["name"], "category");
assert_eq!(cat["cardinality"], 7);
let cat_keys: Vec<&String> = cat.as_object().unwrap().keys().collect();
assert_eq!(cat_keys.len(), 2, "categorical carries only name+cardinality: {cat_keys:?}");
assert!(v["style_hints"]["properties"][0].get("cardinality").is_none());
assert_eq!(v["style_hints"]["suggested_playback_seconds"], 45);
assert_eq!(v["style_hints"]["layer_hint"], "points");
let decoded = Metadata::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded.style_hints, Some(hints));
}
#[test]
fn test_style_hints_field_omitted_when_unset() {
let metadata = Metadata::new("no-hints");
let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
assert!(!s.contains("style_hints"), "got: {s}");
}
#[test]
fn test_metadata_without_summary_tier_decodes() {
let json = br#"{
"name": "old",
"description": "",
"attribution": "",
"bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
"time_range": {"start":0,"end":1000},
"min_zoom": 0,
"max_zoom": 8,
"tile_count": 0,
"feature_count": 0,
"layers": ["default"],
"properties": {},
"temporal_bucket_ms": 3600000
}"#;
let m = Metadata::from_json_bytes(json).unwrap();
assert!(m.summary_tier.is_none());
}
#[test]
fn test_metadata_builder() {
let metadata = Metadata::new("test")
.with_description("Test archive")
.with_attribution("Test data")
.with_zoom_levels(0, 14)
.with_property("key", "value");
assert_eq!(metadata.name, "test");
assert_eq!(metadata.description, "Test archive");
assert_eq!(metadata.min_zoom, 0);
assert_eq!(metadata.max_zoom, 14);
assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
}
fn hour() -> u64 {
3_600_000
}
fn day() -> u64 {
24 * hour()
}
fn thirty_days() -> u64 {
30 * day()
}
#[test]
fn temporal_lod_roundtrips_through_json() {
let levels = vec![
TemporalLodLevel {
bucket_ms: day(),
max_zoom_level: 8,
},
TemporalLodLevel {
bucket_ms: thirty_days(),
max_zoom_level: 4,
},
];
let metadata = Metadata::new("lod")
.with_temporal_bucket_ms(hour())
.with_temporal_lod(levels.clone())
.unwrap();
let bytes = metadata.to_json_bytes().unwrap();
let decoded = Metadata::from_json_bytes(&bytes).unwrap();
assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
}
#[test]
fn temporal_lod_field_omitted_when_unset() {
let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
assert!(!s.contains("temporal_lod"), "got: {s}");
}
#[test]
fn temporal_lod_missing_field_decodes_back_compat() {
let legacy = r#"{
"name": "legacy",
"description": "",
"attribution": "",
"bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
"time_range": {"start": 0, "end": 1},
"min_zoom": 0,
"max_zoom": 14,
"tile_count": 0,
"feature_count": 0,
"layers": ["default"],
"properties": {},
"temporal_bucket_ms": 3600000
}"#;
let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
assert!(m.temporal_lod.is_none());
}
#[test]
fn temporal_lod_rejects_non_multiple_bucket() {
let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
]);
assert!(res.is_err());
}
#[test]
fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
let res = Metadata::new("bad")
.with_temporal_bucket_ms(day())
.with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
assert!(res.is_err());
let res = Metadata::new("bad")
.with_temporal_bucket_ms(hour())
.with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
assert!(res.is_err());
}
#[test]
fn temporal_lod_rejects_unsorted_levels() {
let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
]);
assert!(res.is_err());
}
#[test]
fn temporal_lod_for_zoom_picks_coarsest_applicable() {
let m = Metadata::new("lod")
.with_temporal_bucket_ms(hour())
.with_temporal_lod(vec![
TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
])
.unwrap();
assert_eq!(
m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
Some(thirty_days())
);
assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
assert!(m.temporal_lod_for_zoom(12).is_none());
}
#[test]
fn temporal_lod_for_zoom_is_none_when_unset() {
let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
assert!(m.temporal_lod_for_zoom(0).is_none());
}
#[test]
fn temporal_lod_empty_vec_clears_to_none() {
let m = Metadata::new("empty")
.with_temporal_bucket_ms(hour())
.with_temporal_lod(vec![])
.unwrap();
assert!(m.temporal_lod.is_none());
}
#[test]
fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
let m = Metadata::new("quakes")
.with_description("USGS earthquakes")
.with_attribution("USGS")
.with_zoom_levels(0, 10)
.with_temporal_bucket_ms(hour())
.with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
.with_temporal_lod(vec![TemporalLodLevel {
bucket_ms: day(),
max_zoom_level: 6,
}])
.unwrap();
let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
assert_eq!(tj["tilejson"], "3.0.0");
assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
assert_eq!(tj["minzoom"], 0);
assert_eq!(tj["maxzoom"], 10);
assert_eq!(tj["scheme"], "xyz");
assert_eq!(tj["vector_layers"][0]["id"], "default");
assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
assert_eq!(tj["temporal"]["step"], "PT1H");
assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
let interval = &tj["temporal"]["interval"][0];
assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
assert!(interval[1].is_string());
assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
}
#[test]
fn iso8601_duration_formats_common_buckets() {
assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
assert_eq!(iso8601_duration(0), None);
}
#[test]
fn tilejson_time_beyond_i64_is_null_not_garbage() {
let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
let tj = m.to_tilejson(None);
assert!(tj["temporal"]["interval"][0][0].is_null());
assert!(tj["temporal"]["interval"][0][1].is_null());
}
}