use super::*;
use unicode_width::UnicodeWidthStr;
pub(crate) fn render_chart(config: &ChartConfig) -> Vec<ChartRow> {
let width = config.width as usize;
let height = config.height as usize;
if width == 0 || height == 0 {
return Vec::new();
}
let frame_style = config.x_axis.style;
let dim_style = Style::new().fg(Color::Indexed(238));
let axis_style = config.y_axis.style;
let title_style = Style::new()
.bold()
.fg(config.x_axis.style.fg.unwrap_or(Color::White));
let title_style = config.title_style.unwrap_or(title_style);
let title_rows = usize::from(config.title.is_some());
let has_x_title = config.x_axis_visible && config.x_axis.title.is_some();
let x_title_rows = usize::from(has_x_title);
let frame_rows = if config.frame_visible { 2 } else { 0 };
let x_axis_rows = if config.x_axis_visible {
1 + x_title_rows
} else {
0
};
let overhead = title_rows + frame_rows + x_axis_rows;
if height <= overhead || width < 3 {
return minimal_chart(config, width, frame_style, title_style);
}
let plot_height = height.saturating_sub(overhead).max(1);
let (x_min, x_max) = resolve_bounds(
config
.datasets
.iter()
.flat_map(|d| d.data.iter().map(|p| p.0)),
config.x_axis.bounds,
);
let (y_min, y_max) = resolve_bounds(
config
.datasets
.iter()
.flat_map(|d| d.data.iter().map(|p| p.1)),
config.y_axis.bounds,
);
let y_label_chars: Vec<char> = if config.y_axis_visible {
config
.y_axis
.title
.as_deref()
.and_then(|t| {
let chars: Vec<char> = t.chars().collect();
if chars.len() <= plot_height {
Some(chars)
} else {
None
}
})
.unwrap_or_default()
} else {
Vec::new()
};
let y_label_col_width = if y_label_chars.is_empty() { 0 } else { 2 };
let legend_items = build_legend_items(&config.datasets);
let legend_on_right = matches!(
config.legend,
LegendPosition::TopRight | LegendPosition::BottomRight
);
let legend_width = if legend_on_right && !legend_items.is_empty() {
legend_items
.iter()
.map(|(_, name, _)| 4 + UnicodeWidthStr::width(name.as_str()))
.max()
.unwrap_or(0)
} else {
0
};
let y_ticks = if let Some(ref manual) = config.y_axis.ticks {
TickSpec {
values: manual.clone(),
step: if manual.len() > 1 {
manual[1] - manual[0]
} else {
1.0
},
}
} else {
build_tui_ticks(y_min, y_max, plot_height)
};
let y_min = y_ticks.values.first().copied().unwrap_or(y_min).min(y_min);
let y_max = y_ticks.values.last().copied().unwrap_or(y_max).max(y_max);
let use_manual_y_labels = config.y_axis.ticks.is_some() && config.y_axis.labels.is_some();
let y_tick_labels: Vec<String> = if use_manual_y_labels {
config
.y_axis
.labels
.as_deref()
.unwrap_or(&[])
.iter()
.take(y_ticks.values.len())
.cloned()
.collect()
} else {
y_ticks
.values
.iter()
.map(|v| format_number(*v, y_ticks.step))
.collect()
};
let y_tick_width = y_tick_labels
.iter()
.map(|s| UnicodeWidthStr::width(s.as_str()))
.max()
.unwrap_or(1);
let y_axis_width = if config.y_axis_visible {
y_tick_width + 2
} else {
0
};
let inner_width = if config.frame_visible {
width.saturating_sub(2)
} else {
width
};
let plot_width = inner_width
.saturating_sub(y_label_col_width)
.saturating_sub(y_axis_width)
.saturating_sub(legend_width)
.max(1);
let content_width = y_label_col_width + y_axis_width + plot_width + legend_width;
let x_ticks = if let Some(ref manual) = config.x_axis.ticks {
TickSpec {
values: manual.clone(),
step: if manual.len() > 1 {
manual[1] - manual[0]
} else {
1.0
},
}
} else {
build_tui_ticks(x_min, x_max, plot_width)
};
let x_min = x_ticks.values.first().copied().unwrap_or(x_min).min(x_min);
let x_max = x_ticks.values.last().copied().unwrap_or(x_max).max(x_max);
let mut plot_chars: Vec<char> = vec![' '; plot_width * plot_height];
let mut plot_styles: Vec<Style> = vec![Style::new(); plot_width * plot_height];
apply_grid(
config,
GridSpec {
x_ticks: &x_ticks.values,
y_ticks: &y_ticks.values,
x_min,
x_max,
y_min,
y_max,
},
&mut plot_chars,
&mut plot_styles,
plot_width,
plot_height,
config.grid_style.unwrap_or(dim_style),
);
for &(y_val, ref style) in &config.hlines {
let row = map_value_to_cell(y_val, y_min, y_max, plot_height, true);
if row < plot_height {
for col in 0..plot_width {
let idx = row * plot_width + col;
plot_chars[idx] = '─';
plot_styles[idx] = *style;
}
}
}
for &(x_val, ref style) in &config.vlines {
let col = map_value_to_cell(x_val, x_min, x_max, plot_width, false);
if col < plot_width {
for row in 0..plot_height {
let idx = row * plot_width + col;
if plot_chars[idx] == ' ' || plot_chars[idx] == '·' {
plot_chars[idx] = '│';
plot_styles[idx] = *style;
}
}
}
}
for dataset in &config.datasets {
match dataset.graph_type {
GraphType::Line | GraphType::Area | GraphType::Scatter => {
draw_braille_dataset(
dataset,
x_min,
x_max,
y_min,
y_max,
&mut plot_chars,
&mut plot_styles,
plot_width,
plot_height,
);
}
GraphType::Bar => {
draw_bar_dataset(
dataset,
x_min,
x_max,
y_min,
y_max,
&mut plot_chars,
&mut plot_styles,
plot_width,
plot_height,
);
}
}
}
if !legend_items.is_empty()
&& matches!(
config.legend,
LegendPosition::TopLeft | LegendPosition::BottomLeft
)
{
overlay_legend_on_plot(
config.legend,
&legend_items,
&mut plot_chars,
&mut plot_styles,
plot_width,
plot_height,
axis_style,
);
}
let y_tick_rows = build_y_tick_row_map(
&y_ticks.values,
if use_manual_y_labels {
config.y_axis.labels.as_deref()
} else {
None
},
y_min,
y_max,
plot_height,
);
let x_tick_cols = build_x_tick_col_map(
&x_ticks.values,
config.x_axis.labels.as_deref(),
config.x_axis.ticks.is_some() && config.x_axis.labels.is_some(),
x_min,
x_max,
plot_width,
);
let mut rows: Vec<ChartRow> = Vec::with_capacity(height);
if let Some(title) = &config.title {
rows.push(ChartRow {
segments: vec![(center_text(title, width), title_style)],
});
}
if config.frame_visible {
rows.push(ChartRow {
segments: vec![(format!("┌{}┐", "─".repeat(content_width)), frame_style)],
});
}
let y_label_start = if y_label_chars.is_empty() {
0
} else {
plot_height.saturating_sub(y_label_chars.len()) / 2
};
let y_title_style = config.y_axis.title_style.unwrap_or(axis_style);
let zero_label = format_number(0.0, y_ticks.step);
for row in 0..plot_height {
let mut segments: Vec<(String, Style)> = Vec::new();
if config.frame_visible {
segments.push(("│".to_string(), frame_style));
}
if config.y_axis_visible {
if y_label_col_width > 0 {
let label_idx = row.wrapping_sub(y_label_start);
if label_idx < y_label_chars.len() {
segments.push((format!("{} ", y_label_chars[label_idx]), y_title_style));
} else {
segments.push((" ".to_string(), Style::new()));
}
}
let (label, divider) =
if let Some(index) = y_tick_rows.iter().position(|(r, _)| *r == row) {
let is_zero = y_tick_rows[index].1 == zero_label;
(
y_tick_rows[index].1.clone(),
if is_zero { '┼' } else { '┤' },
)
} else {
(String::new(), '│')
};
let padded = format!("{:>w$}", label, w = y_tick_width);
segments.push((padded, axis_style));
segments.push((format!("{divider} "), axis_style));
}
let mut current_style = Style::new();
let mut buffer = String::new();
for col in 0..plot_width {
let idx = row * plot_width + col;
let style = plot_styles[idx];
if col == 0 {
current_style = style;
}
if style != current_style {
if !buffer.is_empty() {
segments.push((buffer.clone(), current_style));
buffer.clear();
}
current_style = style;
}
buffer.push(plot_chars[idx]);
}
if !buffer.is_empty() {
segments.push((buffer, current_style));
}
if legend_on_right && legend_width > 0 {
let legend_row = match config.legend {
LegendPosition::TopRight => row,
LegendPosition::BottomRight => {
row.wrapping_add(legend_items.len().saturating_sub(plot_height))
}
_ => usize::MAX,
};
if let Some((symbol, name, color)) = legend_items.get(legend_row) {
let raw = format!(" {symbol} {name}");
let raw_w = UnicodeWidthStr::width(raw.as_str());
let pad = legend_width.saturating_sub(raw_w);
let text = format!("{raw}{}", " ".repeat(pad));
segments.push((text, Style::new().fg(*color)));
} else {
segments.push((" ".repeat(legend_width), Style::new()));
}
}
if config.frame_visible {
segments.push(("│".to_string(), frame_style));
}
rows.push(ChartRow { segments });
}
if config.x_axis_visible {
let footer_legend_pad = " ".repeat(legend_width);
let footer_ylabel_pad = if config.y_axis_visible {
" ".repeat(y_label_col_width)
} else {
String::new()
};
let mut x_label_line: Vec<char> = vec![' '; plot_width];
let mut occupied_until: usize = 0;
for (col, label) in &x_tick_cols {
if label.is_empty() {
continue;
}
let label_width = UnicodeWidthStr::width(label.as_str());
let start = col
.saturating_sub(label_width / 2)
.min(plot_width.saturating_sub(label_width));
if start < occupied_until {
continue;
}
for (offset, ch) in label.chars().enumerate() {
let idx = start + offset;
if idx < plot_width {
x_label_line[idx] = ch;
}
}
occupied_until = start + label_width + 1;
}
let mut x_label_segments: Vec<(String, Style)> = Vec::new();
if config.frame_visible {
x_label_segments.push(("│".to_string(), frame_style));
}
if config.y_axis_visible {
x_label_segments.push((footer_ylabel_pad.clone(), Style::new()));
x_label_segments.push((" ".repeat(y_axis_width), Style::new()));
}
x_label_segments.push((x_label_line.into_iter().collect(), axis_style));
x_label_segments.push((footer_legend_pad.clone(), Style::new()));
if config.frame_visible {
x_label_segments.push(("│".to_string(), frame_style));
}
rows.push(ChartRow {
segments: x_label_segments,
});
if has_x_title {
let x_title_text = config.x_axis.title.as_deref().unwrap_or_default();
let x_title = center_text(x_title_text, plot_width);
let x_title_style = config.x_axis.title_style.unwrap_or(axis_style);
let mut x_title_segments: Vec<(String, Style)> = Vec::new();
if config.frame_visible {
x_title_segments.push(("│".to_string(), frame_style));
}
if config.y_axis_visible {
x_title_segments.push((footer_ylabel_pad, Style::new()));
x_title_segments.push((" ".repeat(y_axis_width), Style::new()));
}
x_title_segments.push((x_title, x_title_style));
x_title_segments.push((footer_legend_pad, Style::new()));
if config.frame_visible {
x_title_segments.push(("│".to_string(), frame_style));
}
rows.push(ChartRow {
segments: x_title_segments,
});
}
}
if config.frame_visible {
rows.push(ChartRow {
segments: vec![(format!("└{}┘", "─".repeat(content_width)), frame_style)],
});
}
rows
}
fn minimal_chart(
config: &ChartConfig,
width: usize,
frame_style: Style,
title_style: Style,
) -> Vec<ChartRow> {
let mut rows = Vec::new();
if let Some(title) = &config.title {
rows.push(ChartRow {
segments: vec![(center_text(title, width), title_style)],
});
}
if config.frame_visible {
let inner = width.saturating_sub(2);
rows.push(ChartRow {
segments: vec![(format!("┌{}┐", "─".repeat(inner)), frame_style)],
});
rows.push(ChartRow {
segments: vec![(format!("│{}│", " ".repeat(inner)), frame_style)],
});
rows.push(ChartRow {
segments: vec![(format!("└{}┘", "─".repeat(inner)), frame_style)],
});
} else {
rows.push(ChartRow {
segments: vec![(" ".repeat(width), Style::new())],
});
}
rows
}