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)
}
#[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}"));
}
});
}
}
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;
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<()> {
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);
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) {
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()));
}
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;
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)));
}
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),
);
}
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(())
}