use std::marker::PhantomData;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
mod annotations;
mod builders;
pub(crate) mod downsample;
mod error_bands;
pub(crate) mod format;
mod grid;
mod render;
pub(crate) mod scale;
mod series;
mod state;
pub(crate) mod ticks;
pub use annotations::ChartAnnotation;
pub use grid::ChartGrid;
pub use scale::Scale;
pub use series::{DEFAULT_PALETTE, chart_palette_color};
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct DataSeries {
label: String,
values: Vec<f64>,
color: Color,
x_values: Option<Vec<f64>>,
upper_bound: Option<Vec<f64>>,
lower_bound: Option<Vec<f64>>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum BarMode {
#[default]
Single,
Grouped,
Stacked,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ChartKind {
Line,
BarVertical,
BarHorizontal,
Area,
Scatter,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ThresholdLine {
pub value: f64,
pub label: String,
pub color: Color,
}
impl ThresholdLine {
pub fn new(value: f64, label: impl Into<String>, color: Color) -> Self {
Self {
value,
label: label.into(),
color,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct VerticalLine {
pub x_value: f64,
pub label: String,
pub color: Color,
}
impl VerticalLine {
pub fn new(x_value: f64, label: impl Into<String>, color: Color) -> Self {
Self {
x_value,
label: label.into(),
color,
}
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ChartMessage {
NextSeries,
PrevSeries,
SetThresholds(Vec<ThresholdLine>),
AddThreshold(ThresholdLine),
SetYRange(Option<f64>, Option<f64>),
SetYScale(Scale),
SetVerticalLines(Vec<VerticalLine>),
AddVerticalLine(VerticalLine),
CursorLeft,
CursorRight,
CursorHome,
CursorEnd,
ToggleCrosshair,
ToggleGrid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ChartOutput {
ActiveSeriesChanged(usize),
CursorMoved(usize),
CrosshairToggled(bool),
GridToggled(bool),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ChartState {
pub(crate) series: Vec<DataSeries>,
pub(crate) kind: ChartKind,
pub(crate) active_series: usize,
pub(crate) title: Option<String>,
pub(crate) x_label: Option<String>,
pub(crate) y_label: Option<String>,
pub(crate) show_legend: bool,
pub(crate) max_display_points: usize,
pub(crate) bar_width: u16,
pub(crate) bar_gap: u16,
pub(crate) thresholds: Vec<ThresholdLine>,
pub(crate) y_min: Option<f64>,
pub(crate) y_max: Option<f64>,
pub(crate) y_scale: Scale,
pub(crate) vertical_lines: Vec<VerticalLine>,
pub(crate) cursor_position: Option<usize>,
pub(crate) show_crosshair: bool,
pub(crate) show_grid: bool,
pub(crate) categories: Vec<String>,
pub(crate) bar_mode: BarMode,
pub(crate) x_labels: Option<Vec<String>>,
pub(crate) annotations: Vec<ChartAnnotation>,
}
impl Default for ChartState {
fn default() -> Self {
Self {
series: Vec::new(),
kind: ChartKind::Line,
active_series: 0,
title: None,
x_label: None,
y_label: None,
show_legend: true,
max_display_points: 500,
bar_width: 3,
bar_gap: 1,
thresholds: Vec::new(),
y_min: None,
y_max: None,
y_scale: Scale::default(),
vertical_lines: Vec::new(),
cursor_position: None,
show_crosshair: false,
show_grid: false,
categories: Vec::new(),
bar_mode: BarMode::default(),
x_labels: None,
annotations: Vec::new(),
}
}
}
pub struct Chart(PhantomData<()>);
impl Component for Chart {
type State = ChartState;
type Message = ChartMessage;
type Output = ChartOutput;
fn init() -> Self::State {
ChartState::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::Tab if key.modifiers.shift() => Some(ChartMessage::PrevSeries),
Key::Tab => Some(ChartMessage::NextSeries),
Key::Left | Key::Char('h') => Some(ChartMessage::CursorLeft),
Key::Right | Key::Char('l') => Some(ChartMessage::CursorRight),
Key::Home => Some(ChartMessage::CursorHome),
Key::End => Some(ChartMessage::CursorEnd),
Key::Char('c') => Some(ChartMessage::ToggleCrosshair),
Key::Char('g') => Some(ChartMessage::ToggleGrid),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
ChartMessage::SetThresholds(thresholds) => {
state.thresholds = thresholds;
None
}
ChartMessage::AddThreshold(threshold) => {
state.thresholds.push(threshold);
None
}
ChartMessage::SetYRange(min, max) => {
state.y_min = min;
state.y_max = max;
None
}
ChartMessage::SetYScale(scale) => {
state.y_scale = scale;
None
}
ChartMessage::SetVerticalLines(lines) => {
state.vertical_lines = lines;
None
}
ChartMessage::AddVerticalLine(line) => {
state.vertical_lines.push(line);
None
}
ChartMessage::ToggleCrosshair => {
state.show_crosshair = !state.show_crosshair;
if state.show_crosshair && state.cursor_position.is_none() {
state.cursor_position = Some(0);
}
Some(ChartOutput::CrosshairToggled(state.show_crosshair))
}
ChartMessage::ToggleGrid => {
state.show_grid = !state.show_grid;
Some(ChartOutput::GridToggled(state.show_grid))
}
ChartMessage::CursorLeft
| ChartMessage::CursorRight
| ChartMessage::CursorHome
| ChartMessage::CursorEnd => {
let max_idx = state
.series
.iter()
.map(|s| s.values().len())
.max()
.unwrap_or(1)
.saturating_sub(1);
if max_idx == 0 {
return None;
}
let current = state.cursor_position.unwrap_or(0);
let new_pos = match msg {
ChartMessage::CursorLeft => current.saturating_sub(1),
ChartMessage::CursorRight => (current + 1).min(max_idx),
ChartMessage::CursorHome => 0,
ChartMessage::CursorEnd => max_idx,
_ => unreachable!(),
};
if new_pos != current || state.cursor_position.is_none() {
state.cursor_position = Some(new_pos);
if !state.show_crosshair {
state.show_crosshair = true;
}
Some(ChartOutput::CursorMoved(new_pos))
} else {
None
}
}
ChartMessage::NextSeries | ChartMessage::PrevSeries => {
if state.series.is_empty() {
return None;
}
let len = state.series.len();
match msg {
ChartMessage::NextSeries => {
state.active_series = (state.active_series + 1) % len;
Some(ChartOutput::ActiveSeriesChanged(state.active_series))
}
ChartMessage::PrevSeries => {
state.active_series = if state.active_series == 0 {
len - 1
} else {
state.active_series - 1
};
Some(ChartOutput::ActiveSeriesChanged(state.active_series))
}
_ => unreachable!(),
}
}
}
}
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("chart")
.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.series.is_empty() {
return;
}
let title_padding = if state.title.is_some() { 1u16 } else { 0 };
let legend_entry_count =
state.series.len() + state.thresholds.len() + state.vertical_lines.len();
let legend_height = if state.show_legend && legend_entry_count > 1 {
1u16
} else {
0
};
let x_label_height = if state.x_label.is_some() { 1u16 } else { 0 };
let has_extras = title_padding + legend_height + x_label_height > 0;
let chart_area = if has_extras {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(title_padding),
Constraint::Min(1),
Constraint::Length(legend_height),
Constraint::Length(x_label_height),
])
.split(inner);
if legend_height > 0 {
render::render_legend(state, ctx.frame, chunks[2]);
}
if x_label_height > 0 {
if let Some(ref label) = state.x_label {
let p = Paragraph::new(label.as_str())
.alignment(Alignment::Center)
.style(Style::default().fg(Color::DarkGray));
ctx.frame.render_widget(p, chunks[3]);
}
}
chunks[1]
} else {
inner
};
match state.kind {
ChartKind::Line | ChartKind::Area | ChartKind::Scatter => {
render::render_shared_axis_chart(
state,
ctx.frame,
chart_area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
if state.show_crosshair {
if let Some(pos) = state.cursor_position {
render::render_crosshair_readout(state, ctx.frame, chart_area, pos);
}
}
}
ChartKind::BarVertical => render::render_bar_chart(
state,
ctx.frame,
chart_area,
ctx.theme,
false,
ctx.focused,
ctx.disabled,
),
ChartKind::BarHorizontal => render::render_bar_chart(
state,
ctx.frame,
chart_area,
ctx.theme,
true,
ctx.focused,
ctx.disabled,
),
}
}
}
#[cfg(test)]
mod annotation_tests;
#[cfg(test)]
mod area_fill_tests;
#[cfg(test)]
mod enhancement_tests;
#[cfg(test)]
mod error_band_tests;
#[cfg(test)]
mod render_tests;
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod x_labels_tests;