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::HashMap;
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
246    pub properties: HashMap<String, String>,
247    /// Temporal bucket size in milliseconds for tile chunking
248    /// Tiles are organized into fixed temporal intervals (e.g., 3600000 = 1 hour)
249    pub temporal_bucket_ms: u64,
250    /// Optional server-side aggregated summary tier. v2/v3 archives without
251    /// a summary tier round-trip cleanly via the field default.
252    #[serde(default)]
253    pub summary_tier: Option<SummaryTier>,
254
255    /// Optional temporal LOD pyramid (orthogonal to summary tier).
256    /// When present, the archive carries aggregate tiles at coarser temporal
257    /// granularities so a reader animating decades of data at "year scale"
258    /// can fetch coarser tiles instead of streaming per-hour base tiles.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub temporal_lod: Option<Vec<TemporalLodLevel>>,
261
262    /// Optional bake-time HeatmapLayer intensity domains. When set, the
263    /// renderer's HeatmapLayer skips its runtime `colorDomain` default of
264    /// `[0, 1]` and uses these per-class entries instead — vital when the
265    /// configured `weightProperty` carries values far outside that range
266    /// (earthquake magnitudes, AIS speed, etc.).
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub heatmap_domain: Option<HeatmapDomain>,
269
270    /// Optional build-time style hints (per-property statistics + suggested
271    /// rendering defaults). Baked by `stt-build --style-hints`; always
272    /// overridable by the renderer/user, ignored by older readers.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub style_hints: Option<StyleHints>,
275}
276
277impl Default for Metadata {
278    fn default() -> Self {
279        Self {
280            name: String::new(),
281            description: String::new(),
282            attribution: String::new(),
283            bounds: BoundingBox {
284                min_lon: -180.0,
285                min_lat: -85.0511,
286                max_lon: 180.0,
287                max_lat: 85.0511,
288            },
289            time_range: TimeRange::new(0, 0),
290            min_zoom: 0,
291            max_zoom: 14,
292            tile_count: 0,
293            feature_count: 0,
294            layers: vec!["default".to_string()],
295            properties: HashMap::new(),
296            temporal_bucket_ms: 3600 * 1000, // 1 hour default
297            summary_tier: None,
298            temporal_lod: None,
299            heatmap_domain: None,
300            style_hints: None,
301        }
302    }
303}
304
305impl Metadata {
306    /// Create a new metadata object
307    pub fn new(name: impl Into<String>) -> Self {
308        Self {
309            name: name.into(),
310            ..Default::default()
311        }
312    }
313
314    /// Set description
315    pub fn with_description(mut self, description: impl Into<String>) -> Self {
316        self.description = description.into();
317        self
318    }
319
320    /// Set attribution
321    pub fn with_attribution(mut self, attribution: impl Into<String>) -> Self {
322        self.attribution = attribution.into();
323        self
324    }
325
326    /// Set bounds
327    pub fn with_bounds(mut self, bounds: BoundingBox) -> Self {
328        self.bounds = bounds;
329        self
330    }
331
332    /// Set time range
333    pub fn with_time_range(mut self, time_range: TimeRange) -> Self {
334        self.time_range = time_range;
335        self
336    }
337
338    /// Set zoom levels
339    pub fn with_zoom_levels(mut self, min_zoom: u8, max_zoom: u8) -> Self {
340        self.min_zoom = min_zoom;
341        self.max_zoom = max_zoom;
342        self
343    }
344
345    /// Set temporal bucket size in milliseconds
346    pub fn with_temporal_bucket_ms(mut self, temporal_bucket_ms: u64) -> Self {
347        self.temporal_bucket_ms = temporal_bucket_ms;
348        self
349    }
350
351    /// Attach a temporal LOD pyramid.
352    ///
353    /// Each level's `bucket_ms` MUST be a strict multiple of the archive's
354    /// base `temporal_bucket_ms` and MUST be strictly greater than it; levels
355    /// MUST be sorted by ascending `bucket_ms`. Returns `Err` if the input
356    /// breaks any of those invariants — the build pipeline relies on them
357    /// when re-bucketing features into LOD aggregates.
358    pub fn with_temporal_lod(mut self, levels: Vec<TemporalLodLevel>) -> Result<Self> {
359        validate_temporal_lod(self.temporal_bucket_ms, &levels)?;
360        self.temporal_lod = if levels.is_empty() { None } else { Some(levels) };
361        Ok(self)
362    }
363
364    /// Return the LOD level that applies at `zoom`, if any. The largest
365    /// matching `bucket_ms` (coarsest level) wins — at a global zoom, you
366    /// want the coarsest available aggregate, not the finest.
367    pub fn temporal_lod_for_zoom(&self, zoom: u8) -> Option<&TemporalLodLevel> {
368        let levels = self.temporal_lod.as_ref()?;
369        levels
370            .iter()
371            .filter(|l| zoom <= l.max_zoom_level)
372            .max_by_key(|l| l.bucket_ms)
373    }
374
375    /// Add a custom property
376    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
377        self.properties.insert(key.into(), value.into());
378        self
379    }
380
381    /// Attach an aggregated summary-tier descriptor.
382    pub fn with_summary_tier(mut self, tier: SummaryTier) -> Self {
383        self.summary_tier = Some(tier);
384        self
385    }
386
387    /// Attach a bake-time HeatmapLayer intensity-domain block.
388    pub fn with_heatmap_domain(mut self, domain: HeatmapDomain) -> Self {
389        self.heatmap_domain = Some(domain);
390        self
391    }
392
393    /// Attach a build-time style-hints block.
394    pub fn with_style_hints(mut self, hints: StyleHints) -> Self {
395        self.style_hints = Some(hints);
396        self
397    }
398
399    /// Serialise to the JSON byte form stored in an archive.
400    pub fn to_json_bytes(&self) -> Result<Vec<u8>> {
401        serde_json::to_vec(self)
402            .map_err(|e| Error::Other(format!("metadata JSON encode failed: {e}")))
403    }
404
405    /// Parse from the JSON byte form stored in an archive.
406    pub fn from_json_bytes(bytes: &[u8]) -> Result<Self> {
407        serde_json::from_slice(bytes)
408            .map_err(|e| Error::InvalidArchive(format!("metadata JSON decode failed: {e}")))
409    }
410
411    /// Render a TileJSON 3.0 descriptor (with a STAC-style `temporal`
412    /// extension) for this archive.
413    ///
414    /// This is the self-describing, ecosystem-recognisable face of the archive:
415    /// MapLibre / Leaflet / OpenLayers understand the core TileJSON fields, and
416    /// the additive `temporal` block — an ISO-8601 `interval` à la STAC, plus
417    /// the bucket size/step and any LOD pyramid — carries the time dimension the
418    /// spatial-only standard lacks (unknown keys are ignored by existing
419    /// clients). `tile_url_template` is the `{z}/{x}/{y}/{t}` URL the host serves
420    /// tiles at; when `None`, a relative template is emitted.
421    pub fn to_tilejson(&self, tile_url_template: Option<&str>) -> serde_json::Value {
422        use serde_json::json;
423        let tiles = tile_url_template.unwrap_or("{z}/{x}/{y}/{t}");
424        let center_lon = (self.bounds.min_lon + self.bounds.max_lon) / 2.0;
425        let center_lat = (self.bounds.min_lat + self.bounds.max_lat) / 2.0;
426
427        let to_iso = |ms: u64| -> serde_json::Value {
428            // Guard the u64→i64 cast: a timestamp beyond i64::MAX ms (year ~292M)
429            // would wrap negative; surface it as a null open bound instead.
430            if ms > i64::MAX as u64 {
431                return serde_json::Value::Null;
432            }
433            chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms as i64)
434                .map(|dt| json!(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)))
435                .unwrap_or(serde_json::Value::Null)
436        };
437
438        let vector_layers: Vec<serde_json::Value> = self
439            .layers
440            .iter()
441            .map(|name| {
442                json!({
443                    "id": name,
444                    "fields": {},
445                    "minzoom": self.min_zoom,
446                    "maxzoom": self.max_zoom,
447                })
448            })
449            .collect();
450
451        // STAC temporal extent: interval is an array of [start, end] pairs with
452        // `null` for an open end. We carry the bucket size + ISO-8601 step and
453        // any LOD pyramid as additive keys.
454        let mut temporal = json!({
455            "interval": [[to_iso(self.time_range.start), to_iso(self.time_range.end)]],
456            "bucket_ms": self.temporal_bucket_ms,
457        });
458        if let Some(step) = iso8601_duration(self.temporal_bucket_ms) {
459            temporal["step"] = json!(step);
460        }
461        if let Some(levels) = &self.temporal_lod {
462            temporal["lod"] = json!(levels
463                .iter()
464                .map(|l| json!({ "bucket_ms": l.bucket_ms, "max_zoom": l.max_zoom_level }))
465                .collect::<Vec<_>>());
466        }
467
468        json!({
469            "tilejson": "3.0.0",
470            "tiles": [tiles],
471            "name": self.name,
472            "description": self.description,
473            "attribution": self.attribution,
474            "scheme": "xyz",
475            "version": "1.0.0",
476            "minzoom": self.min_zoom,
477            "maxzoom": self.max_zoom,
478            "bounds": [
479                self.bounds.min_lon,
480                self.bounds.min_lat,
481                self.bounds.max_lon,
482                self.bounds.max_lat
483            ],
484            "center": [center_lon, center_lat, self.min_zoom],
485            "vector_layers": vector_layers,
486            "temporal": temporal,
487        })
488    }
489}
490
491/// Verify the LOD invariants: ascending bucket order, multiples of the base
492/// bucket, strictly coarser than base, distinct bucket sizes.
493fn validate_temporal_lod(base_bucket_ms: u64, levels: &[TemporalLodLevel]) -> Result<()> {
494    if base_bucket_ms == 0 {
495        return Err(Error::Other(
496            "temporal_bucket_ms must be non-zero when declaring a LOD pyramid".into(),
497        ));
498    }
499    let mut prev: Option<u64> = None;
500    for (i, level) in levels.iter().enumerate() {
501        if level.bucket_ms == 0 {
502            return Err(Error::Other(format!(
503                "temporal_lod[{i}].bucket_ms must be non-zero"
504            )));
505        }
506        if level.bucket_ms <= base_bucket_ms {
507            return Err(Error::Other(format!(
508                "temporal_lod[{i}].bucket_ms ({}) must be > base bucket ({})",
509                level.bucket_ms, base_bucket_ms
510            )));
511        }
512        if level.bucket_ms % base_bucket_ms != 0 {
513            return Err(Error::Other(format!(
514                "temporal_lod[{i}].bucket_ms ({}) must be a multiple of base bucket ({})",
515                level.bucket_ms, base_bucket_ms
516            )));
517        }
518        if let Some(p) = prev {
519            if level.bucket_ms <= p {
520                return Err(Error::Other(format!(
521                    "temporal_lod must be sorted by ascending bucket_ms; got {} after {}",
522                    level.bucket_ms, p
523                )));
524            }
525        }
526        prev = Some(level.bucket_ms);
527    }
528    Ok(())
529}
530
531/// Format a millisecond duration as an ISO-8601 duration string for the
532/// TileJSON `temporal.step` field (best-effort: days / hours / minutes /
533/// seconds). Returns `None` for a zero bucket.
534fn iso8601_duration(ms: u64) -> Option<String> {
535    if ms == 0 {
536        return None;
537    }
538    Some(if ms % 86_400_000 == 0 {
539        format!("P{}D", ms / 86_400_000)
540    } else if ms % 3_600_000 == 0 {
541        format!("PT{}H", ms / 3_600_000)
542    } else if ms % 60_000 == 0 {
543        format!("PT{}M", ms / 60_000)
544    } else if ms % 1000 == 0 {
545        format!("PT{}S", ms / 1000)
546    } else {
547        format!("PT{:.3}S", ms as f64 / 1000.0)
548    })
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_metadata_json_roundtrip() {
557        let metadata = Metadata::new("json-test")
558            .with_description("desc")
559            .with_zoom_levels(2, 12)
560            .with_temporal_bucket_ms(3_600_000)
561            .with_property("source", "unit-test");
562        let bytes = metadata.to_json_bytes().unwrap();
563        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
564        assert_eq!(decoded.name, "json-test");
565        assert_eq!(decoded.min_zoom, 2);
566        assert_eq!(decoded.max_zoom, 12);
567        assert_eq!(decoded.temporal_bucket_ms, 3_600_000);
568        assert_eq!(decoded.properties.get("source").map(String::as_str), Some("unit-test"));
569    }
570
571    #[test]
572    fn test_metadata_summary_tier_roundtrip() {
573        let tier = SummaryTier {
574            scheme: SummaryScheme::H3,
575            min_zoom: 0,
576            max_zoom: 4,
577            cell_resolution_per_zoom: vec![0, 1, 2, 3, 4],
578            columns: vec![
579                SummaryColumn {
580                    name: "magnitude".to_string(),
581                    agg: SummaryAggregation::Mean,
582                },
583                SummaryColumn {
584                    name: "magnitude".to_string(),
585                    agg: SummaryAggregation::Max,
586                },
587            ],
588            layer_name: "summary".to_string(),
589            sub_buckets: 1,
590        };
591        let metadata = Metadata::new("summary-test").with_summary_tier(tier.clone());
592        let bytes = metadata.to_json_bytes().unwrap();
593        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
594        let dt = decoded.summary_tier.unwrap();
595        assert_eq!(dt.scheme, SummaryScheme::H3);
596        assert_eq!(dt.min_zoom, 0);
597        assert_eq!(dt.max_zoom, 4);
598        assert_eq!(dt.cell_resolution_per_zoom.len(), 5);
599        assert_eq!(dt.columns.len(), 2);
600        assert_eq!(dt.resolution_for_zoom(2), 2);
601        // Out-of-range zooms clamp to the endpoints.
602        assert_eq!(dt.resolution_for_zoom(10), 4);
603    }
604
605    #[test]
606    fn test_metadata_ignores_unknown_fields() {
607        // Forward compat: metadata written by a newer builder (or carrying
608        // since-removed fields like the old `raster_tier` scaffold) must
609        // still decode — serde skips unknown keys by default.
610        let metadata = Metadata::new("fwd");
611        let mut v: serde_json::Value =
612            serde_json::from_slice(&metadata.to_json_bytes().unwrap()).unwrap();
613        v["raster_tier"] = serde_json::json!({ "min_zoom": 0, "max_zoom": 5 });
614        let decoded = Metadata::from_json_bytes(&serde_json::to_vec(&v).unwrap()).unwrap();
615        assert_eq!(decoded.name, "fwd");
616    }
617
618    #[test]
619    fn test_heatmap_domain_roundtrip() {
620        let domain = HeatmapDomain {
621            classes: vec![
622                HeatmapClassDomain {
623                    id: "pickup".to_string(),
624                    min: 0.0,
625                    max: 7.5,
626                    property: Some("intensity".to_string()),
627                },
628                HeatmapClassDomain {
629                    id: "dropoff".to_string(),
630                    min: 0.0,
631                    max: 9.0,
632                    property: None,
633                },
634            ],
635        };
636        let metadata = Metadata::new("heat-test").with_heatmap_domain(domain.clone());
637        let bytes = metadata.to_json_bytes().unwrap();
638        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
639        let d = decoded.heatmap_domain.unwrap();
640        assert_eq!(d.classes.len(), 2);
641        assert_eq!(d.classes[0].id, "pickup");
642        assert_eq!(d.classes[0].max, 7.5);
643        assert_eq!(d.classes[0].property.as_deref(), Some("intensity"));
644        assert_eq!(d.classes[1].id, "dropoff");
645        assert_eq!(d.classes[1].property, None);
646    }
647
648    #[test]
649    fn test_heatmap_domain_field_omitted_when_unset() {
650        let metadata = Metadata::new("no-heat");
651        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
652        assert!(!s.contains("heatmap_domain"), "got: {s}");
653    }
654
655    #[test]
656    fn test_style_hints_roundtrip() {
657        let hints = StyleHints {
658            version: 1,
659            properties: vec![
660                PropertyStyleHint {
661                    name: "magnitude".to_string(),
662                    min: Some(0.1),
663                    p50: Some(2.0),
664                    p90: Some(4.1),
665                    p95: Some(4.9),
666                    p97: Some(5.3),
667                    p99: Some(6.2),
668                    max: Some(9.1),
669                    suggested_domain: Some([0.1, 5.3]),
670                    cardinality: None,
671                },
672                // Categorical: ONLY name + cardinality.
673                PropertyStyleHint {
674                    name: "category".to_string(),
675                    cardinality: Some(7),
676                    ..Default::default()
677                },
678            ],
679            suggested_playback_seconds: Some(45),
680            layer_hint: Some("points".to_string()),
681        };
682        let metadata = Metadata::new("hints-test").with_style_hints(hints.clone());
683        let bytes = metadata.to_json_bytes().unwrap();
684
685        // Wire shape: the categorical entry must carry NO numeric keys (absent,
686        // not null-filled) — pinned cross-language contract with the TS reader.
687        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
688        let cat = &v["style_hints"]["properties"][1];
689        assert_eq!(cat["name"], "category");
690        assert_eq!(cat["cardinality"], 7);
691        let cat_keys: Vec<&String> = cat.as_object().unwrap().keys().collect();
692        assert_eq!(cat_keys.len(), 2, "categorical carries only name+cardinality: {cat_keys:?}");
693        // Numeric entry: no cardinality key.
694        assert!(v["style_hints"]["properties"][0].get("cardinality").is_none());
695        assert_eq!(v["style_hints"]["suggested_playback_seconds"], 45);
696        assert_eq!(v["style_hints"]["layer_hint"], "points");
697
698        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
699        assert_eq!(decoded.style_hints, Some(hints));
700    }
701
702    #[test]
703    fn test_style_hints_field_omitted_when_unset() {
704        // Old readers must be unaffected: an archive built without
705        // --style-hints carries no `style_hints` key at all, and a legacy
706        // metadata JSON without the key decodes to None (see the
707        // `test_metadata_without_summary_tier_decodes` fixture, which also
708        // lacks style_hints).
709        let metadata = Metadata::new("no-hints");
710        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
711        assert!(!s.contains("style_hints"), "got: {s}");
712    }
713
714    #[test]
715    fn test_metadata_without_summary_tier_decodes() {
716        // A pre-summary-tier archive's metadata JSON has no `summary_tier`
717        // field at all. serde's `#[default]` must accept it.
718        let json = br#"{
719            "name": "old",
720            "description": "",
721            "attribution": "",
722            "bounds": {"min_lon":-180.0,"min_lat":-85.0,"max_lon":180.0,"max_lat":85.0},
723            "time_range": {"start":0,"end":1000},
724            "min_zoom": 0,
725            "max_zoom": 8,
726            "tile_count": 0,
727            "feature_count": 0,
728            "layers": ["default"],
729            "properties": {},
730            "temporal_bucket_ms": 3600000
731        }"#;
732        let m = Metadata::from_json_bytes(json).unwrap();
733        assert!(m.summary_tier.is_none());
734    }
735
736    #[test]
737    fn test_metadata_builder() {
738        let metadata = Metadata::new("test")
739            .with_description("Test archive")
740            .with_attribution("Test data")
741            .with_zoom_levels(0, 14)
742            .with_property("key", "value");
743
744        assert_eq!(metadata.name, "test");
745        assert_eq!(metadata.description, "Test archive");
746        assert_eq!(metadata.min_zoom, 0);
747        assert_eq!(metadata.max_zoom, 14);
748        assert_eq!(metadata.properties.get("key"), Some(&"value".to_string()));
749    }
750
751    // ------------------------------------------------------------------
752    // temporal_lod
753    // ------------------------------------------------------------------
754
755    fn hour() -> u64 {
756        3_600_000
757    }
758    fn day() -> u64 {
759        24 * hour()
760    }
761    fn thirty_days() -> u64 {
762        30 * day()
763    }
764
765    #[test]
766    fn temporal_lod_roundtrips_through_json() {
767        let levels = vec![
768            TemporalLodLevel {
769                bucket_ms: day(),
770                max_zoom_level: 8,
771            },
772            TemporalLodLevel {
773                bucket_ms: thirty_days(),
774                max_zoom_level: 4,
775            },
776        ];
777        let metadata = Metadata::new("lod")
778            .with_temporal_bucket_ms(hour())
779            .with_temporal_lod(levels.clone())
780            .unwrap();
781        let bytes = metadata.to_json_bytes().unwrap();
782        let decoded = Metadata::from_json_bytes(&bytes).unwrap();
783        assert_eq!(decoded.temporal_lod.as_deref(), Some(levels.as_slice()));
784    }
785
786    #[test]
787    fn temporal_lod_field_omitted_when_unset() {
788        // Older readers that don't know about temporal_lod must still parse
789        // a freshly-written archive; the field is skipped when None.
790        let metadata = Metadata::new("no-lod").with_temporal_bucket_ms(hour());
791        let s = String::from_utf8(metadata.to_json_bytes().unwrap()).unwrap();
792        assert!(!s.contains("temporal_lod"), "got: {s}");
793    }
794
795    #[test]
796    fn temporal_lod_missing_field_decodes_back_compat() {
797        // A v3 archive built before this feature has no `temporal_lod` key
798        // in its metadata JSON; the new field must default to None.
799        let legacy = r#"{
800            "name": "legacy",
801            "description": "",
802            "attribution": "",
803            "bounds": {"min_lon": -180, "min_lat": -85, "max_lon": 180, "max_lat": 85},
804            "time_range": {"start": 0, "end": 1},
805            "min_zoom": 0,
806            "max_zoom": 14,
807            "tile_count": 0,
808            "feature_count": 0,
809            "layers": ["default"],
810            "properties": {},
811            "temporal_bucket_ms": 3600000
812        }"#;
813        let m = Metadata::from_json_bytes(legacy.as_bytes()).unwrap();
814        assert!(m.temporal_lod.is_none());
815    }
816
817    #[test]
818    fn temporal_lod_rejects_non_multiple_bucket() {
819        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
820            TemporalLodLevel { bucket_ms: hour() + 7, max_zoom_level: 5 },
821        ]);
822        assert!(res.is_err());
823    }
824
825    #[test]
826    fn temporal_lod_rejects_bucket_smaller_than_or_equal_to_base() {
827        let res = Metadata::new("bad")
828            .with_temporal_bucket_ms(day())
829            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
830        assert!(res.is_err());
831
832        let res = Metadata::new("bad")
833            .with_temporal_bucket_ms(hour())
834            .with_temporal_lod(vec![TemporalLodLevel { bucket_ms: hour(), max_zoom_level: 5 }]);
835        assert!(res.is_err());
836    }
837
838    #[test]
839    fn temporal_lod_rejects_unsorted_levels() {
840        let res = Metadata::new("bad").with_temporal_bucket_ms(hour()).with_temporal_lod(vec![
841            TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
842            TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
843        ]);
844        assert!(res.is_err());
845    }
846
847    #[test]
848    fn temporal_lod_for_zoom_picks_coarsest_applicable() {
849        let m = Metadata::new("lod")
850            .with_temporal_bucket_ms(hour())
851            .with_temporal_lod(vec![
852                TemporalLodLevel { bucket_ms: day(), max_zoom_level: 8 },
853                TemporalLodLevel { bucket_ms: thirty_days(), max_zoom_level: 4 },
854            ])
855            .unwrap();
856        // Very-zoomed-out: both levels apply, pick the coarser (30d).
857        assert_eq!(
858            m.temporal_lod_for_zoom(0).map(|l| l.bucket_ms),
859            Some(thirty_days())
860        );
861        // Mid zoom: only the day level applies.
862        assert_eq!(m.temporal_lod_for_zoom(6).map(|l| l.bucket_ms), Some(day()));
863        // High zoom: no LOD — fall back to base bucket.
864        assert!(m.temporal_lod_for_zoom(12).is_none());
865    }
866
867    #[test]
868    fn temporal_lod_for_zoom_is_none_when_unset() {
869        let m = Metadata::new("plain").with_temporal_bucket_ms(hour());
870        assert!(m.temporal_lod_for_zoom(0).is_none());
871    }
872
873    #[test]
874    fn temporal_lod_empty_vec_clears_to_none() {
875        // Passing an empty list is treated as "no LOD" rather than an error,
876        // so callers can compute the level set unconditionally.
877        let m = Metadata::new("empty")
878            .with_temporal_bucket_ms(hour())
879            .with_temporal_lod(vec![])
880            .unwrap();
881        assert!(m.temporal_lod.is_none());
882    }
883
884    #[test]
885    fn tilejson_descriptor_has_core_fields_and_temporal_extension() {
886        let m = Metadata::new("quakes")
887            .with_description("USGS earthquakes")
888            .with_attribution("USGS")
889            .with_zoom_levels(0, 10)
890            .with_temporal_bucket_ms(hour())
891            .with_time_range(TimeRange::new(1_700_000_000_000, 1_700_086_400_000))
892            .with_temporal_lod(vec![TemporalLodLevel {
893                bucket_ms: day(),
894                max_zoom_level: 6,
895            }])
896            .unwrap();
897        let tj = m.to_tilejson(Some("https://cdn/{z}/{x}/{y}/{t}.stt"));
898
899        // Core TileJSON 3.0 fields every web client recognises.
900        assert_eq!(tj["tilejson"], "3.0.0");
901        assert_eq!(tj["tiles"][0], "https://cdn/{z}/{x}/{y}/{t}.stt");
902        assert_eq!(tj["minzoom"], 0);
903        assert_eq!(tj["maxzoom"], 10);
904        assert_eq!(tj["scheme"], "xyz");
905        assert_eq!(tj["vector_layers"][0]["id"], "default");
906        assert_eq!(tj["bounds"].as_array().unwrap().len(), 4);
907
908        // Additive STAC-style temporal extension.
909        assert_eq!(tj["temporal"]["step"], "PT1H");
910        assert_eq!(tj["temporal"]["bucket_ms"], 3_600_000u64);
911        let interval = &tj["temporal"]["interval"][0];
912        assert!(interval[0].as_str().unwrap().starts_with("2023-11-"));
913        assert!(interval[1].is_string());
914        assert_eq!(tj["temporal"]["lod"][0]["bucket_ms"], day());
915    }
916
917    #[test]
918    fn iso8601_duration_formats_common_buckets() {
919        assert_eq!(iso8601_duration(hour()).as_deref(), Some("PT1H"));
920        assert_eq!(iso8601_duration(day()).as_deref(), Some("P1D"));
921        assert_eq!(iso8601_duration(60_000).as_deref(), Some("PT1M"));
922        assert_eq!(iso8601_duration(0), None);
923    }
924
925    #[test]
926    fn tilejson_time_beyond_i64_is_null_not_garbage() {
927        // A timestamp past i64::MAX ms must surface as a null open bound rather
928        // than wrapping to a negative (bogus) date.
929        let m = Metadata::new("x").with_time_range(TimeRange::new(u64::MAX, u64::MAX));
930        let tj = m.to_tilejson(None);
931        assert!(tj["temporal"]["interval"][0][0].is_null());
932        assert!(tj["temporal"]["interval"][0][1].is_null());
933    }
934}