use super::chart_common::{Axis, AxisFormat, LegendPosition, Marker};
use super::types::{ChartType, LineStyle, Series};
use crate::layout::Rect;
use crate::render::Cell;
use crate::style::Color;
use crate::utils::{char_width, display_width};
use crate::widget::theme::DARK_GRAY;
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
pub(super) struct LineSegment {
x0: u16,
y0: u16,
x1: u16,
y1: u16,
color: Color,
style: LineStyle,
}
#[derive(Debug)]
pub struct Chart {
title: Option<String>,
series: Vec<Series>,
x_axis: Axis,
y_axis: Axis,
legend: LegendPosition,
bg_color: Option<Color>,
border_color: Option<Color>,
braille_mode: bool,
tooltip: Option<super::chart_common::ChartTooltip>,
props: WidgetProps,
}
impl Chart {
pub fn new() -> Self {
Self {
title: None,
series: Vec::new(),
x_axis: Axis::default(),
y_axis: Axis::default(),
legend: LegendPosition::TopRight,
bg_color: None,
border_color: None,
braille_mode: false,
tooltip: None,
props: WidgetProps::new(),
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn series(mut self, series: Series) -> Self {
self.series.push(series);
self
}
pub fn series_vec(mut self, series: Vec<Series>) -> Self {
self.series.extend(series);
self
}
pub fn x_axis(mut self, axis: Axis) -> Self {
self.x_axis = axis;
self
}
pub fn y_axis(mut self, axis: Axis) -> Self {
self.y_axis = axis;
self
}
pub fn legend(mut self, position: LegendPosition) -> Self {
self.legend = position;
self
}
pub fn no_legend(mut self) -> Self {
self.legend = LegendPosition::None;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg_color = Some(color);
self
}
pub fn border(mut self, color: Color) -> Self {
self.border_color = Some(color);
self
}
pub fn braille(mut self) -> Self {
self.braille_mode = true;
self
}
pub fn tooltip(mut self, tooltip: super::chart_common::ChartTooltip) -> Self {
self.tooltip = Some(tooltip);
self
}
pub fn with_tooltip(mut self) -> Self {
self.tooltip = Some(super::chart_common::ChartTooltip::enabled());
self
}
fn compute_bounds(&self) -> (f64, f64, f64, f64) {
let mut x_min = f64::MAX;
let mut x_max = f64::MIN;
let mut y_min = f64::MAX;
let mut y_max = f64::MIN;
let mut has_data = false;
for series in &self.series {
for &(x, y) in &series.data {
if !x.is_finite() || !y.is_finite() {
continue;
}
has_data = true;
x_min = x_min.min(x);
x_max = x_max.max(x);
y_min = y_min.min(y);
y_max = y_max.max(y);
}
}
if !has_data {
x_min = self.x_axis.min.unwrap_or(0.0);
x_max = self.x_axis.max.unwrap_or(1.0);
y_min = self.y_axis.min.unwrap_or(0.0);
y_max = self.y_axis.max.unwrap_or(1.0);
} else {
x_min = self.x_axis.min.unwrap_or(x_min);
x_max = self.x_axis.max.unwrap_or(x_max);
y_min = self.y_axis.min.unwrap_or(y_min);
y_max = self.y_axis.max.unwrap_or(y_max);
}
const EPSILON: f64 = 1e-10;
if (x_max - x_min).abs() < EPSILON {
let center = (x_max + x_min) / 2.0;
x_min = center - 0.5;
x_max = center + 0.5;
}
if (y_max - y_min).abs() < EPSILON {
let center = (y_max + y_min) / 2.0;
y_min = center - 0.5;
y_max = center + 0.5;
}
let y_range = y_max - y_min;
let y_min = if self.y_axis.min.is_none() {
y_min - y_range * 0.05
} else {
y_min
};
let y_max = if self.y_axis.max.is_none() {
y_max + y_range * 0.05
} else {
y_max
};
(x_min, x_max, y_min, y_max)
}
fn format_label(&self, value: f64, format: &AxisFormat) -> String {
match format {
AxisFormat::Auto => {
if value.abs() >= 1000.0 || (value != 0.0 && value.abs() < 0.01) {
format!("{:.1e}", value)
} else if value.fract() == 0.0 {
format!("{:.0}", value)
} else {
format!("{:.2}", value)
}
}
AxisFormat::Integer => format!("{:.0}", value),
AxisFormat::Fixed(decimals) => format!("{:.1$}", value, *decimals),
AxisFormat::Percent => format!("{:.0}%", value * 100.0),
AxisFormat::Custom(fmt) => fmt.replace("{}", &value.to_string()),
}
}
fn map_point(
&self,
x: f64,
y: f64,
bounds: (f64, f64, f64, f64),
chart_area: (u16, u16, u16, u16),
) -> (u16, u16) {
let (x_min, x_max, y_min, y_max) = bounds;
let (cx, cy, cw, ch) = chart_area;
let x_range = x_max - x_min;
let y_range = y_max - y_min;
let px = if x_range > 0.0 {
cx + ((x - x_min) / x_range * (cw as f64 - 1.0)) as u16
} else {
cx + cw / 2
};
let py = if y_range > 0.0 {
cy + ch - 1 - ((y - y_min) / y_range * (ch as f64 - 1.0)) as u16
} else {
cy + ch / 2
};
(px, py)
}
fn get_line_char(&self, dx: i32, dy: i32) -> char {
match (dx.signum(), dy.signum()) {
(1, 0) | (-1, 0) => '─', (0, 1) | (0, -1) => '│', (1, -1) | (-1, 1) => '╱', (1, 1) | (-1, -1) => '╲', _ => '·',
}
}
fn draw_line(&self, ctx: &mut RenderContext, seg: &LineSegment, bounds: &Rect) {
let LineSegment {
x0,
y0,
x1,
y1,
color,
style,
} = *seg;
let dx = (x1 as i32 - x0 as i32).abs();
let dy = (y1 as i32 - y0 as i32).abs();
let sx = if x0 < x1 { 1i32 } else { -1i32 };
let sy = if y0 < y1 { 1i32 } else { -1i32 };
let mut err = dx - dy;
let mut x = x0 as i32;
let mut y = y0 as i32;
let mut step = 0;
loop {
if x >= bounds.x as i32
&& x < (bounds.x + bounds.width) as i32
&& y >= bounds.y as i32
&& y < (bounds.y + bounds.height) as i32
{
let draw = match style {
LineStyle::Solid => true,
LineStyle::Dashed => (step / 3) % 2 == 0,
LineStyle::Dotted => step % 2 == 0,
LineStyle::None => false,
};
if draw {
let ch = self.get_line_char(x1 as i32 - x0 as i32, y1 as i32 - y0 as i32);
let mut cell = Cell::new(ch);
cell.fg = Some(color);
ctx.set(x as u16, y as u16, cell);
}
}
if x == x1 as i32 && y == y1 as i32 {
break;
}
let e2 = 2 * err;
if e2 > -dy {
err -= dy;
x += sx;
}
if e2 < dx {
err += dx;
y += sy;
}
step += 1;
}
}
fn draw_area_fill(
&self,
ctx: &mut RenderContext,
points: &[(u16, u16)],
fill_color: Color,
chart_area: (u16, u16, u16, u16),
y_bottom: u16,
) {
let (cx, cy, cw, ch) = chart_area;
for window in points.windows(2) {
let (x0, y0) = window[0];
let (x1, y1) = window[1];
for x in x0..=x1 {
if x < cx || x >= cx + cw {
continue;
}
let t = if x1 != x0 {
(x - x0) as f64 / (x1 - x0) as f64
} else {
0.0
};
let y_line = y0 as f64 + t * (y1 as f64 - y0 as f64);
let y_start = y_line.ceil() as u16;
let y_end = y_bottom.min(cy + ch - 1);
for y in y_start..=y_end {
if y >= cy && y < cy + ch {
let gradient = (y - y_start) as f64 / (y_end - y_start).max(1) as f64;
let ch = if gradient < 0.33 {
'░'
} else if gradient < 0.66 {
'▒'
} else {
'▓'
};
let mut cell = Cell::new(ch);
cell.fg = Some(fill_color);
ctx.set(x, y, cell);
}
}
}
}
}
}
impl Default for Chart {
fn default() -> Self {
Self::new()
}
}
impl View for Chart {
crate::impl_view_meta!("Chart");
fn render(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if area.width < 10 || area.height < 5 {
return;
}
if let Some(bg) = self.bg_color {
for y in 0..area.height {
for x in 0..area.width {
let mut cell = Cell::new(' ');
cell.bg = Some(bg);
ctx.set(x, y, cell);
}
}
}
if let Some(border_color) = self.border_color {
for x in 0..area.width {
let mut top = Cell::new('─');
top.fg = Some(border_color);
ctx.set(x, 0, top);
let mut bottom = Cell::new('─');
bottom.fg = Some(border_color);
ctx.set(x, area.height - 1, bottom);
}
for y in 0..area.height {
let mut left = Cell::new('│');
left.fg = Some(border_color);
ctx.set(0, y, left);
let mut right = Cell::new('│');
right.fg = Some(border_color);
ctx.set(area.width - 1, y, right);
}
let corners = [
(0u16, 0u16, '┌'),
(area.width - 1, 0, '┐'),
(0, area.height - 1, '└'),
(area.width - 1, area.height - 1, '┘'),
];
for (x, y, ch) in corners {
let mut cell = Cell::new(ch);
cell.fg = Some(border_color);
ctx.set(x, y, cell);
}
}
let has_border = self.border_color.is_some();
let has_title = self.title.is_some();
let y_label_width = 8u16; let x_label_height = 2u16;
let inner_x = if has_border { 1 } else { 0 } + y_label_width;
let inner_y = if has_border { 1 } else { 0 } + if has_title { 1u16 } else { 0 };
let inner_w = area
.width
.saturating_sub(y_label_width + if has_border { 2 } else { 0 });
let inner_h = area.height.saturating_sub(
x_label_height + if has_border { 2 } else { 0 } + if has_title { 1 } else { 0 },
);
if inner_w < 5 || inner_h < 3 {
return;
}
let chart_bounds = Rect::new(inner_x, inner_y, inner_w, inner_h);
if let Some(ref title) = self.title {
let title_x = (area.width.saturating_sub(display_width(title) as u16)) / 2;
let title_y = if has_border { 1u16 } else { 0 };
let mut dx: u16 = 0;
for ch in title.chars() {
let mut cell = Cell::new(ch);
cell.fg = Some(Color::WHITE);
cell.modifier |= crate::render::Modifier::BOLD;
ctx.set(title_x + dx, title_y, cell);
dx += char_width(ch) as u16;
}
}
let bounds = self.compute_bounds();
let (x_min, x_max, y_min, y_max) = bounds;
let y_label_x = if has_border { 1u16 } else { 0 };
for i in 0..=self.y_axis.ticks {
let t = i as f64 / self.y_axis.ticks as f64;
let value = y_min + t * (y_max - y_min);
let label = self.format_label(value, &self.y_axis.format);
let y = inner_y + inner_h - 1 - ((t * (inner_h as f64 - 1.0)) as u16);
let label_start =
y_label_x + y_label_width.saturating_sub(display_width(&label) as u16 + 1);
let mut dx: u16 = 0;
for ch in label.chars() {
let mut cell = Cell::new(ch);
cell.fg = Some(self.y_axis.color);
ctx.set(label_start + dx, y, cell);
dx += char_width(ch) as u16;
}
if self.y_axis.grid && i > 0 && i < self.y_axis.ticks {
for x in inner_x..inner_x + inner_w {
let mut cell = Cell::new('┄');
cell.fg = Some(Color::rgb(50, 50, 50));
ctx.set(x, y, cell);
}
}
}
let x_label_y = inner_y + inner_h;
for i in 0..=self.x_axis.ticks {
let t = i as f64 / self.x_axis.ticks as f64;
let value = x_min + t * (x_max - x_min);
let label = self.format_label(value, &self.x_axis.format);
let x = inner_x + (t * (inner_w as f64 - 1.0)) as u16;
let label_start = x.saturating_sub(display_width(&label) as u16 / 2);
let mut dx: u16 = 0;
for ch in label.chars() {
let px = label_start + dx;
if px < area.width {
let mut cell = Cell::new(ch);
cell.fg = Some(self.x_axis.color);
ctx.set(px, x_label_y, cell);
}
dx += char_width(ch) as u16;
}
if self.x_axis.grid && i > 0 && i < self.x_axis.ticks {
for y in inner_y..inner_y + inner_h {
let mut cell = Cell::new('┊');
cell.fg = Some(Color::rgb(50, 50, 50));
ctx.set(x, y, cell);
}
}
}
if let Some(ref title) = self.y_axis.title {
let char_count = title.chars().count() as u16;
let y_start = inner_y + (inner_h.saturating_sub(char_count)) / 2;
for (i, ch) in title.chars().enumerate() {
let mut cell = Cell::new(ch);
cell.fg = Some(self.y_axis.color);
ctx.set(y_label_x, y_start + i as u16, cell);
}
}
if let Some(ref title) = self.x_axis.title {
let x_start = inner_x + (inner_w.saturating_sub(display_width(title) as u16)) / 2;
let y = x_label_y + 1;
if y < area.height {
let mut dx: u16 = 0;
for ch in title.chars() {
let mut cell = Cell::new(ch);
cell.fg = Some(self.x_axis.color);
ctx.set(x_start + dx, y, cell);
dx += char_width(ch) as u16;
}
}
}
let y_bottom = inner_y + inner_h - 1;
for series in &self.series {
if series.data.is_empty() {
continue;
}
let chart_area = (
chart_bounds.x,
chart_bounds.y,
chart_bounds.width,
chart_bounds.height,
);
let screen_points: Vec<(u16, u16)> = series
.data
.iter()
.map(|&(x, y)| self.map_point(x, y, bounds, chart_area))
.collect();
if matches!(series.chart_type, ChartType::Area) {
if let Some(fill_color) = series.fill_color {
self.draw_area_fill(ctx, &screen_points, fill_color, chart_area, y_bottom);
}
}
if !matches!(series.line_style, LineStyle::None) {
match series.chart_type {
ChartType::Line | ChartType::Area => {
for window in screen_points.windows(2) {
let (x0, y0) = window[0];
let (x1, y1) = window[1];
let seg = LineSegment {
x0,
y0,
x1,
y1,
color: series.color,
style: series.line_style,
};
self.draw_line(ctx, &seg, &chart_bounds);
}
}
ChartType::StepAfter => {
for window in screen_points.windows(2) {
let (x0, y0) = window[0];
let (x1, y1) = window[1];
let horiz = LineSegment {
x0,
y0,
x1,
y1: y0,
color: series.color,
style: series.line_style,
};
self.draw_line(ctx, &horiz, &chart_bounds);
let vert = LineSegment {
x0: x1,
y0,
x1,
y1,
color: series.color,
style: series.line_style,
};
self.draw_line(ctx, &vert, &chart_bounds);
}
}
ChartType::StepBefore => {
for window in screen_points.windows(2) {
let (x0, y0) = window[0];
let (x1, y1) = window[1];
let vert = LineSegment {
x0,
y0,
x1: x0,
y1,
color: series.color,
style: series.line_style,
};
self.draw_line(ctx, &vert, &chart_bounds);
let horiz = LineSegment {
x0,
y0: y1,
x1,
y1,
color: series.color,
style: series.line_style,
};
self.draw_line(ctx, &horiz, &chart_bounds);
}
}
ChartType::Scatter => {}
}
}
if !matches!(series.marker, Marker::None) {
let marker_char = series.marker.char();
for &(x, y) in &screen_points {
if x >= inner_x
&& x < inner_x + inner_w
&& y >= inner_y
&& y < inner_y + inner_h
{
let mut cell = Cell::new(marker_char);
cell.fg = Some(series.color);
ctx.set(x, y, cell);
}
}
}
}
if !matches!(self.legend, LegendPosition::None) && !self.series.is_empty() {
let legend_width = self
.series
.iter()
.map(|s| s.name.len() + 4)
.max()
.unwrap_or(10) as u16;
let legend_height = self.series.len() as u16 + 2;
let (legend_x, legend_y) = match self.legend {
LegendPosition::TopLeft => (inner_x + 1, inner_y + 1),
LegendPosition::TopCenter => (inner_x + (inner_w - legend_width) / 2, inner_y + 1),
LegendPosition::TopRight => (inner_x + inner_w - legend_width - 1, inner_y + 1),
LegendPosition::BottomLeft => (inner_x + 1, inner_y + inner_h - legend_height - 1),
LegendPosition::BottomCenter => (
inner_x + (inner_w - legend_width) / 2,
inner_y + inner_h - legend_height - 1,
),
LegendPosition::BottomRight => (
inner_x + inner_w - legend_width - 1,
inner_y + inner_h - legend_height - 1,
),
LegendPosition::Left => (inner_x + 1, inner_y + (inner_h - legend_height) / 2),
LegendPosition::Right => (
inner_x + inner_w - legend_width - 1,
inner_y + (inner_h - legend_height) / 2,
),
LegendPosition::None => (inner_x + 1, inner_y + 1),
};
for dy in 0..legend_height {
for dx in 0..legend_width {
let x = legend_x + dx;
let y = legend_y + dy;
if x < inner_x + inner_w && y < inner_y + inner_h {
let ch = if dy == 0 && dx == 0 {
'┌'
} else if dy == 0 && dx == legend_width - 1 {
'┐'
} else if dy == legend_height - 1 && dx == 0 {
'└'
} else if dy == legend_height - 1 && dx == legend_width - 1 {
'┘'
} else if dy == 0 || dy == legend_height - 1 {
'─'
} else if dx == 0 || dx == legend_width - 1 {
'│'
} else {
' '
};
let mut cell = Cell::new(ch);
cell.fg = Some(DARK_GRAY);
cell.bg = self.bg_color.or(Some(Color::rgb(20, 20, 20)));
ctx.set(x, y, cell);
}
}
}
for (i, series) in self.series.iter().enumerate() {
let y = legend_y + 1 + i as u16;
if y >= inner_y + inner_h - 1 {
break;
}
let mut indicator = Cell::new('■');
indicator.fg = Some(series.color);
ctx.set(legend_x + 1, y, indicator);
let mut dx: u16 = 0;
for ch in series.name.chars() {
let x = legend_x + 3 + dx;
if x < legend_x + legend_width - 1 {
let mut cell = Cell::new(ch);
cell.fg = Some(Color::WHITE);
cell.bg = self.bg_color.or(Some(Color::rgb(20, 20, 20)));
ctx.set(x, y, cell);
}
dx += char_width(ch) as u16;
}
}
}
}
}
impl_styled_view!(Chart);
impl_props_builders!(Chart);
pub fn chart() -> Chart {
Chart::new()
}
pub fn line_chart(data: &[f64]) -> Chart {
Chart::new().series(Series::new("Data").data_y(data).line())
}
pub fn scatter_plot(data: &[(f64, f64)]) -> Chart {
Chart::new().series(Series::new("Data").data(data.to_vec()).scatter())
}