opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation
//! Backend-agnostic helpers used by the egui and ratatui renderers.

use std::collections::BTreeMap;

use crate::config::{Aggregate, ChartSpec};
use crate::data::{Dataset, Value};
use crate::error::{OpsisError, Result};

#[cfg(feature = "egui-backend")]
pub mod egui_backend;
#[cfg(feature = "ratatui-backend")]
pub mod ratatui_backend;

/// (label, value) pair — produced by aggregate_categorical, consumed by bar/pie.
#[derive(Debug, Clone)]
pub struct LabeledValue {
    pub label: String,
    pub value: f64,
}

/// (x, y) for line / scatter / area.
#[derive(Debug, Clone, Copy)]
pub struct XY {
    pub x: f64,
    pub y: f64,
}

/// Group records by a categorical field and aggregate a value field.
pub fn aggregate_categorical(
    data: &Dataset,
    category_field: &str,
    value_field: Option<&str>,
    aggregate: Aggregate,
) -> Result<Vec<LabeledValue>> {
    if data.is_empty() {
        return Err(OpsisError::EmptyDataset);
    }
    // Preserve insertion order via a Vec of keys + a map.
    let mut order: Vec<String> = Vec::new();
    let mut buckets: BTreeMap<String, Vec<f64>> = BTreeMap::new();

    for rec in &data.records {
        let label = rec
            .get(category_field)
            .and_then(|v| v.as_str())
            .unwrap_or_default();
        let v: f64 = match value_field {
            Some(f) => rec.get(f).and_then(|v| v.as_f64()).unwrap_or(0.0),
            None => 1.0, // count mode
        };
        if !buckets.contains_key(&label) {
            order.push(label.clone());
        }
        buckets.entry(label).or_default().push(v);
    }

    let agg = |xs: &[f64]| -> f64 {
        match aggregate {
            Aggregate::Sum => xs.iter().sum(),
            Aggregate::Mean if !xs.is_empty() => xs.iter().sum::<f64>() / xs.len() as f64,
            Aggregate::Mean => 0.0,
            Aggregate::Count => xs.len() as f64,
            Aggregate::Min => xs.iter().cloned().fold(f64::INFINITY, f64::min),
            Aggregate::Max => xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
        }
    };

    Ok(order
        .into_iter()
        .map(|label| {
            let value = agg(&buckets[&label]);
            LabeledValue { label, value }
        })
        .collect())
}

/// Build (x, y) pairs from two channels. Numeric x is used directly;
/// categorical x is converted to its ordinal position.
pub fn extract_xy(spec: &ChartSpec, data: &Dataset) -> Result<Vec<XY>> {
    let x_ch = spec.encoding.x.as_ref().ok_or_else(|| {
        OpsisError::Config("missing `[encoding.x]`".into())
    })?;
    let y_ch = spec.encoding.y.as_ref().ok_or_else(|| {
        OpsisError::Config("missing `[encoding.y]`".into())
    })?;

    let ys = data.column_f64(&y_ch.field)?;

    let xs: Vec<f64> = match data.column_f64(&x_ch.field) {
        Ok(v) => v,
        Err(_) => (0..ys.len()).map(|i| i as f64).collect(),
    };

    Ok(xs.into_iter().zip(ys).map(|(x, y)| XY { x, y }).collect())
}

/// Bin a numeric column into a histogram. `bins = None` uses Sturges' rule.
pub fn histogram(values: &[f64], bins: Option<usize>) -> Vec<LabeledValue> {
    if values.is_empty() {
        return Vec::new();
    }
    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
    let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    if (max - min).abs() < f64::EPSILON {
        return vec![LabeledValue {
            label: format!("{:.3}", min),
            value: values.len() as f64,
        }];
    }
    let n = bins
        .unwrap_or_else(|| ((values.len() as f64).log2().ceil() as usize + 1).max(1));
    let width = (max - min) / n as f64;
    let mut counts = vec![0usize; n];
    for &v in values {
        let mut idx = ((v - min) / width) as usize;
        if idx >= n {
            idx = n - 1;
        }
        counts[idx] += 1;
    }
    counts
        .into_iter()
        .enumerate()
        .map(|(i, c)| {
            let lo = min + i as f64 * width;
            LabeledValue {
                label: format!("{:.2}", lo),
                value: c as f64,
            }
        })
        .collect()
}

/// Default categorical aggregation for bar charts: take the y field as the
/// value, sum across rows that share the same x category. If x is numeric
/// we don't aggregate — caller should treat each record as a bar.
pub fn bar_data(spec: &ChartSpec, data: &Dataset) -> Result<Vec<LabeledValue>> {
    let x_ch = spec.encoding.x.as_ref().ok_or_else(|| {
        OpsisError::Config("bar chart requires `[encoding.x]`".into())
    })?;
    let y_ch = spec.encoding.y.as_ref().ok_or_else(|| {
        OpsisError::Config("bar chart requires `[encoding.y]`".into())
    })?;
    let agg = y_ch.aggregate.unwrap_or(Aggregate::Sum);
    aggregate_categorical(data, &x_ch.field, Some(&y_ch.field), agg)
}

/// Pie data: aggregate value by category.
pub fn pie_data(spec: &ChartSpec, data: &Dataset) -> Result<Vec<LabeledValue>> {
    let value_ch = spec.encoding.value.as_ref().ok_or_else(|| {
        OpsisError::Config("pie chart requires `[encoding.value]`".into())
    })?;
    let cat = spec.encoding.category.as_ref().or(spec.encoding.x.as_ref());
    match cat {
        Some(c) => aggregate_categorical(
            data,
            &c.field,
            Some(&value_ch.field),
            value_ch.aggregate.unwrap_or(Aggregate::Sum),
        ),
        None => Ok(data
            .column_f64(&value_ch.field)?
            .into_iter()
            .enumerate()
            .map(|(i, v)| LabeledValue {
                label: format!("{}", i),
                value: v,
            })
            .collect()),
    }
}

/// Histogram values come from `value` channel or fall back to `x`.
pub fn histogram_data(spec: &ChartSpec, data: &Dataset) -> Result<Vec<LabeledValue>> {
    let field = spec
        .encoding
        .value
        .as_ref()
        .or(spec.encoding.x.as_ref())
        .ok_or_else(|| {
            OpsisError::Config("histogram requires `[encoding.value]` or `[encoding.x]`".into())
        })?
        .field
        .clone();
    let xs = data.column_f64(&field)?;
    Ok(histogram(&xs, spec.style.bins))
}

/// Heatmap cells: (x_index, y_index, value, x_label, y_label).
pub struct HeatCell {
    pub x_idx: usize,
    pub y_idx: usize,
    pub value: f64,
    pub x_label: String,
    pub y_label: String,
}

pub fn heatmap_cells(spec: &ChartSpec, data: &Dataset) -> Result<(Vec<String>, Vec<String>, Vec<HeatCell>)> {
    let x = spec.encoding.x.as_ref().ok_or_else(|| {
        OpsisError::Config("heatmap requires `[encoding.x]`".into())
    })?;
    let y = spec.encoding.y.as_ref().ok_or_else(|| {
        OpsisError::Config("heatmap requires `[encoding.y]`".into())
    })?;
    let v = spec.encoding.value.as_ref().or(spec.encoding.color.as_ref()).ok_or_else(|| {
        OpsisError::Config("heatmap requires `[encoding.value]` or `[encoding.color]`".into())
    })?;

    let mut x_labels: Vec<String> = Vec::new();
    let mut y_labels: Vec<String> = Vec::new();
    let mut cells = Vec::new();

    for rec in &data.records {
        let xl = rec.get(&x.field).and_then(|v| v.as_str()).unwrap_or_default();
        let yl = rec.get(&y.field).and_then(|v| v.as_str()).unwrap_or_default();
        let val = rec.get(&v.field).and_then(|v| v.as_f64()).unwrap_or(0.0);

        let xi = match x_labels.iter().position(|s| s == &xl) {
            Some(i) => i,
            None => { x_labels.push(xl.clone()); x_labels.len() - 1 }
        };
        let yi = match y_labels.iter().position(|s| s == &yl) {
            Some(i) => i,
            None => { y_labels.push(yl.clone()); y_labels.len() - 1 }
        };

        cells.push(HeatCell { x_idx: xi, y_idx: yi, value: val, x_label: xl, y_label: yl });
    }
    Ok((x_labels, y_labels, cells))
}

/// Box plot summary per category.
#[derive(Debug, Clone)]
pub struct BoxStats {
    pub label: String,
    pub min: f64,
    pub q1: f64,
    pub median: f64,
    pub q3: f64,
    pub max: f64,
}

pub fn boxplot_stats(spec: &ChartSpec, data: &Dataset) -> Result<Vec<BoxStats>> {
    let cat = spec.encoding.x.as_ref().or(spec.encoding.category.as_ref()).ok_or_else(|| {
        OpsisError::Config("boxplot requires a categorical channel (`[encoding.x]`)".into())
    })?;
    let val = spec.encoding.y.as_ref().or(spec.encoding.value.as_ref()).ok_or_else(|| {
        OpsisError::Config("boxplot requires `[encoding.y]` or `[encoding.value]`".into())
    })?;

    let mut order: Vec<String> = Vec::new();
    let mut buckets: BTreeMap<String, Vec<f64>> = BTreeMap::new();
    for rec in &data.records {
        let label = rec.get(&cat.field).and_then(|v| v.as_str()).unwrap_or_default();
        let v = rec.get(&val.field).and_then(|v| v.as_f64()).unwrap_or(0.0);
        if !buckets.contains_key(&label) { order.push(label.clone()); }
        buckets.entry(label).or_default().push(v);
    }

    let quantile = |sorted: &[f64], q: f64| -> f64 {
        if sorted.is_empty() { return 0.0; }
        let pos = q * (sorted.len() as f64 - 1.0);
        let lo = pos.floor() as usize;
        let hi = pos.ceil() as usize;
        if lo == hi { sorted[lo] } else {
            let frac = pos - lo as f64;
            sorted[lo] * (1.0 - frac) + sorted[hi] * frac
        }
    };

    Ok(order.into_iter().map(|label| {
        let mut xs = buckets.remove(&label).unwrap_or_default();
        xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
        BoxStats {
            label,
            min: *xs.first().unwrap_or(&0.0),
            q1: quantile(&xs, 0.25),
            median: quantile(&xs, 0.5),
            q3: quantile(&xs, 0.75),
            max: *xs.last().unwrap_or(&0.0),
        }
    }).collect())
}

/// Built-in 10-color palette (Tableau-ish), used when style.palette is unset.
pub const DEFAULT_PALETTE_HEX: &[&str] = &[
    "#4C78A8", "#F58518", "#54A24B", "#E45756", "#72B7B2",
    "#EECA3B", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC",
];

/// Parse "#RRGGBB" or "#RGB" into (r, g, b) bytes. Defaults to mid-gray on bad input.
pub fn hex_rgb(s: &str) -> (u8, u8, u8) {
    let s = s.trim().trim_start_matches('#');
    let parse = |a: &str| u8::from_str_radix(a, 16).unwrap_or(128);
    match s.len() {
        6 => (parse(&s[0..2]), parse(&s[2..4]), parse(&s[4..6])),
        3 => (
            parse(&s[0..1].repeat(2)),
            parse(&s[1..2].repeat(2)),
            parse(&s[2..3].repeat(2)),
        ),
        _ => (128, 128, 128),
    }
}

pub fn palette_for(spec: &ChartSpec) -> Vec<(u8, u8, u8)> {
    let custom: Option<Vec<&str>> = spec.style.palette.as_ref().map(|p| p.iter().map(|s| s.as_str()).collect());
    let src: Vec<&str> = custom.unwrap_or_else(|| DEFAULT_PALETTE_HEX.to_vec());
    src.into_iter().map(hex_rgb).collect()
}

/// Helper: get the base color from style.color, or first palette entry.
pub fn primary_color(spec: &ChartSpec) -> (u8, u8, u8) {
    if let Some(c) = &spec.style.color {
        return hex_rgb(c);
    }
    palette_for(spec).into_iter().next().unwrap_or((76, 120, 168))
}

// Suppress unused-import warnings if a backend is disabled.
#[allow(dead_code)]
fn _silence_value_unused(_: Value) {}