opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::data::{Dataset, Record};
use crate::error::{OpsisError, Result};

/// Top-level Vega-ish spec. A spec describes one chart.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartSpec {
    #[serde(default)]
    pub chart: ChartMeta,
    pub data: DataSource,
    #[serde(default)]
    pub encoding: Encoding,
    #[serde(default)]
    pub style: Style,
    #[serde(rename = "mark")]
    #[serde(default)]
    pub mark: Option<Mark>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChartMeta {
    #[serde(default)]
    pub r#type: ChartType,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub width: Option<f32>,
    #[serde(default)]
    pub height: Option<f32>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartType {
    Bar,
    Line,
    Scatter,
    Histogram,
    Pie,
    Area,
    Heatmap,
    BoxPlot,
}

impl Default for ChartType {
    fn default() -> Self { ChartType::Bar }
}

/// How data gets in. `values = [...]` for inline, or `source = "path"` for a file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DataSource {
    Inline {
        values: Vec<Record>,
    },
    File {
        source: PathBuf,
        #[serde(default)]
        format: Option<DataFormat>,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DataFormat {
    Csv,
    Json,
}

impl DataSource {
    pub fn load(&self) -> Result<Dataset> {
        match self {
            DataSource::Inline { values } => Ok(Dataset::new(values.clone())),
            DataSource::File { source, format } => {
                let format = format
                    .or_else(|| guess_format_from_ext(source))
                    .ok_or_else(|| {
                        OpsisError::Config(format!(
                            "could not infer format from path {:?}; set `format = \"csv\"` or `\"json\"`",
                            source
                        ))
                    })?;
                match format {
                    DataFormat::Csv => Dataset::from_csv_path(source),
                    DataFormat::Json => {
                        let s = std::fs::read_to_string(source)?;
                        Dataset::from_json_str(&s)
                    }
                }
            }
        }
    }
}

fn guess_format_from_ext(path: &Path) -> Option<DataFormat> {
    match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
        "csv" | "tsv" => Some(DataFormat::Csv),
        "json" => Some(DataFormat::Json),
        _ => None,
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Encoding {
    #[serde(default)]
    pub x: Option<Channel>,
    #[serde(default)]
    pub y: Option<Channel>,
    #[serde(default)]
    pub color: Option<Channel>,
    #[serde(default)]
    pub size: Option<Channel>,
    /// For pie charts and histograms — the field whose values are aggregated.
    #[serde(default)]
    pub value: Option<Channel>,
    /// For categorical groupings (pie slice labels, bar groups).
    #[serde(default)]
    pub category: Option<Channel>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
    pub field: String,
    #[serde(default)]
    pub r#type: ChannelType,
    #[serde(default)]
    pub title: Option<String>,
    /// Optional aggregation when this channel collapses many records.
    #[serde(default)]
    pub aggregate: Option<Aggregate>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelType {
    #[default]
    Quantitative,
    Categorical,
    Temporal,
    Ordinal,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Aggregate {
    Sum,
    Mean,
    Count,
    Min,
    Max,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Style {
    #[serde(default)]
    pub color: Option<String>,
    #[serde(default)]
    pub palette: Option<Vec<String>>,
    #[serde(default)]
    pub background: Option<String>,
    /// Histograms only.
    #[serde(default)]
    pub bins: Option<usize>,
    /// Line/area: should we connect points?
    #[serde(default)]
    pub smooth: Option<bool>,
    /// Show a legend?
    #[serde(default)]
    pub legend: Option<bool>,
    /// Show grid lines?
    #[serde(default)]
    pub grid: Option<bool>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mark {
    Point,
    Line,
    Bar,
    Area,
    Tick,
}

impl ChartSpec {
    pub fn from_toml_str(s: &str) -> Result<Self> {
        Ok(toml::from_str(s)?)
    }

    pub fn from_toml_path(path: impl AsRef<Path>) -> Result<Self> {
        let s = std::fs::read_to_string(path)?;
        Self::from_toml_str(&s)
    }

    pub fn to_toml_string(&self) -> Result<String> {
        Ok(toml::to_string_pretty(self)?)
    }

    pub fn load_data(&self) -> Result<Dataset> {
        self.data.load()
    }

    pub fn title(&self) -> &str {
        self.chart.title.as_deref().unwrap_or("opsis chart")
    }

    pub fn validate(&self) -> Result<()> {
        use ChartType::*;
        let needs_xy = matches!(self.chart.r#type, Bar | Line | Scatter | Area | Heatmap);
        if needs_xy && (self.encoding.x.is_none() || self.encoding.y.is_none()) {
            return Err(OpsisError::Config(format!(
                "chart type {:?} requires `[encoding.x]` and `[encoding.y]`",
                self.chart.r#type
            )));
        }
        if matches!(self.chart.r#type, Histogram) && self.encoding.value.is_none()
            && self.encoding.x.is_none()
        {
            return Err(OpsisError::Config(
                "histogram requires `[encoding.value]` (or `[encoding.x]`)".into(),
            ));
        }
        if matches!(self.chart.r#type, Pie) && self.encoding.value.is_none() {
            return Err(OpsisError::Config(
                "pie chart requires `[encoding.value]`".into(),
            ));
        }
        Ok(())
    }
}