use egui_plot::{Plot, PlotPoint, uniform_grid_spacer};
use web_time::Instant;
use crate::ui::results::ResultsRequest;
use crate::ui::results::plot::{
HeatmapTiles, color_bar, format_significant, heat_color, labeled_combo, level_formatter, refresh_due,
};
use crate::ui::results::response::{output_combo, pins_row};
use crate::ui::results::store::{HeatColor, HeatGrid, HeatQuery, ResultsStore};
use crate::ui::show_plot;
pub const MAX_HEATMAP_LEVELS: usize = 64;
const MAX_LABELED_CELLS: usize = 100;
impl HeatColor {
fn label(self) -> &'static str {
match self {
Self::Mean => "Mean",
Self::StandardDeviation => "Standard deviation",
Self::CoefficientOfVariation => "Coefficient of variation",
}
}
}
#[derive(Debug, Default)]
pub struct HeatmapView {
x_axis: usize,
y_axis: usize,
output: usize,
color_by: HeatColor,
pins: Vec<Option<usize>>,
cache: Option<HeatmapCache>,
}
#[derive(Debug)]
struct HeatmapCache {
revision: u64,
computed_at: Instant,
query: HeatQuery,
grid: Option<HeatGrid>,
}
pub fn heatmap_ui(
ui: &mut egui::Ui,
store: &ResultsStore,
view: &mut HeatmapView,
request: &mut Option<ResultsRequest>,
) {
let sides: Vec<usize> = (0..store.axes().len())
.filter(|&axis| store.axes()[axis].levels.len() <= MAX_HEATMAP_LEVELS)
.collect();
if sides.len() < 2 {
ui.weak("Vary two parameters over values or ranges to draw a heatmap.");
return;
}
if store.reducer_columns().is_empty() {
ui.weak("Heatmap will appear once the first run finishes.");
return;
}
view.fit(store, &sides);
controls(ui, store, view, &sides);
let query = HeatQuery {
x_axis: view.x_axis,
y_axis: view.y_axis,
output: view.output,
color_by: view.color_by,
pins: view.pins.clone(),
};
let (x_axis, y_axis) = (&store.axes()[view.x_axis], &store.axes()[view.y_axis]);
let Some(grid) = view.grid(ui.ctx(), store, &query) else {
return;
};
let (min, max) = grid.range().unwrap_or((0.0, 1.0));
color_bar(ui, min, max);
let mut tiles = HeatmapTiles::new(
&grid.values,
grid.columns,
PlotPoint::new(-0.5, -0.5),
[1.0, 1.0],
move |value| heat_color(value, min, max),
);
if grid.values.len() <= MAX_LABELED_CELLS {
tiles = tiles.labels(format_significant);
}
let plot = Plot::new("henad_results_heatmap")
.x_axis_label(x_axis.label.as_str())
.y_axis_label(y_axis.label.as_str())
.x_axis_formatter(level_formatter(&x_axis.levels))
.y_axis_formatter(level_formatter(&y_axis.levels))
.x_grid_spacer(uniform_grid_spacer(|_| [1.0, 5.0, 10.0]))
.y_grid_spacer(uniform_grid_spacer(|_| [1.0, 5.0, 10.0]))
.show_crosshair(false);
let response = show_plot(ui, plot, |plot_ui| {
plot_ui.add(tiles);
plot_ui.pointer_coordinate()
});
let Some((column, row)) = response.inner.and_then(|position| cell_at(grid, position)) else {
return;
};
let cell = grid.cell(column, row);
if response.response.clicked() && !grid.configs[cell].is_empty() {
*request = Some(ResultsRequest::FilterConfigs(grid.configs[cell].clone()));
}
response.response.on_hover_text_at_pointer(format!(
"{}, {}: {} (n = {})",
x_axis.levels[column],
y_axis.levels[row],
format_significant(grid.values[cell]),
grid.counts[cell]
));
}
fn cell_at(grid: &HeatGrid, position: PlotPoint) -> Option<(usize, usize)> {
let (column, row) = (position.x.round(), position.y.round());
if column < 0.0 || row < 0.0 {
return None;
}
let (column, row) = (column as usize, row as usize);
(column < grid.columns && row < grid.rows).then_some((column, row))
}
fn controls(ui: &mut egui::Ui, store: &ResultsStore, view: &mut HeatmapView, sides: &[usize]) {
let axes = store.axes();
ui.horizontal_wrapped(|ui| {
let x_text = axes[view.x_axis].label.as_str();
labeled_combo(ui, "X axis", "henad_results_heatmap_x", x_text, |ui| {
for &axis in sides {
ui.selectable_value(&mut view.x_axis, axis, axes[axis].label.as_str());
}
});
let y_text = axes[view.y_axis].label.as_str();
labeled_combo(ui, "Y axis", "henad_results_heatmap_y", y_text, |ui| {
for &axis in sides.iter().filter(|&&axis| axis != view.x_axis) {
ui.selectable_value(&mut view.y_axis, axis, axes[axis].label.as_str());
}
});
output_combo(ui, "henad_results_heatmap_output", store, &mut view.output);
let color_text = view.color_by.label();
labeled_combo(ui, "Color by", "henad_results_heatmap_color", color_text, |ui| {
for color_by in [
HeatColor::Mean,
HeatColor::StandardDeviation,
HeatColor::CoefficientOfVariation,
] {
ui.selectable_value(&mut view.color_by, color_by, color_by.label());
}
});
});
let free = [Some(view.x_axis), Some(view.y_axis)];
pins_row(ui, "henad_results_heatmap_pin", axes, &mut view.pins, &free);
}
impl HeatmapView {
fn fit(&mut self, store: &ResultsStore, sides: &[usize]) {
if !sides.contains(&self.x_axis) {
self.x_axis = sides[0];
}
if !sides.contains(&self.y_axis) || self.y_axis == self.x_axis {
self.y_axis = sides
.iter()
.copied()
.find(|&axis| axis != self.x_axis)
.unwrap_or(sides[1]);
}
self.output = self.output.min(store.reducer_columns().len() - 1);
self.pins.resize(store.axes().len(), None);
for (pin, axis) in self.pins.iter_mut().zip(store.axes()) {
*pin = pin.filter(|&level| level < axis.levels.len());
}
}
fn grid(&mut self, ctx: &egui::Context, store: &ResultsStore, query: &HeatQuery) -> Option<&HeatGrid> {
let stale = self.cache.as_ref().is_some_and(|cache| {
if cache.query != *query || cache.revision == store.revision() {
return cache.query != *query;
}
refresh_due(ctx, cache.computed_at)
});
if stale {
self.cache = None;
}
let cache = self.cache.get_or_insert_with(|| HeatmapCache {
revision: store.revision(),
computed_at: Instant::now(),
query: query.clone(),
grid: store.heat_grid(query),
});
cache.grid.as_ref()
}
}