use unicode_width::UnicodeWidthStr;
use crate::xy_chart::{XAxis, XyChart};
const DEFAULT_WIDTH: usize = 72;
const MIN_WIDTH: usize = 30;
const DEFAULT_CHART_ROWS: usize = 20;
const BAR_GLYPH: char = '\u{2588}';
pub fn render(diag: &XyChart, max_width: Option<usize>) -> String {
let canvas_width = max_width.map(|w| w.max(MIN_WIDTH)).unwrap_or(DEFAULT_WIDTH);
let mut out = String::new();
if let Some(title) = &diag.title {
out.push_str(title);
out.push('\n');
out.push('\n');
}
if let Some(label) = &diag.y_axis.label {
out.push_str(label);
out.push('\n');
}
let y_min = diag.y_axis.min;
let y_max = diag.y_axis.max;
let chart_rows = DEFAULT_CHART_ROWS;
let y_label_width = compute_y_label_width(y_min, y_max, chart_rows);
let n_data = count_data_points(diag);
let axis_prefix_width = y_label_width + 2; let usable_width = canvas_width.saturating_sub(axis_prefix_width);
let col_width = if n_data == 0 || usable_width == 0 {
3
} else {
(usable_width / n_data).max(2)
};
let total_cols = usable_width.min(n_data * col_width);
let mut canvas: Vec<Vec<char>> = vec![vec![' '; total_cols + 1]; chart_rows + 1];
if !diag.bar_series.is_empty() {
draw_bars(
&mut canvas,
&diag.bar_series,
y_min,
y_max,
col_width,
chart_rows,
);
}
if !diag.line_series.is_empty() {
draw_line(
&mut canvas,
&diag.line_series,
y_min,
y_max,
col_width,
chart_rows,
);
}
for (row_idx, row_chars) in canvas.iter().enumerate().take(chart_rows + 1) {
let y_val = y_max - (row_idx as f64 / chart_rows as f64) * (y_max - y_min);
let tick_label = if row_idx == 0 {
format_tick(y_max)
} else if row_idx == chart_rows {
format_tick(y_min)
} else {
let step = chart_rows / 4;
if step > 0 && row_idx % step == 0 {
format_tick(y_val)
} else {
String::new()
}
};
let label_str = if tick_label.is_empty() {
" ".repeat(y_label_width)
} else {
let w = tick_label.len();
format!(
"{}{}",
" ".repeat(y_label_width.saturating_sub(w)),
tick_label
)
};
let axis_glyph = '\u{2524}';
let row_str: String = row_chars.iter().collect();
let row_trimmed = row_str.trim_end();
out.push_str(&label_str);
out.push(' ');
out.push(axis_glyph);
out.push_str(row_trimmed);
out.push('\n');
}
let pad = " ".repeat(y_label_width + 1); out.push_str(&pad);
out.push('\u{2514}');
if n_data > 0 {
for col in 0..n_data {
let dashes = "\u{2500}".repeat(col_width.saturating_sub(1));
out.push_str(&dashes);
if col + 1 < n_data {
out.push('\u{252C}'); }
}
out.push('\u{2500}'); }
out.push('\n');
let x_labels = build_x_labels(diag, n_data);
if !x_labels.is_empty() {
let max_lw = x_labels
.iter()
.map(|l| UnicodeWidthStr::width(l.as_str()))
.max()
.unwrap_or(0);
let padded: Vec<String> = x_labels
.iter()
.map(|l| {
let lw = UnicodeWidthStr::width(l.as_str());
let pad = max_lw.saturating_sub(lw);
format!("{}{}", l, " ".repeat(pad))
})
.collect();
let label_pad = " ".repeat(y_label_width + 2); out.push_str(&label_pad);
for (i, label) in padded.iter().enumerate() {
let lw = UnicodeWidthStr::width(label.as_str());
let left_pad = col_width.saturating_sub(lw) / 2;
let right_pad = col_width.saturating_sub(lw).saturating_sub(left_pad);
out.push_str(&" ".repeat(left_pad));
out.push_str(label);
if i + 1 < padded.len() {
out.push_str(&" ".repeat(right_pad));
}
}
out.push('\n');
}
while out.ends_with('\n') {
out.pop();
}
out
}
fn compute_y_label_width(y_min: f64, y_max: f64, chart_rows: usize) -> usize {
let mut max_w = 0;
let candidates = [
format_tick(y_min),
format_tick(y_max),
format_tick((y_min + y_max) / 2.0),
];
for c in &candidates {
max_w = max_w.max(c.len());
}
let step = chart_rows / 4;
if step > 0 {
for i in (0..=chart_rows).step_by(step) {
let y = y_max - (i as f64 / chart_rows as f64) * (y_max - y_min);
let w = format_tick(y).len();
max_w = max_w.max(w);
}
}
max_w
}
fn format_tick(v: f64) -> String {
if v.fract() == 0.0 {
format!("{}", v as i64)
} else {
format!("{:.1}", v)
}
}
fn count_data_points(diag: &XyChart) -> usize {
if !diag.bar_series.is_empty() {
return diag.bar_series.len();
}
if !diag.line_series.is_empty() {
return diag.line_series.len();
}
match &diag.x_axis {
XAxis::Categorical { labels } => labels.len(),
XAxis::Numeric { .. } => 0,
}
}
fn value_to_row(value: f64, y_min: f64, y_max: f64, chart_rows: usize) -> usize {
if y_max <= y_min {
return chart_rows;
}
let frac = (y_max - value.clamp(y_min, y_max)) / (y_max - y_min);
(frac * chart_rows as f64).round() as usize
}
fn draw_bars(
canvas: &mut [Vec<char>],
values: &[f64],
y_min: f64,
y_max: f64,
col_width: usize,
chart_rows: usize,
) {
let baseline_row = chart_rows; let canvas_cols = canvas[0].len();
for (i, &val) in values.iter().enumerate() {
let top_row = value_to_row(val, y_min, y_max, chart_rows);
let start_col = i * col_width;
let bar_chars = col_width.saturating_sub(1);
for row in top_row..baseline_row {
for c in 0..bar_chars {
let col = start_col + c;
if col < canvas_cols && row < canvas.len() {
canvas[row][col] = BAR_GLYPH;
}
}
}
}
}
fn draw_line(
canvas: &mut [Vec<char>],
values: &[f64],
y_min: f64,
y_max: f64,
col_width: usize,
chart_rows: usize,
) {
let canvas_cols = canvas[0].len();
let n = values.len();
let rows: Vec<usize> = values
.iter()
.map(|&v| value_to_row(v, y_min, y_max, chart_rows))
.collect();
for i in 0..n.saturating_sub(1) {
let row = rows[i];
let center_col = i * col_width + col_width / 2;
let next_row = rows[i + 1];
let next_center_col = (i + 1) * col_width + col_width / 2;
draw_segment(
canvas,
row,
center_col,
next_row,
next_center_col,
chart_rows,
);
}
for (i, &row) in rows.iter().enumerate() {
let center_col = i * col_width + col_width / 2;
if row < canvas.len() && center_col < canvas_cols {
canvas[row][center_col] = '\u{25CF}'; }
}
}
fn draw_segment(
canvas: &mut [Vec<char>],
r0: usize,
c0: usize,
r1: usize,
c1: usize,
chart_rows: usize,
) {
let canvas_rows = canvas.len();
let canvas_cols = if canvas_rows > 0 { canvas[0].len() } else { 0 };
let dash_start = c0 + 1;
let dash_end = c1.saturating_sub(1);
let mid_row = if r0 <= r1 { r0 } else { r1 };
if mid_row < canvas_rows {
let dash_end_clamped = dash_end.min(canvas_cols.saturating_sub(1));
if dash_start <= dash_end_clamped {
for cell in &mut canvas[mid_row][dash_start..=dash_end_clamped] {
if *cell == ' ' {
*cell = '\u{2500}'; }
}
}
}
if r0 != r1 {
let (top_row, bottom_row) = if r0 < r1 { (r0, r1) } else { (r1, r0) };
let vert_col = if r0 < r1 { c1 } else { c0 };
let (top_corner, bot_corner) = if r0 < r1 {
('\u{256E}', '\u{2570}') } else {
('\u{256D}', '\u{256F}') };
if top_row < canvas_rows && vert_col < canvas_cols {
canvas[top_row][vert_col] = top_corner;
}
for row in canvas.iter_mut().take(bottom_row).skip(top_row + 1) {
if vert_col < row.len() && row[vert_col] == ' ' {
row[vert_col] = '\u{2502}'; }
}
if bottom_row < canvas_rows && vert_col < canvas_cols {
canvas[bottom_row][vert_col] = bot_corner;
}
let _ = (top_corner, bot_corner, chart_rows);
}
}
fn build_x_labels(diag: &XyChart, n_data: usize) -> Vec<String> {
match &diag.x_axis {
XAxis::Categorical { labels } => labels.clone(),
XAxis::Numeric { min, max, .. } => {
if n_data == 0 {
return Vec::new();
}
let mut out = vec![String::new(); n_data];
out[0] = format_tick(*min);
if n_data > 1 {
out[n_data - 1] = format_tick(*max);
}
out
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::xy_chart::parse;
fn canonical_src() -> &'static str {
"xychart-beta
title \"Sales Revenue\"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
y-axis \"Revenue (in $)\" 4000 --> 11000
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]"
}
#[test]
fn title_appears_in_output() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(
out.contains("Sales Revenue"),
"title missing from output:\n{out}"
);
}
#[test]
fn y_axis_label_appears_in_output() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(
out.contains("Revenue (in $)"),
"y-axis label missing:\n{out}"
);
}
#[test]
fn x_axis_labels_appear_in_output() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(out.contains("jan"), "jan label missing:\n{out}");
assert!(out.contains("dec"), "dec label missing:\n{out}");
}
#[test]
fn bar_glyphs_present_when_bar_series_set() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(
out.contains(BAR_GLYPH),
"bar glyph missing from output:\n{out}"
);
}
#[test]
fn empty_chart_renders_without_panic() {
let src = "xychart-beta\n y-axis 0 --> 100";
let chart = parse(src).unwrap();
let out = render(&chart, Some(80));
assert!(!out.is_empty());
assert!(out.contains('\u{2524}') || out.contains('\u{2514}'));
}
#[test]
fn max_width_is_honoured() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, Some(60));
let longest = out.lines().map(|l| l.chars().count()).max().unwrap_or(0);
assert!(
longest <= 80, "line too long ({longest} chars) with max_width=60:\n{out}"
);
}
#[test]
fn line_only_chart_renders_without_bar_glyphs() {
let src = "xychart-beta\n x-axis [a, b, c]\n y-axis 0 --> 10\n line [3, 7, 5]";
let chart = parse(src).unwrap();
let out = render(&chart, None);
assert!(
!out.contains(BAR_GLYPH),
"bar glyph should not appear when only line series is set:\n{out}"
);
}
#[test]
fn y_axis_tick_values_appear_in_output() {
let src = "xychart-beta\n y-axis 0 --> 100\n bar [50, 80, 30]";
let chart = parse(src).unwrap();
let out = render(&chart, None);
assert!(out.contains("100"), "y_max tick missing:\n{out}");
assert!(out.contains("0"), "y_min tick missing:\n{out}");
}
#[test]
fn x_axis_same_width_labels_have_uniform_spacing() {
for &(n, label_len) in &[(3usize, 3), (5, 3), (8, 3), (12, 3), (15, 1)] {
let labels: Vec<String> = (0..n)
.map(|i| {
let base = (b'a' + (i % 26) as u8) as char;
std::iter::repeat_n(base, label_len).collect()
})
.collect();
let series: Vec<String> = (0..n).map(|i| ((i + 1) * 10).to_string()).collect();
let src = format!(
"xychart-beta\n x-axis [{}]\n y-axis 0 --> 200\n bar [{}]",
labels.join(", "),
series.join(", "),
);
let chart = parse(&src).unwrap();
let out = render(&chart, None);
let label_line = out.lines().last().expect("last line should be labels");
let positions: Vec<usize> = labels
.iter()
.scan(0usize, |start, label| {
let pos = label_line[*start..].find(label.as_str())? + *start;
*start = pos + label.len();
Some(pos)
})
.collect();
assert_eq!(
positions.len(),
n,
"missing labels for n={n} label_len={label_len}:\n{out}"
);
if positions.len() >= 3 {
let gaps: Vec<usize> = positions.windows(2).map(|w| w[1] - w[0]).collect();
let first = gaps[0];
assert!(
gaps.iter().all(|g| *g == first),
"label gaps drift for n={n} label_len={label_len}: {gaps:?}\n{out}"
);
}
}
}
}