Skip to main content

stt_core/
metadata.rs

1//! Archive metadata structures
2//!
3//! This module provides types for storing and managing archive metadata.
4
5use crate::error::{Error, Result};
6use crate::types::{BoundingBox, TimeRange};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10/// Aggregation scheme for the optional pre-aggregated summary tier.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum SummaryScheme {
14    /// Uber H3 hexagonal cells.
15    H3,
16    /// CARTO Quadbin (Z/X/Y quad-key encoded as u64).
17    Quadbin,
18}
19
20/// One aggregated column in a summary tier.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SummaryAggregation {
24    Count,
25    Sum,
26    Mean,
27    Min,
28    Max,
29}
30
31/// Description of a single column emitted by the summary tier.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SummaryColumn {
34    pub name: String,
35    pub agg: SummaryAggregation,
36}
37
38/// Description of the optional pre-aggregated summary tier.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SummaryTier {
41    pub scheme: SummaryScheme,
42    pub min_zoom: u8,
43    pub max_zoom: u8,
44    pub cell_resolution_per_zoom: Vec<u8>,
45    pub columns: Vec<SummaryColumn>,
46    #[serde(default = "default_summary_layer_name")]
47    pub layer_name: String,
48    /// Number of fine-grained sub-buckets per outer time-bucket emitted
49    /// at build time. `1` (or absent) = legacy single-count behaviour.
50    /// When > 1, each cell row carries N additional numeric columns named
51    /// `bucket_0`..`bucket_<N-1>` and the renderer animates inside a tile
52    /// by switching which column drives the per-cell colour — zero data
53    /// re-upload between frames.
54    #[serde(default = "default_sub_buckets")]
55    pub sub_buckets: u32,
56}
57
58fn default_sub_buckets() -> u32 {
59    1
60}
61
62fn default_summary_layer_name() -> String {
63    "summary".to_string()
64}
65
66impl SummaryTier {
67    /// Resolution to use at a given zoom. Falls back to the closest mapped
68    /// resolution if the zoom is outside `[min_zoom, max_zoom]`.
69    pub fn resolution_for_zoom(&self, zoom: u8) -> u8 {
70        if self.cell_resolution_per_zoom.is_empty() {
71            return zoom;
72        }
73        if zoom <= self.min_zoom {
74            return self.cell_resolution_per_zoom[0];
75        }
76        if zoom >= self.max_zoom {
77            return *self
78                .cell_resolution_per_zoom
79                .last()
80                .expect("non-empty per check");
81        }
82        let idx = (zoom - self.min_zoom) as usize;
83        self.cell_resolution_per_zoom
84            .get(idx)
85            .copied()
86            .unwrap_or_else(|| {
87                *self.cell_resolution_per_zoom.last().unwrap()
88            })
89    }
90}
91
92/// Bake-time per-class intensity domain for the GPU-splat HeatmapLayer.
93///
94/// The HeatmapLayer maps `(weight × gaussian_falloff × intensity)` through a
95/// palette LUT. Without a pinned domain the renderer would either bake `[0,1]`
96/// in (saturating immediately when `weightProperty` carries large values like
97/// earthquake magnitudes) or trigger a runtime GPU readback to auto-detect
98/// the max. Computing the domain at build time gives the renderer a stable
99/// ramp with zero runtime cost.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct HeatmapClassDomain {
102    /// Class id — `"default"` for the un-classified single-channel mode,
103    /// otherwise matches the FE channel id (typically a categorical value).
104    pub id: String,
105    /// Inclusive minimum splat intensity for this class.
106    pub min: f64,
107    /// Inclusive maximum splat intensity for this class. For the
108    /// un-weighted default this is 1.0 (the gaussian peak). For a
109    /// weight-property-driven layer this is the 95th-percentile weight
110    /// across all features (95p is more visually useful than absolute max,
111    /// which lets a single outlier dim the whole ramp).
112    pub max: f64,
113    /// Source weight property the domain was computed from, if any.
114    /// `None` = constant unit weight.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub property: Option<String>,
117}
118
119/// Container for the build-time HeatmapLayer domain metadata.
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct HeatmapDomain {
122    pub classes: Vec<HeatmapClassDomain>,
123}
124
125/// Build-time statistics + rendering defaults for ONE property column,
126/// carried inside [`StyleHints`].
127///
128/// Two shapes share this struct:
129/// * **numeric** properties carry `min`/`p50`/`p90`/`p95`/`p97`/`p99`/`max`
130///   and `suggested_domain` (`cardinality` absent);
131/// * **categorical** (string) properties carry ONLY `name` + `cardinality`
132///   (the numeric fields are absent from the JSON, not null-filled).
133#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
134pub struct PropertyStyleHint {
135    /// Property (tile column) name.
136    pub name: String,
137    /// Minimum observed value (numeric only).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub min: Option<f64>,
140    /// 50th percentile (numeric only).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub p50: Option<f64>,
143    /// 90th percentile (numeric only).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub p90: Option<f64>,
146    /// 95th percentile (numeric only).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub p95: Option<f64>,
149    /// 97th percentile (numeric only).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub p97: Option<f64>,
152    /// 99th percentile (numeric only).
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub p99: Option<f64>,
155    /// Maximum observed value (numeric only).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub max: Option<f64>,
158    /// Suggested render domain `[min, p97]`, each endpoint rounded OUTWARD to
159    /// 2 significant figures. p97 (not max) encodes the project's manual
160    /// "domain clamps at ~p97" tuning convention — a single outlier must not
161    /// dim the whole ramp (numeric only).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub suggested_domain: Option<[f64; 2]>,
164    /// Distinct-value count (categorical only; the profiler caps it at
165    /// 10 000 — "at least 10k" is already actionable for palette sizing).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub cardinality: Option<u32>,
168}
169
170/// Optional build-time "style hints" block: per-property statistics plus
171/// archive-level rendering defaults, computed by the opt-in
172/// `stt-build --style-hints` profiler so a fresh dataset renders sensibly
173/// without hand-tuning.
174///
175/// Hints are DEFAULTS — a renderer or user can always override them. The
176/// whole block is additive: archives without it (and readers that don't know
177/// it) are unaffected.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct StyleHints {
180    /// Style-hints block schema version. Currently `1`.
181    pub version: u32,
182    /// Per-property statistics (numeric percentiles or categorical
183    /// cardinality — see [`PropertyStyleHint`]).
184    pub properties: Vec<PropertyStyleHint>,
185    /// Suggested playback duration in seconds:
186    /// `clamp(round(sqrt(bucket_count)), 20, 90)` where `bucket_count` is the
187    /// time-range duration divided by `temporal_bucket_ms`. Absent when the
188    /// bucket size is unknown/zero.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub suggested_playback_seconds: Option<u32>,
191    /// Dominant produced layer kind: one of `"points"`, `"paths"`, `"trips"`,
192    /// or `"polygons"` (absent when no kind could be derived). Kept as a
193    /// string (not an enum) so a newer writer's vocabulary can't fail an
194    /// older reader's decode.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub layer_hint: Option<String>,
197}
198
199/// One level of a temporal LOD pyramid (orthogonal to the summary tier above).
200///
201/// At any tile-zoom-level `z` such that `z <= max_zoom_level`, a client that
202/// is currently displaying a time range too wide to render the base
203/// `temporal_bucket_ms` tiles efficiently can fetch coarser tiles from this
204/// level instead. Each level uses `bucket_ms` as its temporal bucket size
205/// (which must be a multiple of the archive's base `temporal_bucket_ms`).
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct TemporalLodLevel {
208    /// Temporal bucket size in milliseconds for tiles at this level.
209    pub bucket_ms: u64,
210    /// Inclusive upper bound on the spatial zoom level where this LOD applies.
211    pub max_zoom_level: u8,
212}
213
214/// Archive metadata.
215///
216/// Stored in the archive as UTF-8 JSON — small, human-inspectable, and
217/// versionless thanks to serde's field defaults.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Metadata {
220    /// Archive name
221    pub name: String,
222    /// Description
223    pub description: String,
224    /// Attribution text
225    pub attribution: String,
226    /// Bounding box
227    pub bounds: BoundingBox,
228    /// Time range
229    pub time_range: TimeRange,
230    /// Minimum zoom level
231    pub min_zoom: u8,
232    /// Maximum zoom level
233    pub max_zoom: u8,
234    /// Total number of tiles. For packed manifests this is derived from the
235    /// directory at write time (`PackWriter::finalize`); caller-set values are
236    /// ignored there.
237    pub tile_count: u64,
238    /// Total feature records summed across tiles — a feature that lands in N
239    /// tiles (zoom pyramid, clipping, temporal LOD) counts N times. Matches
240    /// stt-validate's `feature_count_index`; derived from the directory at
241    /// write time for packed manifests.
242    pub feature_count: u64,
243    /// Layer names
244    pub layers: Vec<String>,
245    /// Custom properties. A `BTreeMap` so the serialized JSON key order is
246    /// deterministic across processes — the packed manifest embeds this map,
247    /// and byte-reproducible builds must not depend on hash-map iteration order.
248    pub properties: BTreeMap<String, String>,
249    /// Temporal bucket size in milliseconds for tile chunking
250    /// Tiles are organized into fixed temporal intervals (e.g., 3600000 = 1 hour)
251    pub temporal_bucket_ms: u64,
252    /// Optional server-side aggregated summary tier. v2/v3 archives without
253    /// a summary tier round-trip cleanly via the field default.
254    #[serde(default)]
255    pub summary_tier: Option<SummaryTier>,
256
257    /// Optional temporal LOD pyramid (orthogonal to summary tier).
258    /// When present, the archive carries aggregate tiles at coarser temporal
259    /// granularities so a reader animating decades of data at "year scale"
260    /// can fetch coarser tiles instead of streaming per-hour base tiles.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub temporal_lod: Option<Vec<TemporalLodLevel>>,
263
264    /// Optional bake-time HeatmapLayer intensity domains. When set, the
265    /// renderer's HeatmapLayer skips its runtime `colorDomain` default of
266    /// `[0, 1]` and uses these per-class entries instead — vital when the
267    /// configured `weightProperty` carries values far outside that range
268    /// (earthquake magnitudes, AIS speed, etc.).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub heatmap_domain: Option<HeatmapDomain>,
271
272    /// Optional build-time style hints (per-property statistics + suggested
273    /// rendering defaults). Baked by `stt-build --style-hints`; always
274    /// overridable by the renderer/user, ignored by older readers.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub style_hints: Option<StyleHints>,
277}
278
279impl Default for Metadata {
280    fn default() -> Self {
281        Self {
282            name: String::new(),
283            description: String::new(),
284            attribution: String::new(),
285            bounds: BoundingBox {
286                min_lon: -180.0,
287                min_lat: -85.0511,
288                max_lon: 180.0,
289                max_lat: 85.0511,
290            },
291            time_range: TimeRange::new(0, 0),
292            min_zoom: 0,
293            max_zoom: 14,
294            tile_count: 0,
295            feature_count: 0,
296            layers: vec!["default".to_string()],
297            properties: BTreeMap::new(),
298            temporal_bucket_ms: 3600 * 1000, // 1 hour default
299            summary_tier: None,
300            temporal_lod: None,
301            heatmap_domain: None,
302            style_hints: None,
303        }
304    }
305}
306
307impl Metadata {
308    /// Create a new metadata object
309    pub fn new(name: impl Into<String>) -> Self {
310        Self {
311            name: name.into(),
312            ..Default::default()
313        }
314    }
315
316    /// Set description
317    pub fn with_description(mut self, description: impl Into<String>) -> Self {
318        self.description = description.into();
319        self
320    }
321
322    /// Set attribution
323    pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
324        self.attribution = attribution.into();
325        self
326    }
327
328    /// Set bounds
329    pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
330        self.bounds = bounds;
331        self
332    }
333
334    /// Set time range
335    pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
336        self.time_range = time_range;
337        self
338    }
339
340    /// Set zoom levels
341    pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
342        self.min_zoom = min_zoom;
343        self.max_zoom = max_zoom;
344        self
345    }
346
347    /// Set temporal bucket size in milliseconds
348    pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
349        self.temporal_bucket_ms = temporal_bucket_ms;
350        self
351    }
352
353    /// Attach a temporal LOD pyramid.
354    ///
355    /// Each level's `bucket_ms` MUST be a strict multiple of the archive's
356    /// base `temporal_bucket_ms` and MUST be strictly greater than it; levels
357    /// MUST be sorted by ascending `bucket_ms`. Returns `Err` if the input
358    /// breaks any of those invariants — the build pipeline relies on them
359    /// when re-bucketing features into LOD aggregates.
360    pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
361        validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
362        self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
363        Ok(self)
364    }
365
366    /// Return the LOD level that applies at `zoom`, if any. The largest
367    /// matching `bucket_ms` (coarsest level) wins — at a global zoom, you
368    /// want the coarsest available aggregate, not the finest.
369    pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
370        let levels = self.temporal_lod.as_ref()?;
371        levels
372            .iter()
373            .filter(|l| zoom <= l.max_zoom_level)
374            .max_by_key(|l| l.bucket_ms)
375    }
376
377    /// Add a custom property
378    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
379        self.properties.insert(key.into(), value.into());
380        self
381    }
382
383    /// Attach an aggregated summary-tier descriptor.
384    pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
385        self.summary_tier = Some(tier);
386        self
387    }
388
389    /// Attach a bake-time HeatmapLayer intensity-domain block.
390    pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
391        self.heatmap_domain = Some(domain);
392        self
393    }
394
395    /// Attach a build-time style-hints block.
396    pub fn with_style_hints(mut self, hints: StyleHints) -> Self {
397        self.style_hints = Some(hints);
398        self
399    }
400
401    /// Serialise to the JSON byte form stored in an archive.
402    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
403        serde_json::to_vec(self)
404            .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
405    }
406
407    /// Parse from the JSON byte form stored in an archive.
408    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
409        serde_json::from_slice(bytes)
410            .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
411    }
412
413    /// Render a TileJSON 3.0 descriptor (with a STAC-style `temporal`
414    /// extension) for this archive.
415    ///
416    /// This is the self-describing, ecosystem-recognisable face of the archive:
417    /// MapLibre / Leaflet / OpenLayers understand the core TileJSON fields, and
418    /// the additive `temporal` block — an ISO-8601 `interval` à la STAC, plus
419    /// the bucket size/step and any LOD pyramid — carries the time dimension the
420    /// spatial-only standard lacks (unknown keys are ignored by existing
421    /// clients). `tile_url_template` is the `{z}/{x}/{y}/{t}` URL the host serves
422    /// tiles at; when `None`, a relative template is emitted.
423    pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
424        use serde_json::json;
425        let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
426        let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
427        let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
428
429        let to_iso = |ms: u64| -> serde_json::Value {
430            // Guard the u64→i64 cast: a timestamp beyond i64::MAX ms (year ~292M)
431            // would wrap negative; surface it as a null open bound instead.
432            if ms > i64::MAX as u64 {
433                return serde_json::Value::Null;
434            }
435            chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
436                .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
437                .unwrap_or(serde_json::Value::Null)
438        };
439
440        let vector_layers: Vec<serde_json::Value> = self
441            .layers
442            .iter()
443            .map(|name| {
444                json!({
445                    "id": name,
446                    "fields": {},
447                    "minzoom": self.min_zoom,
448                    "maxzoom": self.max_zoom,
449                })
450            })
451            .collect();
452
453        // STAC temporal extent: interval is an array of [start, end] pairs with
454        // `null` for an open end. We carry the bucket size + ISO-8601 step and
455        // any LOD pyramid as additive keys.
456        let mut temporal = json!({
457            "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
458            "bucket_ms": self.temporal_bucket_ms,
459        });
460        if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
461            temporal["step"] = json!(step);
462        }
463        if let Some(levels) = &self.temporal_lod {
464            temporal["lod"] = json!(levels
465                .iter()
466                .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
467                .collect::<Vec<_>>());
468        }
469
470        json!({
471            "tilejson": "3.0.0",
472            "tiles": [tiles],
473            "name": self.name,
474            "description": self.description,
475            "attribution": self.attribution,
476            "scheme": "xyz",
477            "version": "1.0.0",
478            "minzoom": self.min_zoom,
479            "maxzoom": self.max_zoom,
480            "bounds": [
481                self.bounds.min_lon,
482                self.bounds.min_lat,
483                self.bounds.max_lon,
484                self.bounds.max_lat
485            ],
486            "center": [center_lon, center_lat, self.min_zoom],
487            "vector_layers": vector_layers,
488            "temporal": temporal,
489        })
490    }
491}
492
493/// Verify the LOD invariants: ascending bucket order, multiples of the base
494/// bucket, strictly coarser than base, distinct bucket sizes.
495fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
496    if base_bucket_ms == 0 {
497        return Err(Error::Other(
498            "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
499        ));
500    }
501    let mut prev: Option<u64> = None;
502    for (i, level) in levels.iter().enumerate() {
503        if level.bucket_ms == 0 {
504            return Err(Error::Other(format!(
505                "temporal_lod[{i}].bucket_ms must be non-zero"
506            )));
507        }
508        if level.bucket_ms <= base_bucket_ms {
509            return Err(Error::Other(format!(
510                "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
511                level.bucket_ms, base_bucket_ms
512            )));
513        }
514        if level.bucket_ms % base_bucket_ms != 0 {
515            return Err(Error::Other(format!(
516                "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
517                level.bucket_ms, base_bucket_ms
518            )));
519        }
520        if let Some(p) = prev {
521            if level.bucket_ms <= p {
522                return Err(Error::Other(format!(
523                    "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
524                    level.bucket_ms, p
525                )));
526            }
527        }
528        prev = Some(level.bucket_ms);
529    }
530    Ok(())
531}
532
533/// Format a millisecond duration as an ISO-8601 duration string for the
534/// TileJSON `temporal.step` field (best-effort: days / hours / minutes /
535/// seconds). Returns `None` for a zero bucket.
536fn iso8601_duration(ms: u64) -> Option<String> {
537    if ms == 0 {
538        return None;
539    }
540    Some(if ms % 86_400_000 == 0 {
541        format!("P{}D", ms / 86_400_000)
542    } else if ms % 3_600_000 == 0 {
543        format!("PT{}H", ms / 3_600_000)
544    } else if ms % 60_000 == 0 {
545        format!("PT{}M", ms / 60_000)
546    } else if ms % 1000 == 0 {
547        format!("PT{}S", ms / 1000)
548    } else {
549        format!("PT{:.3}S", ms as f64 / 1000.0)
550    })
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn test_metadata_json_roundtrip() {
559        let metadata = Metadata::new("json-test")
560            .with_description("desc")
561            .with_zoom_levels(2, 12)
562            .with_temporal_bucket_ms(3_600_000)
563            .with_property("source", "unit-test");
564        let bytes = metadata.to_json_bytes().unwrap();
565        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
566        assert_eq!(decoded.name, "json-test");
567        assert_eq!(decoded.min_zoom, 2);
568        assert_eq!(decoded.max_zoom, 12);
569        assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
570        assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
571    }
572
573    #[test]
574    fn test_metadata_summary_tier_roundtrip() {
575        let tier = SummaryTier {
576            scheme: SummaryScheme::H3,
577            min_zoom: 0,
578            max_zoom: 4,
579            cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
580            columns: vec![
581                SummaryColumn {
582                    name: "magnitude".to_string(),
583                    agg: SummaryAggregation::Mean,
584                },
585                SummaryColumn {
586                    name: "magnitude".to_string(),
587                    agg: SummaryAggregation::Max,
588                },
589            ],
590            layer_name: "summary".to_string(),
591            sub_buckets: 1,
592        };
593        let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
594        let bytes = metadata.to_json_bytes().unwrap();
595        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
596        let dt = decoded.summary_tier.unwrap();
597        assert_eq!(dt.scheme, SummaryScheme::H3);
598        assert_eq!(dt.min_zoom, 0);
599        assert_eq!(dt.max_zoom, 4);
600        assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
601        assert_eq!(dt.columns.len(), 2);
602        assert_eq!(dt.resolution_for_zoom(2), 2);
603        // Out-of-range zooms clamp to the endpoints.
604        assert_eq!(dt.resolution_for_zoom(10), 4);
605    }
606
607    #[test]
608    fn test_metadata_summary_tier_quadbin_and_sub_buckets_roundtrip() {
609        // The CARTO `quadbin` scheme and a non-default `sub_buckets` count must
610        // survive the manifest JSON round-trip, and the scheme must serialize
611        // with its documented lowercase spelling. (Moved here from
612        // tests/spec_conformance.rs, whose remit is the per-layer Arrow schema.)
613        let tier = SummaryTier {
614            scheme: SummaryScheme::Quadbin,
615            min_zoom: 0,
616            max_zoom: 6,
617            cell_resolution_per_zoom: vec![0, 1, 2, 3, 4, 5, 6],
618            columns: vec![SummaryColumn {
619                name: "magnitude".to_string(),
620                agg: SummaryAggregation::Mean,
621            }],
622            layer_name: "summary".to_string(),
623            sub_buckets: 24,
624        };
625        let bytes = Metadata::new("q")
626            .with_summary_tier(tier)
627            .to_json_bytes()
628            .expect("serialize metadata");
629        let decoded = Metadata::from_json_bytes(&bytes).expect("deserialize metadata");
630        let dt = decoded.summary_tier.expect("summary tier round-trips");
631        assert_eq!(dt.scheme, SummaryScheme::Quadbin, "quadbin scheme survives");
632        assert_eq!(dt.sub_buckets, 24, "sub_buckets survives the round-trip");
633        assert_eq!(dt.max_zoom, 6);
634
635        let json = String::from_utf8(bytes).unwrap();
636        assert!(json.contains("quadbin"), "scheme serializes lowercase: {json}");
637        assert!(json.contains("sub_buckets"), "sub_buckets is a manifest field: {json}");
638    }
639
640    #[test]
641    fn test_metadata_summary_tier_defaults_when_subfields_absent() {
642        // A pre-`sub_buckets` manifest (tier present, but the `sub_buckets` and
643        // `layer_name` fields absent) must decode to the legacy single-count
644        // behaviour via serde's documented defaults.
645        let json = br#"{
646            "name": "old-summary",
647            "description": "",
648            "attribution": "",
649            "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
650            "time_range": {"start":0,"end":1000},
651            "min_zoom": 0,
652            "max_zoom": 8,
653            "tile_count": 0,
654            "feature_count": 0,
655            "layers": ["default"],
656            "properties": {},
657            "temporal_bucket_ms": 3600000,
658            "summary_tier": {
659                "scheme": "h3",
660                "min_zoom": 0,
661                "max_zoom": 4,
662                "cell_resolution_per_zoom": [0,1,2,3,4],
663                "columns": [{"name":"magnitude","agg":"mean"}]
664            }
665        }"#;
666        let m = Metadata::from_json_bytes(json).expect("legacy summary tier decodes");
667        let dt = m.summary_tier.expect("summary tier present");
668        assert_eq!(dt.scheme, SummaryScheme::H3);
669        assert_eq!(dt.sub_buckets, 1, "absent sub_buckets defaults to 1");
670        assert_eq!(dt.layer_name, "summary", "absent layer_name defaults to 'summary'");
671    }
672
673    #[test]
674    fn test_properties_serialize_in_sorted_key_order() {
675        // `properties` is a BTreeMap so the manifest JSON key order is
676        // deterministic across processes (no HashMap iteration order in
677        // anything serialized) — insertion order must NOT leak through.
678        let metadata = Metadata::new("ord")
679            .with_property("zebra", "1")
680            .with_property("alpha", "2")
681            .with_property("mid", "3");
682        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
683        let a = s.find("\"alpha\"").unwrap();
684        let m = s.find("\"mid\"").unwrap();
685        let z = s.find("\"zebra\"").unwrap();
686        assert!(a < m && m < z, "properties must serialize sorted: {s}");
687    }
688
689    #[test]
690    fn test_metadata_ignores_unknown_fields() {
691        // Forward compat: metadata written by a newer builder (or carrying
692        // since-removed fields like the old `raster_tier` scaffold) must
693        // still decode — serde skips unknown keys by default.
694        let metadata = Metadata::new("fwd");
695        let mut v: serde_json::Value =
696            serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
697        v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
698        let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
699        assert_eq!(decoded.name, "fwd");
700    }
701
702    #[test]
703    fn test_heatmap_domain_roundtrip() {
704        let domain = HeatmapDomain {
705            classes: vec![
706                HeatmapClassDomain {
707                    id: "pickup".to_string(),
708                    min: 0.0,
709                    max: 7.5,
710                    property: Some("intensity".to_string()),
711                },
712                HeatmapClassDomain {
713                    id: "dropoff".to_string(),
714                    min: 0.0,
715                    max: 9.0,
716                    property: None,
717                },
718            ],
719        };
720        let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
721        let bytes = metadata.to_json_bytes().unwrap();
722        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
723        let d = decoded.heatmap_domain.unwrap();
724        assert_eq!(d.classes.len(), 2);
725        assert_eq!(d.classes[0].id, "pickup");
726        assert_eq!(d.classes[0].max, 7.5);
727        assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
728        assert_eq!(d.classes[1].id, "dropoff");
729        assert_eq!(d.classes[1].property, None);
730    }
731
732    #[test]
733    fn test_heatmap_domain_field_omitted_when_unset() {
734        let metadata = Metadata::new("no-heat");
735        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
736        assert!(!s.contains("heatmap_domain"), "got: {s}");
737    }
738
739    #[test]
740    fn test_style_hints_roundtrip() {
741        let hints = StyleHints {
742            version: 1,
743            properties: vec![
744                PropertyStyleHint {
745                    name: "magnitude".to_string(),
746                    min: Some(0.1),
747                    p50: Some(2.0),
748                    p90: Some(4.1),
749                    p95: Some(4.9),
750                    p97: Some(5.3),
751                    p99: Some(6.2),
752                    max: Some(9.1),
753                    suggested_domain: Some([0.1, 5.3]),
754                    cardinality: None,
755                },
756                // Categorical: ONLY name + cardinality.
757                PropertyStyleHint {
758                    name: "category".to_string(),
759                    cardinality: Some(7),
760                    ..Default::default()
761                },
762            ],
763            suggested_playback_seconds: Some(45),
764            layer_hint: Some("points".to_string()),
765        };
766        let metadata = Metadata::new("hints-test").with_style_hints(hints.clone());
767        let bytes = metadata.to_json_bytes().unwrap();
768
769        // Wire shape: the categorical entry must carry NO numeric keys (absent,
770        // not null-filled) — pinned cross-language contract with the TS reader.
771        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
772        let cat = &v["style_hints"]["properties"][1];
773        assert_eq!(cat["name"], "category");
774        assert_eq!(cat["cardinality"], 7);
775        let cat_keys: Vec<&String> = cat.as_object().unwrap().keys().collect();
776        assert_eq!(cat_keys.len(), 2, "categorical carries only name+cardinality: {cat_keys:?}");
777        // Numeric entry: no cardinality key.
778        assert!(v["style_hints"]["properties"][0].get("cardinality").is_none());
779        assert_eq!(v["style_hints"]["suggested_playback_seconds"], 45);
780        assert_eq!(v["style_hints"]["layer_hint"], "points");
781
782        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
783        assert_eq!(decoded.style_hints, Some(hints));
784    }
785
786    #[test]
787    fn test_style_hints_field_omitted_when_unset() {
788        // Old readers must be unaffected: an archive built without
789        // --style-hints carries no `style_hints` key at all, and a legacy
790        // metadata JSON without the key decodes to None (see the
791        // `test_metadata_without_summary_tier_decodes` fixture, which also
792        // lacks style_hints).
793        let metadata = Metadata::new("no-hints");
794        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
795        assert!(!s.contains("style_hints"), "got: {s}");
796    }
797
798    #[test]
799    fn test_metadata_without_summary_tier_decodes() {
800        // A pre-summary-tier archive's metadata JSON has no `summary_tier`
801        // field at all. serde's `#[default]` must accept it.
802        let json = br#"{
803            "name": "old",
804            "description": "",
805            "attribution": "",
806            "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
807            "time_range": {"start":0,"end":1000},
808            "min_zoom": 0,
809            "max_zoom": 8,
810            "tile_count": 0,
811            "feature_count": 0,
812            "layers": ["default"],
813            "properties": {},
814            "temporal_bucket_ms": 3600000
815        }"#;
816        let m = Metadata::from_json_bytes(json).unwrap();
817        assert!(m.summary_tier.is_none());
818    }
819
820    #[test]
821    fn test_metadata_builder() {
822        let metadata = Metadata::new("test")
823            .with_description("Test archive")
824            .with_attribution("Test data")
825            .with_zoom_levels(0, 14)
826            .with_property("key", "value");
827
828        assert_eq!(metadata.name, "test");
829        assert_eq!(metadata.description, "Test archive");
830        assert_eq!(metadata.min_zoom, 0);
831        assert_eq!(metadata.max_zoom, 14);
832        assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
833    }
834
835    // ------------------------------------------------------------------
836    // temporal_lod
837    // ------------------------------------------------------------------
838
839    fn hour() -> u64 {
840        3_600_000
841    }
842    fn day() -> u64 {
843        24 * hour()
844    }
845    fn thirty_days() -> u64 {
846        30 * day()
847    }
848
849    #[test]
850    fn temporal_lod_roundtrips_through_json() {
851        let levels = vec![
852            TemporalLodLevel {
853                bucket_ms: day(),
854                max_zoom_level: 8,
855            },
856            TemporalLodLevel {
857                bucket_ms: thirty_days(),
858                max_zoom_level: 4,
859            },
860        ];
861        let metadata = Metadata::new("lod")
862            .with_temporal_bucket_ms(hour())
863            .with_temporal_lod(levels.clone())
864            .unwrap();
865        let bytes = metadata.to_json_bytes().unwrap();
866        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
867        assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
868    }
869
870    #[test]
871    fn temporal_lod_field_omitted_when_unset() {
872        // Older readers that don't know about temporal_lod must still parse
873        // a freshly-written archive; the field is skipped when None.
874        let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
875        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
876        assert!(!s.contains("temporal_lod"), "got: {s}");
877    }
878
879    #[test]
880    fn temporal_lod_missing_field_decodes_back_compat() {
881        // A v3 archive built before this feature has no `temporal_lod` key
882        // in its metadata JSON; the new field must default to None.
883        let legacy = r#"{
884            "name": "legacy",
885            "description": "",
886            "attribution": "",
887            "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
888            "time_range": {"start": 0, "end": 1},
889            "min_zoom": 0,
890            "max_zoom": 14,
891            "tile_count": 0,
892            "feature_count": 0,
893            "layers": ["default"],
894            "properties": {},
895            "temporal_bucket_ms": 3600000
896        }"#;
897        let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
898        assert!(m.temporal_lod.is_none());
899    }
900
901    #[test]
902    fn temporal_lod_rejects_non_multiple_bucket() {
903        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
904            TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
905        ]);
906        assert!(res.is_err());
907    }
908
909    #[test]
910    fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
911        let res = Metadata::new("bad")
912            .with_temporal_bucket_ms(day())
913            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
914        assert!(res.is_err());
915
916        let res = Metadata::new("bad")
917            .with_temporal_bucket_ms(hour())
918            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
919        assert!(res.is_err());
920    }
921
922    #[test]
923    fn temporal_lod_rejects_unsorted_levels() {
924        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
925            TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
926            TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
927        ]);
928        assert!(res.is_err());
929    }
930
931    #[test]
932    fn temporal_lod_for_zoom_picks_coarsest_applicable() {
933        let m = Metadata::new("lod")
934            .with_temporal_bucket_ms(hour())
935            .with_temporal_lod(vec![
936                TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
937                TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
938            ])
939            .unwrap();
940        // Very-zoomed-out: both levels apply, pick the coarser (30d).
941        assert_eq!(
942            m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
943            Some(thirty_days())
944        );
945        // Mid zoom: only the day level applies.
946        assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
947        // High zoom: no LOD — fall back to base bucket.
948        assert!(m.temporal_lod_for_zoom(12).is_none());
949    }
950
951    #[test]
952    fn temporal_lod_for_zoom_is_none_when_unset() {
953        let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
954        assert!(m.temporal_lod_for_zoom(0).is_none());
955    }
956
957    #[test]
958    fn temporal_lod_empty_vec_clears_to_none() {
959        // Passing an empty list is treated as "no LOD" rather than an error,
960        // so callers can compute the level set unconditionally.
961        let m = Metadata::new("empty")
962            .with_temporal_bucket_ms(hour())
963            .with_temporal_lod(vec![])
964            .unwrap();
965        assert!(m.temporal_lod.is_none());
966    }
967
968    #[test]
969    fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
970        let m = Metadata::new("quakes")
971            .with_description("USGS earthquakes")
972            .with_attribution("USGS")
973            .with_zoom_levels(0, 10)
974            .with_temporal_bucket_ms(hour())
975            .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
976            .with_temporal_lod(vec![TemporalLodLevel {
977                bucket_ms: day(),
978                max_zoom_level: 6,
979            }])
980            .unwrap();
981        let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
982
983        // Core TileJSON 3.0 fields every web client recognises.
984        assert_eq!(tj["tilejson"], "3.0.0");
985        assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
986        assert_eq!(tj["minzoom"], 0);
987        assert_eq!(tj["maxzoom"], 10);
988        assert_eq!(tj["scheme"], "xyz");
989        assert_eq!(tj["vector_layers"][0]["id"], "default");
990        assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
991
992        // Additive STAC-style temporal extension.
993        assert_eq!(tj["temporal"]["step"], "PT1H");
994        assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
995        let interval = &tj["temporal"]["interval"][0];
996        assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
997        assert!(interval[1].is_string());
998        assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
999    }
1000
1001    #[test]
1002    fn iso8601_duration_formats_common_buckets() {
1003        assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
1004        assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
1005        assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
1006        assert_eq!(iso8601_duration(0), None);
1007    }
1008
1009    #[test]
1010    fn tilejson_time_beyond_i64_is_null_not_garbage() {
1011        // A timestamp past i64::MAX ms must surface as a null open bound rather
1012        // than wrapping to a negative (bogus) date.
1013        let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
1014        let tj = m.to_tilejson(None);
1015        assert!(tj["temporal"]["interval"][0][0].is_null());
1016        assert!(tj["temporal"]["interval"][0][1].is_null());
1017    }
1018}