use std::marker::PhantomData;
use ratatui::widgets::{Block, Borders};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
mod color;
pub mod distribution;
mod render;
pub use color::{HeatmapColorScale, value_to_color};
pub use distribution::DistributionMap;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum HeatmapMessage {
SetData(Vec<Vec<f64>>),
SetCell {
row: usize,
col: usize,
value: f64,
},
SetRowLabels(Vec<String>),
SetColLabels(Vec<String>),
SetColorScale(HeatmapColorScale),
SetRange(Option<f64>, Option<f64>),
SelectUp,
SelectDown,
SelectLeft,
SelectRight,
Clear,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum HeatmapOutput {
CellSelected {
row: usize,
col: usize,
value: f64,
},
SelectionChanged {
row: usize,
col: usize,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct HeatmapState {
data: Vec<Vec<f64>>,
row_labels: Vec<String>,
col_labels: Vec<String>,
color_scale: HeatmapColorScale,
min_value: Option<f64>,
max_value: Option<f64>,
selected_row: Option<usize>,
selected_col: Option<usize>,
show_values: bool,
title: Option<String>,
}
impl HeatmapState {
pub fn new(rows: usize, cols: usize) -> Self {
let data = vec![vec![0.0; cols]; rows];
let (selected_row, selected_col) = if rows > 0 && cols > 0 {
(Some(0), Some(0))
} else {
(None, None)
};
Self {
data,
selected_row,
selected_col,
..Default::default()
}
}
pub fn with_data(data: Vec<Vec<f64>>) -> Self {
let has_cells = !data.is_empty() && data.iter().any(|row| !row.is_empty());
let (selected_row, selected_col) = if has_cells {
(Some(0), Some(0))
} else {
(None, None)
};
Self {
data,
selected_row,
selected_col,
..Default::default()
}
}
pub fn with_row_labels(mut self, labels: Vec<String>) -> Self {
self.row_labels = labels;
self
}
pub fn with_col_labels(mut self, labels: Vec<String>) -> Self {
self.col_labels = labels;
self
}
pub fn with_color_scale(mut self, scale: HeatmapColorScale) -> Self {
self.color_scale = scale;
self
}
pub fn with_range(mut self, min: f64, max: f64) -> Self {
self.min_value = Some(min);
self.max_value = Some(max);
self
}
pub fn with_show_values(mut self, show: bool) -> Self {
self.show_values = show;
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn data(&self) -> &[Vec<f64>] {
&self.data
}
pub fn data_mut(&mut self) -> &mut Vec<Vec<f64>> {
&mut self.data
}
pub fn rows(&self) -> usize {
self.data.len()
}
pub fn cols(&self) -> usize {
self.data.first().map_or(0, |row| row.len())
}
pub fn get(&self, row: usize, col: usize) -> Option<f64> {
self.data.get(row).and_then(|r| r.get(col)).copied()
}
pub fn set(&mut self, row: usize, col: usize, value: f64) {
if let Some(r) = self.data.get_mut(row) {
if let Some(cell) = r.get_mut(col) {
*cell = value;
}
}
}
pub fn selected(&self) -> Option<(usize, usize)> {
match (self.selected_row, self.selected_col) {
(Some(r), Some(c)) => Some((r, c)),
_ => None,
}
}
pub fn selected_value(&self) -> Option<f64> {
let (r, c) = self.selected()?;
self.get(r, c)
}
pub fn effective_min(&self) -> f64 {
self.min_value.unwrap_or_else(|| {
self.data
.iter()
.flat_map(|row| row.iter())
.copied()
.reduce(f64::min)
.unwrap_or(0.0)
})
}
pub fn effective_max(&self) -> f64 {
self.max_value.unwrap_or_else(|| {
self.data
.iter()
.flat_map(|row| row.iter())
.copied()
.reduce(f64::max)
.unwrap_or(0.0)
})
}
pub fn row_labels(&self) -> &[String] {
&self.row_labels
}
pub fn col_labels(&self) -> &[String] {
&self.col_labels
}
pub fn color_scale(&self) -> &HeatmapColorScale {
&self.color_scale
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn show_values(&self) -> bool {
self.show_values
}
pub fn set_show_values(&mut self, show: bool) {
self.show_values = show;
}
pub fn update(&mut self, msg: HeatmapMessage) -> Option<HeatmapOutput> {
Heatmap::update(self, msg)
}
fn row_cols(&self, row: usize) -> usize {
self.data.get(row).map_or(0, |r| r.len())
}
}
pub struct Heatmap(PhantomData<()>);
impl Component for Heatmap {
type State = HeatmapState;
type Message = HeatmapMessage;
type Output = HeatmapOutput;
fn init() -> Self::State {
HeatmapState::default()
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
match key.code {
Key::Up | Key::Char('k') => Some(HeatmapMessage::SelectUp),
Key::Down | Key::Char('j') => Some(HeatmapMessage::SelectDown),
Key::Left | Key::Char('h') => Some(HeatmapMessage::SelectLeft),
Key::Right | Key::Char('l') => Some(HeatmapMessage::SelectRight),
Key::Enter => {
if let Some((row, col)) = state.selected() {
if let Some(value) = state.get(row, col) {
return Some(HeatmapMessage::SetCell { row, col, value });
}
}
None
}
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
HeatmapMessage::SetData(data) => {
let has_cells = !data.is_empty() && data.iter().any(|row| !row.is_empty());
state.data = data;
if has_cells {
let max_row = state.data.len().saturating_sub(1);
let max_col = state.cols().saturating_sub(1);
state.selected_row = Some(state.selected_row.unwrap_or(0).min(max_row));
state.selected_col = Some(state.selected_col.unwrap_or(0).min(max_col));
} else {
state.selected_row = None;
state.selected_col = None;
}
None
}
HeatmapMessage::SetCell { row, col, value } => {
if state.selected() == Some((row, col)) {
return Some(HeatmapOutput::CellSelected { row, col, value });
}
state.set(row, col, value);
None
}
HeatmapMessage::SetRowLabels(labels) => {
state.row_labels = labels;
None
}
HeatmapMessage::SetColLabels(labels) => {
state.col_labels = labels;
None
}
HeatmapMessage::SetColorScale(scale) => {
state.color_scale = scale;
None
}
HeatmapMessage::SetRange(min, max) => {
state.min_value = min;
state.max_value = max;
None
}
HeatmapMessage::SelectUp => navigate_selection(state, Direction::Up),
HeatmapMessage::SelectDown => navigate_selection(state, Direction::Down),
HeatmapMessage::SelectLeft => navigate_selection(state, Direction::Left),
HeatmapMessage::SelectRight => navigate_selection(state, Direction::Right),
HeatmapMessage::Clear => {
for row in &mut state.data {
for cell in row.iter_mut() {
*cell = 0.0;
}
}
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height < 3 || ctx.area.width < 3 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::container("heatmap")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(ref title) = state.title {
block = block.title(title.as_str());
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if inner.height == 0 || inner.width == 0 || state.data.is_empty() {
return;
}
render::render_heatmap(
state,
ctx.frame,
inner,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
enum Direction {
Up,
Down,
Left,
Right,
}
fn navigate_selection(state: &mut HeatmapState, direction: Direction) -> Option<HeatmapOutput> {
if state.data.is_empty() {
return None;
}
let num_rows = state.data.len();
let current_row = state.selected_row.unwrap_or(0);
let current_col = state.selected_col.unwrap_or(0);
let num_cols = state.row_cols(current_row);
if num_cols == 0 {
return None;
}
let (new_row, new_col) = match direction {
Direction::Up => {
if current_row > 0 {
let nr = current_row - 1;
let nc = current_col.min(state.row_cols(nr).saturating_sub(1));
(nr, nc)
} else {
return None;
}
}
Direction::Down => {
if current_row + 1 < num_rows {
let nr = current_row + 1;
let nc = current_col.min(state.row_cols(nr).saturating_sub(1));
(nr, nc)
} else {
return None;
}
}
Direction::Left => {
if current_col > 0 {
(current_row, current_col - 1)
} else {
return None;
}
}
Direction::Right => {
if current_col + 1 < num_cols {
(current_row, current_col + 1)
} else {
return None;
}
}
};
state.selected_row = Some(new_row);
state.selected_col = Some(new_col);
Some(HeatmapOutput::SelectionChanged {
row: new_row,
col: new_col,
})
}
#[cfg(test)]
mod color_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;