use unicode_width::UnicodeWidthStr;
use crate::quadrant_chart::{QuadrantChart, QuadrantPoint};
const DEFAULT_WIDTH: usize = 70;
const MIN_WIDTH: usize = 30;
const CANVAS_ROWS: usize = 20;
pub fn render(diag: &QuadrantChart, max_width: Option<usize>) -> String {
let canvas_width = max_width.map(|w| w.max(MIN_WIDTH)).unwrap_or(DEFAULT_WIDTH);
let center_col = canvas_width / 2;
let mut out = String::new();
if let Some(title) = &diag.title {
let title_w = UnicodeWidthStr::width(title.as_str());
let pad = center_col.saturating_sub(title_w / 2);
for _ in 0..pad {
out.push(' ');
}
out.push_str(title);
out.push('\n');
out.push('\n');
}
if let Some(y_ax) = &diag.y_axis {
let label = &y_ax.high;
let lw = UnicodeWidthStr::width(label.as_str());
let pad = center_col.saturating_sub(lw / 2);
for _ in 0..pad {
out.push(' ');
}
out.push_str(label);
out.push('\n');
}
let rows = CANVAS_ROWS;
let mid_row = rows / 2;
let mut grid: Vec<Vec<char>> = vec![vec![' '; canvas_width]; rows + 2];
grid[0][center_col] = '^';
for row in grid.iter_mut().take(rows + 1).skip(1) {
row[center_col] = '\u{2502}'; }
grid[rows + 1][center_col] = 'v';
let x_axis_grid_row = mid_row + 1;
for (c, cell) in grid[x_axis_grid_row]
.iter_mut()
.enumerate()
.take(canvas_width)
{
if c != center_col {
*cell = '\u{2500}'; }
}
grid[x_axis_grid_row][center_col] = '\u{253C}';
let label_row_top = 1usize;
let label_row_bot = rows;
if let Some(q1) = &diag.quadrants.q1 {
place_text(&mut grid, label_row_top, center_col + 2, q1, canvas_width);
}
if let Some(q2) = &diag.quadrants.q2 {
place_text_right_aligned(&mut grid, label_row_top, center_col.saturating_sub(2), q2);
}
if let Some(q3) = &diag.quadrants.q3 {
place_text_right_aligned(&mut grid, label_row_bot, center_col.saturating_sub(2), q3);
}
if let Some(q4) = &diag.quadrants.q4 {
place_text(&mut grid, label_row_bot, center_col + 2, q4, canvas_width);
}
let left_cols = center_col.saturating_sub(1); let right_cols = canvas_width.saturating_sub(center_col + 2); let top_rows = mid_row.saturating_sub(1); let bot_rows = rows.saturating_sub(mid_row + 1);
let mut point_positions: Vec<(usize, usize, usize)> = diag
.points
.iter()
.enumerate()
.map(|(i, pt)| {
let (col, grid_row) = point_to_grid(
pt, center_col, left_cols, right_cols, mid_row, top_rows, bot_rows, rows,
);
(i, col, grid_row)
})
.filter(|&(_, col, grid_row)| !(grid_row == 0 || grid_row > rows || col >= canvas_width))
.collect();
for &(_, col, grid_row) in &point_positions {
grid[grid_row][col] = '\u{00B7}'; }
point_positions.sort_by(|a, b| {
let xa = diag.points[a.0].x;
let xb = diag.points[b.0].x;
xb.partial_cmp(&xa).unwrap_or(std::cmp::Ordering::Equal)
});
for &(i, col, grid_row) in &point_positions {
let pt = &diag.points[i];
let label = format!(" {} ({:.2},{:.2})", pt.name, pt.x, pt.y);
let label_width = label.chars().count(); let right_start = col + 1;
let right_fits = right_start + label_width <= canvas_width;
if right_fits && region_is_clear(&grid, grid_row, right_start, label_width) {
place_text(&mut grid, grid_row, right_start, &label, canvas_width);
} else if label_width < col
&& region_is_clear(&grid, grid_row, col - label_width, label_width)
{
let left_start = col - label_width;
place_text(&mut grid, grid_row, left_start, &label, canvas_width);
} else {
let budget = canvas_width.saturating_sub(right_start);
if budget > 1 {
let truncated: String = label.chars().take(budget - 1).collect();
let with_ellipsis = format!("{truncated}\u{2026}");
place_text(
&mut grid,
grid_row,
right_start,
&with_ellipsis,
canvas_width,
);
}
}
}
let total_grid_rows = rows + 2;
if let Some(x_ax) = &diag.x_axis {
if !x_ax.low.is_empty() {
place_text(&mut grid, x_axis_grid_row, 0, &x_ax.low, canvas_width);
}
if !x_ax.high.is_empty() {
let hw = UnicodeWidthStr::width(x_ax.high.as_str());
let start_col = canvas_width.saturating_sub(hw);
place_text(
&mut grid,
x_axis_grid_row,
start_col,
&x_ax.high,
canvas_width,
);
}
}
for row in grid.iter().take(total_grid_rows) {
let row_str: String = row.iter().collect();
let trimmed = row_str.trim_end();
out.push_str(trimmed);
out.push('\n');
}
if let Some(y_ax) = &diag.y_axis {
let label = &y_ax.low;
let lw = UnicodeWidthStr::width(label.as_str());
let pad = center_col.saturating_sub(lw / 2);
for _ in 0..pad {
out.push(' ');
}
out.push_str(label);
out.push('\n');
}
while out.ends_with('\n') {
out.pop();
}
out
}
#[allow(clippy::too_many_arguments)]
fn point_to_grid(
pt: &QuadrantPoint,
center_col: usize,
left_cols: usize,
right_cols: usize,
mid_row: usize,
top_rows: usize,
bot_rows: usize,
rows: usize,
) -> (usize, usize) {
let col = if pt.x < 0.5 {
let frac = pt.x / 0.5; (frac * left_cols as f64) as usize
} else {
let frac = (pt.x - 0.5) / 0.5; center_col + 2 + (frac * right_cols.saturating_sub(1) as f64) as usize
};
let grid_row = if pt.y >= 0.5 {
let frac = (1.0 - pt.y) / 0.5; let interior_rows = top_rows.saturating_sub(1); 2 + (frac * interior_rows.saturating_sub(1) as f64) as usize
} else {
let frac = (0.5 - pt.y) / 0.5; let interior_rows = bot_rows.saturating_sub(1);
let interior_start = mid_row + 2; interior_start + (frac * interior_rows.saturating_sub(1) as f64) as usize
};
let safe_row = grid_row
.max(2) .min(rows - 1); let safe_row = if safe_row == mid_row + 1 && safe_row > 2 {
safe_row - 1
} else if safe_row == mid_row + 1 && safe_row < rows - 1 {
safe_row + 1
} else {
safe_row
};
(col, safe_row)
}
fn region_is_clear(grid: &[Vec<char>], row: usize, start_col: usize, len: usize) -> bool {
const STRUCTURE_CHARS: &[char] = &[
' ', '\u{2502}', '\u{2500}', '\u{253C}', '^', 'v', '\u{00B7}', ];
let row_data = &grid[row];
let end = (start_col + len).min(row_data.len());
row_data[start_col..end]
.iter()
.all(|c| STRUCTURE_CHARS.contains(c))
}
fn place_text(grid: &mut [Vec<char>], row: usize, start_col: usize, text: &str, max_col: usize) {
let row_len = grid[row].len();
let limit = max_col.min(row_len);
for (col, ch) in (start_col..).zip(text.chars()) {
if col >= limit {
break;
}
grid[row][col] = ch;
}
}
fn place_text_right_aligned(grid: &mut [Vec<char>], row: usize, end_col: usize, text: &str) {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
if len == 0 {
return;
}
let start_col = end_col.saturating_sub(len);
let row_len = grid[row].len();
for (i, &ch) in chars.iter().enumerate() {
let col = start_col + i;
if col < row_len {
grid[row][col] = ch;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::quadrant_chart::parse;
fn canonical_src() -> &'static str {
"quadrantChart
title Reach and engagement of campaigns
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 We should expand
quadrant-2 Need to promote
quadrant-3 Re-evaluate
quadrant-4 May be improved
Campaign A: [0.3, 0.6]
Campaign B: [0.45, 0.23]
Campaign C: [0.57, 0.69]
Campaign D: [0.78, 0.34]
Campaign E: [0.40, 0.34]
Campaign F: [0.35, 0.78]"
}
#[test]
fn title_appears_in_output() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(
out.contains("Reach and engagement of campaigns"),
"title missing from output:\n{out}"
);
}
#[test]
fn quadrant_labels_appear_in_correct_corners() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
assert!(out.contains("We should expand"), "Q1 label missing:\n{out}");
assert!(out.contains("Need to promote"), "Q2 label missing:\n{out}");
assert!(out.contains("Re-evaluate"), "Q3 label missing:\n{out}");
assert!(out.contains("May be improved"), "Q4 label missing:\n{out}");
let q1_line = out
.lines()
.find(|l| l.contains("We should expand"))
.expect("Q1 line missing");
let q2_line = out
.lines()
.find(|l| l.contains("Need to promote"))
.expect("Q2 line missing");
assert_eq!(
q1_line, q2_line,
"Q1 and Q2 labels should be on the same line"
);
let q3_line = out
.lines()
.find(|l| l.contains("Re-evaluate"))
.expect("Q3 line missing");
let q4_line = out
.lines()
.find(|l| l.contains("May be improved"))
.expect("Q4 line missing");
assert_eq!(
q3_line, q4_line,
"Q3 and Q4 labels should be on the same line"
);
let q1_line_no = out
.lines()
.position(|l| l.contains("We should expand"))
.unwrap();
let q3_line_no = out.lines().position(|l| l.contains("Re-evaluate")).unwrap();
assert!(
q1_line_no < q3_line_no,
"top quadrant labels ({q1_line_no}) must precede bottom ({q3_line_no})"
);
}
#[test]
fn points_render_inside_canvas() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, Some(80));
for name in &["Campaign A", "Campaign B", "Campaign C", "Campaign D"] {
assert!(
out.contains(name),
"point {name:?} missing from output:\n{out}"
);
}
assert!(out.contains('\u{253C}'), "cross glyph ┼ missing:\n{out}");
}
#[test]
fn axis_labels_appear_on_outer_edges() {
let chart = parse(canonical_src()).unwrap();
let out = render(&chart, None);
let x_axis_line = out
.lines()
.find(|l| l.contains('\u{253C}'))
.expect("x-axis line with ┼ not found");
assert!(
x_axis_line.contains("Low Reach") || out.contains("Low Reach"),
"Low Reach axis label missing"
);
assert!(
x_axis_line.contains("High Reach") || out.contains("High Reach"),
"High Reach axis label missing"
);
assert!(
out.contains("High Engagement"),
"High Engagement y-axis label missing"
);
assert!(
out.contains("Low Engagement"),
"Low Engagement y-axis label missing"
);
let high_eng_line = out
.lines()
.position(|l| l.contains("High Engagement"))
.expect("High Engagement line not found");
let arrow_line = out
.lines()
.position(|l| l.contains('^'))
.expect("^ arrow line not found");
assert!(
high_eng_line < arrow_line,
"High Engagement ({high_eng_line}) must precede ^ arrow ({arrow_line})"
);
}
#[test]
fn empty_chart_renders_without_panic() {
let chart = QuadrantChart::default();
let out = render(&chart, None);
assert!(out.contains('\u{253C}') || out.contains('\u{2502}') || out.contains('^'));
}
}