opsis 0.1.0

Config-driven framework for blazingly fast visualizations.
Documentation
//! egui rendering of an opsis [`ChartSpec`].
//!
//! Two entry points:
//! * [`show_window`] — opens a native window via eframe.
//! * [`draw`] — draws into an existing `egui::Ui`, for embedding.

use eframe::egui::{self, Color32, Pos2, Rect, Stroke, Vec2};
use egui_plot::{
    Bar, BarChart, BoxElem, BoxPlot, BoxSpread, Line, Plot, PlotPoints, Points,
};

use super::{
    bar_data, boxplot_stats, extract_xy, heatmap_cells, histogram_data, palette_for, pie_data,
    primary_color, BoxStats, LabeledValue, XY,
};
use crate::config::{ChartSpec, ChartType};
use crate::data::Dataset;
use crate::error::Result;

fn rgb(c: (u8, u8, u8)) -> Color32 {
    Color32::from_rgb(c.0, c.1, c.2)
}

/// Open a window and run the egui event loop until the user closes it.
#[cfg(feature = "egui-backend")]
pub fn show_window(spec: ChartSpec) -> Result<()> {
    let data = spec.load_data()?;
    spec.validate()?;

    let title = spec.title().to_string();
    let viewport = egui::ViewportBuilder::default().with_inner_size([
        spec.chart.width.unwrap_or(960.0),
        spec.chart.height.unwrap_or(640.0),
    ]);
    let opts = eframe::NativeOptions { viewport, ..Default::default() };

    let app = OpsisApp { spec, data };
    eframe::run_native(
        &title,
        opts,
        Box::new(|_cc| Box::new(app)),
    )
    .map_err(|e| crate::error::OpsisError::Backend(format!("eframe: {e}")))
}

struct OpsisApp {
    spec: ChartSpec,
    data: Dataset,
}

impl eframe::App for OpsisApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            if let Some(t) = &self.spec.chart.title {
                ui.heading(t);
            }
            if let Err(e) = draw(ui, &self.spec, &self.data) {
                ui.colored_label(Color32::RED, format!("error: {e}"));
            }
        });
    }
}

/// Draw the chart into an existing UI. Useful for embedding inside a host app.
pub fn draw(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    match spec.chart.r#type {
        ChartType::Bar => draw_bar(ui, spec, data),
        ChartType::Line => draw_line(ui, spec, data, false),
        ChartType::Area => draw_line(ui, spec, data, true),
        ChartType::Scatter => draw_scatter(ui, spec, data),
        ChartType::Histogram => draw_histogram(ui, spec, data),
        ChartType::Pie => draw_pie(ui, spec, data),
        ChartType::Heatmap => draw_heatmap(ui, spec, data),
        ChartType::BoxPlot => draw_boxplot(ui, spec, data),
    }
}

fn plot_id(spec: &ChartSpec) -> String {
    format!("opsis_{}", spec.title())
}

fn axis_titles(spec: &ChartSpec) -> (String, String) {
    let x = spec
        .encoding
        .x
        .as_ref()
        .and_then(|c| c.title.clone().or(Some(c.field.clone())))
        .unwrap_or_default();
    let y = spec
        .encoding
        .y
        .as_ref()
        .and_then(|c| c.title.clone().or(Some(c.field.clone())))
        .unwrap_or_default();
    (x, y)
}

fn draw_bar(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let bars = bar_data(spec, data)?;
    let color = rgb(primary_color(spec));
    let palette = palette_for(spec);
    let labels: Vec<String> = bars.iter().map(|b| b.label.clone()).collect();

    let bars_widget: Vec<Bar> = bars
        .iter()
        .enumerate()
        .map(|(i, b)| {
            let c = palette.get(i % palette.len()).copied().unwrap_or(primary_color(spec));
            Bar::new(i as f64, b.value)
                .name(&b.label)
                .fill(rgb(c))
        })
        .collect();
    let _ = color; // single-color palette fallback handled above

    let (x_title, y_title) = axis_titles(spec);
    Plot::new(plot_id(spec))
        .x_axis_label(x_title)
        .y_axis_label(y_title)
        .show_grid(spec.style.grid.unwrap_or(true))
        .legend(legend(spec))
        .x_axis_formatter(move |gm, _max_chars, _range| {
            let i = gm.value.round() as isize;
            if i >= 0 && (i as usize) < labels.len() {
                labels[i as usize].clone()
            } else {
                String::new()
            }
        })
        .show(ui, |plot_ui| {
            plot_ui.bar_chart(BarChart::new(bars_widget));
        });
    Ok(())
}

fn legend(spec: &ChartSpec) -> egui_plot::Legend {
    let l = egui_plot::Legend::default();
    if !spec.style.legend.unwrap_or(true) {
        l.background_alpha(0.0)
    } else {
        l
    }
}

fn draw_line(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset, fill: bool) -> Result<()> {
    let pts: Vec<XY> = extract_xy(spec, data)?;
    let plot_pts = PlotPoints::from_iter(pts.iter().map(|p| [p.x, p.y]));
    let color = rgb(primary_color(spec));

    let (x_title, y_title) = axis_titles(spec);
    Plot::new(plot_id(spec))
        .x_axis_label(x_title)
        .y_axis_label(y_title)
        .show_grid(spec.style.grid.unwrap_or(true))
        .legend(legend(spec))
        .show(ui, |plot_ui| {
            let mut line = Line::new(plot_pts).color(color).width(2.0);
            if fill {
                line = line.fill(0.0);
            }
            if let Some(name) = &spec.encoding.y.as_ref().map(|c| c.field.clone()) {
                line = line.name(name);
            }
            plot_ui.line(line);
        });
    Ok(())
}

fn draw_scatter(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let pts: Vec<XY> = extract_xy(spec, data)?;
    let plot_pts = PlotPoints::from_iter(pts.iter().map(|p| [p.x, p.y]));
    let color = rgb(primary_color(spec));

    let (x_title, y_title) = axis_titles(spec);
    Plot::new(plot_id(spec))
        .x_axis_label(x_title)
        .y_axis_label(y_title)
        .show_grid(spec.style.grid.unwrap_or(true))
        .legend(legend(spec))
        .show(ui, |plot_ui| {
            plot_ui.points(
                Points::new(plot_pts)
                    .color(color)
                    .radius(4.0)
                    .name(spec.encoding.y.as_ref().map(|c| c.field.as_str()).unwrap_or("y")),
            );
        });
    Ok(())
}

fn draw_histogram(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let bins: Vec<LabeledValue> = histogram_data(spec, data)?;
    let color = rgb(primary_color(spec));
    let bars: Vec<Bar> = bins
        .iter()
        .enumerate()
        .map(|(i, b)| Bar::new(i as f64, b.value).name(&b.label).fill(color))
        .collect();
    let labels: Vec<String> = bins.iter().map(|b| b.label.clone()).collect();
    let (_, y_title) = axis_titles(spec);

    Plot::new(plot_id(spec))
        .y_axis_label(y_title)
        .show_grid(spec.style.grid.unwrap_or(true))
        .x_axis_formatter(move |gm, _max_chars, _r| {
            let i = gm.value.round() as isize;
            if i >= 0 && (i as usize) < labels.len() {
                labels[i as usize].clone()
            } else {
                String::new()
            }
        })
        .show(ui, |plot_ui| {
            plot_ui.bar_chart(BarChart::new(bars));
        });
    Ok(())
}

fn draw_pie(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    // egui_plot has no pie, so paint manually inside an allocated rect.
    let slices = pie_data(spec, data)?;
    let total: f64 = slices.iter().map(|s| s.value).sum();
    if total <= 0.0 {
        ui.label("(no positive values to plot)");
        return Ok(());
    }
    let palette = palette_for(spec);

    let avail = ui.available_size();
    let side = avail.x.min(avail.y).max(120.0);
    let (rect, _) = ui.allocate_exact_size(Vec2::splat(side), egui::Sense::hover());
    let painter = ui.painter_at(rect);
    let center = rect.center();
    let radius = side * 0.42;

    let mut start = -std::f32::consts::FRAC_PI_2;
    for (i, slice) in slices.iter().enumerate() {
        let frac = (slice.value / total) as f32;
        let end = start + frac * std::f32::consts::TAU;
        let color = rgb(palette.get(i % palette.len()).copied().unwrap_or((76, 120, 168)));
        paint_arc(&painter, center, radius, start, end, color);

        // label at slice midpoint
        if frac > 0.02 {
            let mid = (start + end) * 0.5;
            let lp = Pos2::new(
                center.x + (radius * 0.7) * mid.cos(),
                center.y + (radius * 0.7) * mid.sin(),
            );
            painter.text(
                lp,
                egui::Align2::CENTER_CENTER,
                &slice.label,
                egui::FontId::proportional(12.0),
                Color32::WHITE,
            );
        }
        start = end;
    }

    if spec.style.legend.unwrap_or(true) {
        ui.vertical(|ui| {
            for (i, slice) in slices.iter().enumerate() {
                let c = rgb(palette.get(i % palette.len()).copied().unwrap_or((76, 120, 168)));
                ui.horizontal(|ui| {
                    let (sw, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), egui::Sense::hover());
                    ui.painter().rect_filled(sw, 2.0, c);
                    ui.label(format!("{}: {:.2}", slice.label, slice.value));
                });
            }
        });
    }
    Ok(())
}

fn paint_arc(painter: &egui::Painter, center: Pos2, radius: f32, start: f32, end: f32, color: Color32) {
    // Triangle fan approximation.
    let segments = 64.max(((end - start).abs() / 0.05) as usize);
    let mut points = Vec::with_capacity(segments + 2);
    points.push(center);
    for i in 0..=segments {
        let t = start + (end - start) * (i as f32 / segments as f32);
        points.push(Pos2::new(center.x + radius * t.cos(), center.y + radius * t.sin()));
    }
    // egui has no fill_polygon, so emulate via convex_polygon path
    painter.add(egui::Shape::convex_polygon(
        points,
        color,
        Stroke::new(1.0, Color32::from_rgb(255, 255, 255)),
    ));
}

fn draw_heatmap(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let (xs, ys, cells) = heatmap_cells(spec, data)?;
    if cells.is_empty() {
        ui.label("(no data)");
        return Ok(());
    }
    let min = cells.iter().map(|c| c.value).fold(f64::INFINITY, f64::min);
    let max = cells.iter().map(|c| c.value).fold(f64::NEG_INFINITY, f64::max);
    let span = (max - min).max(f64::EPSILON);

    let avail = ui.available_size();
    let (rect, _) = ui.allocate_exact_size(avail, egui::Sense::hover());
    let painter = ui.painter_at(rect);

    let label_pad_l = 60.0_f32;
    let label_pad_b = 24.0_f32;
    let plot_x = rect.left() + label_pad_l;
    let plot_y = rect.top();
    let plot_w = rect.width() - label_pad_l;
    let plot_h = rect.height() - label_pad_b;
    let cw = plot_w / xs.len() as f32;
    let ch = plot_h / ys.len() as f32;

    for cell in &cells {
        let t = ((cell.value - min) / span).clamp(0.0, 1.0) as f32;
        // simple white -> primary color ramp
        let (pr, pg, pb) = primary_color(spec);
        let c = Color32::from_rgb(
            (255.0 * (1.0 - t) + pr as f32 * t) as u8,
            (255.0 * (1.0 - t) + pg as f32 * t) as u8,
            (255.0 * (1.0 - t) + pb as f32 * t) as u8,
        );
        let r = Rect::from_min_size(
            Pos2::new(plot_x + cw * cell.x_idx as f32, plot_y + ch * cell.y_idx as f32),
            Vec2::new(cw, ch),
        );
        painter.rect_filled(r, 0.0, c);
        painter.rect_stroke(r, 0.0, Stroke::new(0.5, Color32::from_gray(180)));
    }

    // y labels
    for (i, label) in ys.iter().enumerate() {
        painter.text(
            Pos2::new(rect.left() + label_pad_l - 4.0, plot_y + ch * (i as f32 + 0.5)),
            egui::Align2::RIGHT_CENTER,
            label,
            egui::FontId::proportional(11.0),
            Color32::from_gray(40),
        );
    }
    // x labels
    for (i, label) in xs.iter().enumerate() {
        painter.text(
            Pos2::new(plot_x + cw * (i as f32 + 0.5), plot_y + plot_h + 4.0),
            egui::Align2::CENTER_TOP,
            label,
            egui::FontId::proportional(11.0),
            Color32::from_gray(40),
        );
    }
    Ok(())
}

fn draw_boxplot(ui: &mut egui::Ui, spec: &ChartSpec, data: &Dataset) -> Result<()> {
    let stats: Vec<BoxStats> = boxplot_stats(spec, data)?;
    let color = rgb(primary_color(spec));
    let labels: Vec<String> = stats.iter().map(|s| s.label.clone()).collect();
    let elems: Vec<BoxElem> = stats
        .into_iter()
        .enumerate()
        .map(|(i, s)| {
            BoxElem::new(
                i as f64,
                BoxSpread::new(s.min, s.q1, s.median, s.q3, s.max),
            )
            .name(s.label)
            .fill(color)
        })
        .collect();

    let (x_title, y_title) = axis_titles(spec);
    Plot::new(plot_id(spec))
        .x_axis_label(x_title)
        .y_axis_label(y_title)
        .legend(legend(spec))
        .show_grid(spec.style.grid.unwrap_or(true))
        .x_axis_formatter(move |gm, _max_chars, _r| {
            let i = gm.value.round() as isize;
            if i >= 0 && (i as usize) < labels.len() {
                labels[i as usize].clone()
            } else {
                String::new()
            }
        })
        .show(ui, |plot_ui| {
            plot_ui.box_plot(BoxPlot::new(elems));
        });
    Ok(())
}