use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::data::{Dataset, Record};
use crate::error::{OpsisError, Result};
#[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 }
}
#[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>,
#[serde(default)]
pub value: Option<Channel>,
#[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>,
#[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>,
#[serde(default)]
pub bins: Option<usize>,
#[serde(default)]
pub smooth: Option<bool>,
#[serde(default)]
pub legend: Option<bool>,
#[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(())
}
}