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;
#[derive(Debug, Clone)]
pub struct LabeledValue {
pub label: String,
pub value: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct XY {
pub x: f64,
pub y: f64,
}
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);
}
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, };
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())
}
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())
}
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()
}
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)
}
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()),
}
}
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))
}
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))
}
#[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())
}
pub const DEFAULT_PALETTE_HEX: &[&str] = &[
"#4C78A8", "#F58518", "#54A24B", "#E45756", "#72B7B2",
"#EECA3B", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC",
];
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()
}
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))
}
#[allow(dead_code)]
fn _silence_value_unused(_: Value) {}