charton 0.5.0

A high-performance, layered charting system for Rust, featuring a flexible data core and multi-backend rendering.
Documentation
use crate::TEMP_SUFFIX;
use crate::chart::Chart;
use crate::core::data::{ColumnVector, Dataset};
use crate::encode::y::StackMode;
use crate::error::ChartonError;
use crate::mark::Mark;
use ahash::AHashMap;

impl<T: Mark> Chart<T> {
    pub(crate) fn transform_bar_data(mut self) -> Result<Self, ChartonError> {
        // --- STEP 1: Context Extraction ---
        let y_enc = self.encoding.y.as_mut().unwrap();
        let agg_op = y_enc.aggregate;
        let x_enc = self.encoding.x.as_ref().unwrap();
        let color_enc_opt = self.encoding.color.as_ref();

        let mut x_field = x_enc.field.clone();
        let y_field = y_enc.field.clone();

        // Check if we are in Pie mode (empty X field).
        // If so, we force Stacked mode to create a circular stack.
        let is_pie = x_field.is_empty();
        if is_pie {
            y_enc.stack = StackMode::Stacked;
            x_field = format!("{}_virtual_root__", TEMP_SUFFIX);
        }

        let color_field = color_enc_opt.map(|ce| &ce.field);

        // A color field triggers grouping ONLY if it's different from the X axis field.
        let has_grouping_color = if let Some(cf) = color_field {
            cf != &x_field
        } else {
            false
        };

        // --- STEP 2: Aggregate Data into a Lookup Map ---
        // We group raw rows by (X-Category, Color-Category) to prepare for aggregation.
        let mut group_map: AHashMap<(String, Option<String>), Vec<usize>> = AHashMap::new();
        let row_count = self.data.height();

        for i in 0..row_count {
            let x_val = if is_pie {
                "all".to_string()
            } else {
                self.data.get_str_or(&x_field, i, "null")
            };
            let c_val = if has_grouping_color {
                color_field.map(|cf| self.data.get_str_or(cf, i, "null"))
            } else {
                None
            };
            group_map.entry((x_val, c_val)).or_default().push(i);
        }

        let y_col = self.data.column(&y_field)?;
        let mut lookup: AHashMap<(String, Option<String>), f64> = group_map
            .into_iter()
            .map(|(key, indices)| (key, agg_op.aggregate_by_index(y_col, &indices)))
            .collect();

        // --- STEP 3: Normalization (100% Stacked / Percentage mode) ---
        // If normalization is requested, we divide each value by the sum of its X-group.
        // This is essential for "Normalized Rose Charts" where all petals have the same radius.
        if y_enc.normalize || y_enc.stack == StackMode::Normalize {
            let mut x_sums: AHashMap<String, f64> = AHashMap::new();
            for ((x, _), val) in &lookup {
                *x_sums.entry(x.clone()).or_insert(0.0) += val;
            }
            for ((x, _), val) in lookup.iter_mut() {
                let sum = x_sums.get(x).cloned().unwrap_or(0.0);
                *val = if sum != 0.0 { *val / sum } else { 0.0 };
            }
        }

        // --- STEP 4: Cartesian Product & Gap Filling ---
        // We ensure every X-category has the same set of Color-categories (filling missing gaps with 0.0).
        // This prevents "shifting" in stacked charts (including Nightingale Rose) when data is sparse.
        let x_uniques = if is_pie {
            vec!["all".to_string()]
        } else {
            self.data.column(&x_field)?.unique_values()
        };

        let c_uniques = if has_grouping_color {
            self.data.column(color_field.unwrap())?.unique_values()
        } else {
            vec![]
        };

        let mut final_x = Vec::new();
        let mut final_y = Vec::new();
        let mut final_color = Vec::new();

        for x in &x_uniques {
            if has_grouping_color {
                for c in &c_uniques {
                    // Using .get() instead of .remove() to keep the lookup intact if needed for debug.
                    // Missing combinations are filled with 0.0 to maintain stack alignment.
                    let val = lookup
                        .get(&(x.clone(), Some(c.clone())))
                        .cloned()
                        .unwrap_or(0.0);
                    final_x.push(x.clone());
                    final_color.push(c.clone());
                    final_y.push(val);
                }
            } else {
                let val = lookup.get(&(x.clone(), None)).cloned().unwrap_or(0.0);
                final_x.push(x.clone());
                final_y.push(val);
            }
        }

        // --- STEP 5: Rebuild Dataset ---
        let mut new_ds = Dataset::new();

        // 1. Resolve column names and metadata
        let x_col_name = if is_pie { "" } else { &x_field };
        let total_c = if has_grouping_color {
            c_uniques.len()
        } else {
            1
        };
        let total_rows = final_x.len();

        // 2. Generate layout helper columns (consistent with Boxplot/Errorbar)
        // These columns allow the renderer to calculate 'dodge' offsets without re-grouping.
        let mut f_groups_count = Vec::with_capacity(total_rows);
        let mut f_sub_idx = Vec::with_capacity(total_rows);

        // Since final_x/final_color were built using nested loops in STEP 4,
        // we replicate that structure here to align helper values.
        for _ in &x_uniques {
            for j in 0..total_c {
                f_groups_count.push(total_c as f64);
                f_sub_idx.push(j as f64);
            }
        }

        // 3. Assemble the New Dataset
        // Primary Axis (X)
        new_ds.add_column(
            x_col_name,
            ColumnVector::String {
                data: final_x,
                validity: None,
            },
        )?;

        // Measures (Y)
        new_ds.add_column(&y_field, ColumnVector::F64 { data: final_y })?;

        // Aesthetic Grouping (Color)
        if has_grouping_color {
            new_ds.add_column(
                color_field.unwrap(),
                ColumnVector::String {
                    data: final_color,
                    validity: None,
                },
            )?;
        }

        // Layout Helpers (The "Secret Sauce" for unified rendering)
        new_ds.add_column(
            format!("{}_groups_count", TEMP_SUFFIX),
            ColumnVector::F64 {
                data: f_groups_count,
            },
        )?;

        new_ds.add_column(
            format!("{}_sub_idx", TEMP_SUFFIX),
            ColumnVector::F64 { data: f_sub_idx },
        )?;

        // --- STEP 6: Finalization ---
        self.data = new_ds;
        Ok(self)
    }
}