use std::collections::BTreeMap;
use std::fmt;
use std::ops::RangeInclusive;
use martin_core::tiles::contour::{ContourOptions, ElevationUnits, ZoomIntervalMap};
use serde::de::{self, Unexpected, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::config::file::{CollectUnrecognizedKeys, UnrecognizedKeys, UnrecognizedValues};
use crate::config::primitives::AutoOption;
pub const MAX_EXTENT: u32 = 16_384;
pub const MAX_FETCH_MARGIN: u32 = 64;
#[derive(thiserror::Error, Debug, PartialEq, Eq, Clone)]
#[error("Contour parameter {name} must be between `{low}` and `{high}`, but was `{value}`")]
pub struct ContourRangeError {
pub name: String,
pub value: String,
pub low: String,
pub high: String,
}
pub type ContourProcessConfig = AutoOption<ContourSettings>;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "unstable-schemas", derive(schemars::JsonSchema))]
pub enum ContourElevationUnits {
#[default]
#[serde(rename = "meters")]
Meters,
#[serde(rename = "feet")]
Feet,
}
impl From<ContourElevationUnits> for ElevationUnits {
fn from(value: ContourElevationUnits) -> Self {
match value {
ContourElevationUnits::Meters => Self::Meters,
ContourElevationUnits::Feet => Self::Feet,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FilteredThreshold {
Disabled,
Elevation(f32),
}
impl Default for FilteredThreshold {
fn default() -> Self {
Self::Elevation(0.0)
}
}
impl FilteredThreshold {
#[must_use]
pub const fn as_elevation(self) -> Option<f32> {
match self {
Self::Disabled => None,
Self::Elevation(v) => Some(v),
}
}
}
impl Serialize for FilteredThreshold {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Disabled => serializer.serialize_str("disabled"),
Self::Elevation(v) => serializer.serialize_f32(*v),
}
}
}
impl<'de> Deserialize<'de> for FilteredThreshold {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_any(FilteredThresholdVisitor)
}
}
struct FilteredThresholdVisitor;
impl Visitor<'_> for FilteredThresholdVisitor {
type Value = FilteredThreshold;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(r#"an elevation number or the string "disabled""#)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v {
"disabled" | "off" | "none" => Ok(FilteredThreshold::Disabled),
_ => Err(E::invalid_value(Unexpected::Str(v), &self)),
}
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
if v {
Err(E::invalid_value(Unexpected::Bool(v), &self))
} else {
Ok(FilteredThreshold::Disabled)
}
}
#[expect(clippy::cast_possible_truncation, reason = "an elevation in meters")]
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Self::Value, E> {
Ok(FilteredThreshold::Elevation(v as f32))
}
#[expect(clippy::cast_precision_loss, reason = "an elevation in meters")]
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
Ok(FilteredThreshold::Elevation(v as f32))
}
#[expect(clippy::cast_precision_loss, reason = "an elevation in meters")]
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(FilteredThreshold::Elevation(v as f32))
}
}
#[cfg(feature = "unstable-schemas")]
impl schemars::JsonSchema for FilteredThreshold {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("FilteredThreshold")
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description":
"An elevation whose contour line is suppressed:\n\n\
- A number - skip the contour at this elevation\n\
- `\"disabled\"` or boolean `false` - draw every threshold",
"oneOf": [
{ "type": "number", "description": "Elevation to skip." },
{
"type": "string",
"enum": ["disabled", "off", "none"],
"description": "Draw every threshold."
},
{ "type": "boolean", "description": "false = draw every threshold." },
]
})
}
}
#[cfg(feature = "unstable-schemas")]
fn zoom_intervals_example() -> BTreeMap<u8, f32> {
BTreeMap::from([
(0, 0.0),
(4, 400.0),
(6, 200.0),
(8, 150.0),
(10, 100.0),
(12, 50.0),
])
}
#[serde_with::skip_serializing_none]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "unstable-schemas", derive(schemars::JsonSchema))]
pub struct ContourSettings {
#[cfg_attr(feature = "unstable-schemas", schemars(example = &10.0f64))]
pub resolution: Option<f64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &5.0f64))]
pub major_interval: Option<f64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &10.0f64))]
pub simplification_tolerance: Option<f64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &50.0f64))]
pub min_feature_length: Option<f64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &32.0f64))]
pub fetch_margin: Option<f64>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = zoom_intervals_example()))]
pub zoom_intervals: Option<BTreeMap<u8, f32>>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &"meters"))]
pub elevation_units: Option<ContourElevationUnits>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &0.0f32))]
pub filtered_threshold: Option<FilteredThreshold>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &"contour"))]
pub layer_name: Option<String>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &4096u32))]
pub extent: Option<u32>,
#[cfg_attr(feature = "unstable-schemas", schemars(example = &false))]
pub allow_request_overrides: Option<bool>,
#[serde(flatten, skip_serializing)]
#[cfg_attr(feature = "unstable-schemas", schemars(skip))]
pub unrecognized: UnrecognizedValues,
}
impl CollectUnrecognizedKeys for ContourSettings {
fn collect_unrecognized(&self, path: &str, out: &mut UnrecognizedKeys) {
CollectUnrecognizedKeys::collect_unrecognized(&self.unrecognized, path, out);
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ResolvedContour {
pub opts: ContourOptions,
pub allow_request_overrides: bool,
}
struct NumericParam {
name: &'static str,
range: RangeInclusive<f64>,
configured: fn(&ContourSettings) -> Option<f64>,
apply: fn(&mut ContourOptions, f64),
current: fn(&ContourOptions) -> f64,
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "every value is range-checked into its target type before the cast"
)]
static NUMERIC_PARAMS: [NumericParam; 5] = [
NumericParam {
name: "resolution",
range: 1.0..=64.0,
configured: |settings| settings.resolution,
apply: |opts, v| opts.resolution = v as f32,
current: |opts| f64::from(opts.resolution),
},
NumericParam {
name: "major_interval",
range: 0.0..=64.0,
configured: |settings| settings.major_interval,
apply: |opts, v| opts.major_interval = v as u32,
current: |opts| f64::from(opts.major_interval),
},
NumericParam {
name: "simplification_tolerance",
range: 0.0..=100.0,
configured: |settings| settings.simplification_tolerance,
apply: |opts, v| opts.simplification_tolerance = v,
current: |opts| opts.simplification_tolerance,
},
NumericParam {
name: "min_feature_length",
range: 0.0..=10_000.0,
configured: |settings| settings.min_feature_length,
apply: |opts, v| opts.min_feature_length = v,
current: |opts| opts.min_feature_length,
},
NumericParam {
name: "fetch_margin",
range: 0.0..=MAX_FETCH_MARGIN as f64,
configured: |settings| settings.fetch_margin,
apply: |opts, v| opts.fetch_margin = v as u8,
current: |opts| f64::from(opts.fetch_margin),
},
];
impl NumericParam {
fn checked(&self, value: f64) -> Result<f64, ContourRangeError> {
if self.range.contains(&value) {
Ok(value)
} else {
Err(self.rejected(value.to_string()))
}
}
fn rejected(&self, value: String) -> ContourRangeError {
ContourRangeError {
name: self.name.to_owned(),
value,
low: self.range.start().to_string(),
high: self.range.end().to_string(),
}
}
}
impl ContourSettings {
#[expect(
clippy::unneeded_field_pattern,
reason = "the ignored fields are resolved through NUMERIC_PARAMS; naming them \
anyway means adding a field without listing it there is a compile \
error, which `..` would hide"
)]
pub fn resolve(&self) -> Result<ResolvedContour, ContourRangeError> {
let Self {
resolution: _,
major_interval: _,
simplification_tolerance: _,
min_feature_length: _,
fetch_margin: _,
zoom_intervals,
elevation_units,
filtered_threshold,
layer_name,
extent,
allow_request_overrides,
unrecognized: _,
} = self;
let mut out = ResolvedContour::default();
for param in &NUMERIC_PARAMS {
if let Some(v) = (param.configured)(self) {
(param.apply)(&mut out.opts, param.checked(v)?);
}
}
let units = elevation_units.unwrap_or_default().into();
out.opts.elevation_units = units;
out.opts.threshold_intervals = match zoom_intervals {
Some(map) => {
let pairs: Vec<(u8, f32)> = map.iter().map(|(&z, &i)| (z, i)).collect();
ZoomIntervalMap::new(&pairs, units)
}
None => ZoomIntervalMap::default_for_units(units),
};
out.opts.filtered_threshold = filtered_threshold.unwrap_or_default().as_elevation();
if let Some(name) = layer_name {
if name.is_empty() {
return Err(ContourRangeError {
name: "layer_name".to_owned(),
value: String::new(),
low: "1".to_owned(),
high: "any".to_owned(),
});
}
out.opts.layer_name.clone_from(name);
}
if let Some(v) = *extent {
if v == 0 || v > MAX_EXTENT {
return Err(ContourRangeError {
name: "extent".to_owned(),
value: v.to_string(),
low: "1".to_owned(),
high: MAX_EXTENT.to_string(),
});
}
out.opts.extent = v;
}
if let Some(v) = *allow_request_overrides {
out.allow_request_overrides = v;
}
Ok(out)
}
}
impl ContourProcessConfig {
#[must_use]
pub const fn is_enabled(&self) -> bool {
!matches!(self, Self::Disabled)
}
pub fn resolve_contour(&self) -> Result<Option<ResolvedContour>, ContourRangeError> {
match self {
Self::Disabled => Ok(None),
Self::Auto => Ok(Some(ResolvedContour::default())),
Self::Explicit(settings) => settings.resolve().map(Some),
}
}
}
impl ResolvedContour {
pub fn with_query_overrides<'a, I>(mut self, params: I) -> Result<Self, ContourRangeError>
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
if !self.allow_request_overrides {
return Ok(self);
}
for (key, raw) in params {
let Some(param) = NUMERIC_PARAMS.iter().find(|param| param.name == key) else {
continue;
};
let value = raw
.parse::<f64>()
.map_err(|_e| param.rejected(raw.to_owned()))?;
(param.apply)(&mut self.opts, param.checked(value)?);
}
Ok(self)
}
#[must_use]
#[expect(
clippy::unneeded_field_pattern,
reason = "the ignored fields are folded in through NUMERIC_PARAMS; naming \
every field anyway means adding one that affects the encoded bytes \
without folding it in here is a compile error, which `..` would hide"
)]
pub fn fingerprint(&self) -> String {
let ContourOptions {
resolution: _,
major_interval: _,
simplification_tolerance: _,
min_feature_length: _,
fetch_margin: _,
threshold_intervals,
elevation_units,
filtered_threshold,
layer_name,
extent,
} = &self.opts;
let numeric = NUMERIC_PARAMS
.iter()
.map(|param| (param.current)(&self.opts).to_string())
.collect::<Vec<_>>()
.join(":");
let intervals = (0..=24u8)
.map(|z| threshold_intervals.interval_meters(z).to_string())
.collect::<Vec<_>>()
.join(",");
let filtered = filtered_threshold.map_or_else(|| "off".to_owned(), |v| v.to_string());
format!("{numeric}:{intervals}:{elevation_units:?}:{filtered}:{layer_name}:{extent}")
}
}
#[cfg(test)]
mod tests {
use indoc::indoc;
use super::*;
#[test]
fn nested_override_settings_parse_from_a_source_block() {
let cfg: ContourProcessConfig = serde_saphyr::from_str(indoc! {"
allow_request_overrides: true
simplification_tolerance: 0
"})
.unwrap();
let resolved = cfg.resolve_contour().unwrap().unwrap();
assert!(resolved.allow_request_overrides);
assert!((resolved.opts.simplification_tolerance - 0.0).abs() < 1e-9);
let overridden = resolved
.with_query_overrides([("simplification_tolerance", "50")])
.unwrap();
assert!((overridden.opts.simplification_tolerance - 50.0).abs() < 1e-9);
}
#[test]
fn parse_auto_string() {
let cfg: ContourProcessConfig = serde_saphyr::from_str("auto").unwrap();
assert_eq!(cfg, ContourProcessConfig::Auto);
assert_eq!(
cfg.resolve_contour().unwrap(),
Some(ResolvedContour::default())
);
}
#[test]
fn parse_disabled_string() {
let cfg: ContourProcessConfig = serde_saphyr::from_str("disabled").unwrap();
assert_eq!(cfg.resolve_contour().unwrap(), None);
}
#[test]
fn explicit_settings_override_only_what_they_name() {
let cfg: ContourProcessConfig = serde_saphyr::from_str(indoc! {"
resolution: 8
min_feature_length: 25
"})
.unwrap();
let resolved = cfg.resolve_contour().unwrap().unwrap();
assert!((resolved.opts.resolution - 8.0).abs() < 1e-6);
assert!((resolved.opts.min_feature_length - 25.0).abs() < 1e-6);
assert_eq!(
resolved.opts.major_interval,
ContourOptions::default().major_interval
);
assert_eq!(resolved.opts.layer_name, "contour");
assert_eq!(resolved.opts.extent, 4096);
}
#[test]
fn zoom_intervals_are_read_in_the_configured_units() {
let cfg: ContourProcessConfig = serde_saphyr::from_str(indoc! {"
elevation_units: feet
zoom_intervals:
0: 0
10: 50
"})
.unwrap();
let resolved = cfg.resolve_contour().unwrap().unwrap();
assert!((resolved.opts.threshold_intervals.interval_meters(10) - 15.24).abs() < 0.01);
assert_eq!(resolved.opts.elevation_units, ElevationUnits::Feet);
}
#[test]
fn filtered_threshold_defaults_to_sea_level_and_can_be_switched_off() {
let default: ContourProcessConfig = serde_saphyr::from_str("{}").unwrap();
assert_eq!(
default
.resolve_contour()
.unwrap()
.unwrap()
.opts
.filtered_threshold,
Some(0.0)
);
let off: ContourProcessConfig =
serde_saphyr::from_str("filtered_threshold: disabled").unwrap();
assert_eq!(
off.resolve_contour()
.unwrap()
.unwrap()
.opts
.filtered_threshold,
None
);
let explicit: ContourProcessConfig =
serde_saphyr::from_str("filtered_threshold: -50").unwrap();
assert_eq!(
explicit
.resolve_contour()
.unwrap()
.unwrap()
.opts
.filtered_threshold,
Some(-50.0)
);
}
#[test]
fn out_of_range_parameters_are_rejected() {
let cfg: ContourProcessConfig = serde_saphyr::from_str("resolution: 0").unwrap();
let err = cfg.resolve_contour().unwrap_err();
assert_eq!(err.name, "resolution");
let cfg: ContourProcessConfig = serde_saphyr::from_str("fetch_margin: 500").unwrap();
assert_eq!(cfg.resolve_contour().unwrap_err().name, "fetch_margin");
let cfg: ContourProcessConfig = serde_saphyr::from_str("extent: 0").unwrap();
assert_eq!(cfg.resolve_contour().unwrap_err().name, "extent");
let cfg: ContourProcessConfig = serde_saphyr::from_str("layer_name: ''").unwrap();
assert_eq!(cfg.resolve_contour().unwrap_err().name, "layer_name");
}
#[test]
fn query_overrides_are_ignored_unless_opted_in() {
let locked = ResolvedContour::default();
let unchanged = locked
.clone()
.with_query_overrides([("resolution", "32")])
.unwrap();
assert_eq!(unchanged, locked);
let open = ResolvedContour {
allow_request_overrides: true,
..Default::default()
};
let overridden = open.with_query_overrides([("resolution", "32")]).unwrap();
assert!((overridden.opts.resolution - 32.0).abs() < 1e-6);
}
#[test]
fn query_overrides_are_range_checked_and_unknown_keys_ignored() {
let open = ResolvedContour {
allow_request_overrides: true,
..Default::default()
};
assert_eq!(
open.clone()
.with_query_overrides([("resolution", "999")])
.unwrap_err()
.name,
"resolution"
);
assert_eq!(
open.clone()
.with_query_overrides([("resolution", "not-a-number")])
.unwrap_err()
.name,
"resolution"
);
assert_eq!(
open.clone()
.with_query_overrides([("totally_unrelated", "1")])
.unwrap(),
open
);
}
#[test]
fn the_fingerprint_changes_with_every_setting_that_changes_the_bytes() {
let base = ResolvedContour::default();
let baseline = base.fingerprint();
let mut resolution = base.clone();
resolution.opts.resolution = 12.0;
assert_ne!(resolution.fingerprint(), baseline);
let mut layer = base.clone();
layer.opts.layer_name = "other".to_owned();
assert_ne!(layer.fingerprint(), baseline);
let mut intervals = base.clone();
intervals.opts.threshold_intervals =
ZoomIntervalMap::new(&[(0, 25.0)], ElevationUnits::Meters);
assert_ne!(intervals.fingerprint(), baseline);
let mut filtered = base.clone();
filtered.opts.filtered_threshold = None;
assert_ne!(filtered.fingerprint(), baseline);
let mut overrides = base.clone();
overrides.allow_request_overrides = true;
assert_eq!(overrides.fingerprint(), baseline);
}
#[test]
fn unrecognized_keys_are_collected() {
let cfg: ContourProcessConfig = serde_saphyr::from_str(indoc! {"
resolution: 8
definitely_a_typo: 1
"})
.unwrap();
let ContourProcessConfig::Explicit(inner) = cfg else {
panic!("expected explicit ContourSettings");
};
assert!(inner.get_unrecognized_keys().contains("definitely_a_typo"));
}
}