bland 0.2.1

Pure-Rust library for paper-ready, monochrome, hatch-patterned technical plots in the visual tradition of 1960s-80s engineering reports.
Documentation
//! Static base-layer data for geographic figures.
//!
//! BLAND ships hand-curated, low-resolution cartographic outlines
//! suitable for schematic maps in the tradition of 1960s-80s technical
//! reports. The data is embedded in the library — no downloads, no
//! external assets, no runtime dependencies.
//!
//! # Available layers
//!
//! ## Earth
//!
//! - [`Basemap::EarthCoastlines`] — continental outlines and major islands
//! - [`Basemap::EarthBorders`] — simplified outlines of major countries
//! - [`Basemap::EarthTropics`] — Tropic of Cancer, Equator, Tropic of
//!   Capricorn, Arctic / Antarctic circles as labelled horizontal
//!   reference lines
//!
//! ## Moon (selenographic coordinates)
//!
//! - [`Basemap::MoonMaria`] — simplified outlines of the major lunar
//!   maria (Imbrium, Serenitatis, Tranquillitatis, Crisium, …)
//!
//! # Use via [`Figure::basemap`](crate::Figure::basemap)
//!
//! ```no_run
//! use bland::{Basemap, Figure, PaperSize, Projection};
//!
//! let fig = Figure::new()
//!     .size(PaperSize::A4Landscape)
//!     .projection(Projection::Mercator)
//!     .xlim(-180.0, 180.0)
//!     .ylim(-70.0, 75.0)
//!     .basemap(Basemap::EarthCoastlines, |b| b)
//!     .basemap(Basemap::EarthBorders, |b| b.stroke(bland::Stroke::Dashed));
//! ```
//!
//! # Resolutions
//!
//! Earth coastlines and borders ship at three resolutions:
//!
//! - [`Resolution::Schematic`] — the hand-curated outlines shipped with
//!   BLAND. ~15 features, ~5KB of compiled data, recognizable at world
//!   scale and intentionally rough.
//! - [`Resolution::Low`] — Natural Earth 1:110m. ~130 coastline
//!   features, ~180 country polygons, ~150KB of compiled data. Default.
//! - [`Resolution::High`] — Natural Earth 1:50m. ~1,400 coastline
//!   features, ~240 country polygons. Gated behind the
//!   `high-res-basemaps` cargo feature because it adds ~3MB to the
//!   crate.
//!
//! Layers that aren't resolution-scaled (`EarthTropics`, `MoonMaria`)
//! ignore the resolution argument.

mod earth;
mod moon;

pub mod data;

use crate::hatch::Hatch;
use crate::strokes::Stroke;

/// A basemap layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Basemap {
    /// Continental outlines and major islands.
    EarthCoastlines,
    /// Simplified outlines of major countries (closed polygons).
    EarthBorders,
    /// Standard reference parallels: Arctic Circle, Tropic of Cancer,
    /// Equator, Tropic of Capricorn, Antarctic Circle.
    EarthTropics,
    /// Outlines of the major lunar maria.
    MoonMaria,
}

/// Resolution for [`Basemap::EarthCoastlines`] and [`Basemap::EarthBorders`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Resolution {
    /// Hand-curated continental outlines. ~15 features. Smallest.
    Schematic,
    /// Natural Earth 1:110m. ~130 features. Default.
    #[default]
    Low,
    /// Natural Earth 1:50m. Requires the `high-res-basemaps` cargo
    /// feature; falls back to [`Resolution::Low`] if the feature is
    /// not enabled.
    High,
}

/// A basemap feature — one polyline (potentially closed).
#[derive(Debug, Clone)]
pub struct Feature {
    pub name: &'static str,
    pub closed: bool,
    pub points: &'static [(f64, f64)],
}

/// Returns the features for a basemap layer.
pub fn features(layer: Basemap, resolution: Resolution) -> Vec<&'static Feature> {
    match layer {
        Basemap::EarthCoastlines => earth_coastlines(resolution),
        Basemap::EarthBorders => earth_borders(resolution),
        Basemap::EarthTropics => earth::TROPICS.iter().collect(),
        Basemap::MoonMaria => moon::MARIA.iter().collect(),
    }
}

fn earth_coastlines(resolution: Resolution) -> Vec<&'static Feature> {
    match resolution {
        Resolution::Schematic => earth::COASTLINES_SCHEMATIC.iter().collect(),
        Resolution::Low => data::coastline_110m::FEATURES.iter().collect(),
        Resolution::High => {
            #[cfg(feature = "high-res-basemaps")]
            {
                data::coastline_50m::FEATURES.iter().collect()
            }
            #[cfg(not(feature = "high-res-basemaps"))]
            {
                data::coastline_110m::FEATURES.iter().collect()
            }
        }
    }
}

fn earth_borders(resolution: Resolution) -> Vec<&'static Feature> {
    match resolution {
        Resolution::Schematic => earth::BORDERS_SCHEMATIC.iter().collect(),
        Resolution::Low => data::countries_110m::FEATURES.iter().collect(),
        Resolution::High => {
            #[cfg(feature = "high-res-basemaps")]
            {
                data::countries_50m::FEATURES.iter().collect()
            }
            #[cfg(not(feature = "high-res-basemaps"))]
            {
                data::countries_110m::FEATURES.iter().collect()
            }
        }
    }
}

/// Per-call options for [`Figure::basemap`](crate::Figure::basemap).
#[derive(Debug, Clone)]
pub struct BasemapOpts {
    pub(crate) resolution: Resolution,
    pub(crate) stroke: Stroke,
    pub(crate) stroke_width: f64,
    pub(crate) hatch: Option<Hatch>,
    pub(crate) only: Option<Vec<String>>,
    pub(crate) except: Option<Vec<String>>,
}

impl Default for BasemapOpts {
    fn default() -> Self {
        Self {
            resolution: Resolution::Low,
            stroke: Stroke::Solid,
            stroke_width: 0.8,
            hatch: None,
            only: None,
            except: None,
        }
    }
}

impl BasemapOpts {
    pub fn resolution(mut self, r: Resolution) -> Self {
        self.resolution = r;
        self
    }
    pub fn stroke(mut self, s: Stroke) -> Self {
        self.stroke = s;
        self
    }
    pub fn stroke_width(mut self, w: f64) -> Self {
        self.stroke_width = w;
        self
    }
    /// Fill closed features with this hatch instead of leaving them
    /// stroke-only. Ignored on open features (e.g. `EarthTropics`).
    pub fn hatch(mut self, h: Hatch) -> Self {
        self.hatch = Some(h);
        self
    }
    /// Restrict to features whose `name` is in `names`.
    pub fn only<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
        self.only = Some(names.into_iter().map(|n| n.into()).collect());
        self
    }
    /// Exclude features whose `name` is in `names`.
    pub fn except<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
        self.except = Some(names.into_iter().map(|n| n.into()).collect());
        self
    }
}

pub(crate) fn filter_features<'a>(
    features: Vec<&'a Feature>,
    only: &Option<Vec<String>>,
    except: &Option<Vec<String>>,
) -> Vec<&'a Feature> {
    features
        .into_iter()
        .filter(|f| match only {
            Some(names) => names.iter().any(|n| n == f.name),
            None => true,
        })
        .filter(|f| match except {
            Some(names) => !names.iter().any(|n| n == f.name),
            None => true,
        })
        .collect()
}