use unicode_width::UnicodeWidthStr;
use crate::sankey::Sankey;
const DEFAULT_WIDTH: usize = 80;
const MIN_WIDTH: usize = 20;
const BAR_MAX_CELLS: usize = 33;
const EIGHTH_GLYPHS: [char; 8] = [
' ', '\u{258F}', '\u{258E}', '\u{258D}', '\u{258C}', '\u{258B}', '\u{258A}', '\u{2589}', ];
const FULL_BLOCK: char = '\u{2588}';
const ARROW_HEAD: char = '\u{25BA}';
pub fn render(diag: &Sankey, max_width: Option<usize>) -> String {
let width = max_width.map(|w| w.max(MIN_WIDTH)).unwrap_or(DEFAULT_WIDTH);
if diag.flows.is_empty() {
return "(empty sankey diagram)".to_string();
}
let mut sources: Vec<String> = Vec::new();
let mut outgoing: std::collections::HashMap<String, Vec<(String, f64)>> =
std::collections::HashMap::new();
for flow in &diag.flows {
if !sources.contains(&flow.source) {
sources.push(flow.source.clone());
}
outgoing
.entry(flow.source.clone())
.or_default()
.push((flow.target.clone(), flow.value));
}
let global_max = diag
.flows
.iter()
.map(|f| f.value)
.fold(0.0_f64, f64::max);
let max_val_len = diag
.flows
.iter()
.map(|f| format!("{:.1}", f.value).len())
.max()
.unwrap_or(1);
let mut out = String::new();
let mut first_source = true;
for source in &sources {
if !first_source {
out.push('\n');
}
first_source = false;
let total: f64 = outgoing
.get(source)
.map(|arcs| arcs.iter().map(|(_, v)| v).sum())
.unwrap_or(0.0);
let header_text = format!("{source} (total: {total:.1})");
let header = truncate_to_width(&header_text, width);
out.push_str(&header);
out.push('\n');
let arcs = outgoing.get(source).map(Vec::as_slice).unwrap_or(&[]);
for (target, value) in arcs {
let arc_line =
format_arc(target, *value, max_val_len, global_max, BAR_MAX_CELLS, width);
out.push_str(&arc_line);
out.push('\n');
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
pub fn bar_eighths(value: f64, max_value: f64, max_cells: usize) -> usize {
if max_value <= 0.0 || value <= 0.0 {
return 0;
}
let eighths = (value / max_value * (max_cells * 8) as f64) as usize;
eighths.min(max_cells * 8)
}
pub fn proportional_bar(value: f64, max_value: f64, max_cells: usize) -> String {
let eighths = bar_eighths(value, max_value, max_cells);
let full = eighths / 8;
let partial = eighths % 8;
let mut bar = String::with_capacity(full + if partial > 0 { 1 } else { 0 });
for _ in 0..full {
bar.push(FULL_BLOCK);
}
if partial > 0 {
bar.push(EIGHTH_GLYPHS[partial]);
}
bar
}
fn format_arc(
target: &str,
value: f64,
max_val_len: usize,
global_max: f64,
max_cells: usize,
max_width: usize,
) -> String {
const INDENT: &str = " ";
let fixed_overhead = 2 + 1 + 1 + max_val_len + 1 + 3;
let effective_cells = max_cells.min(max_width.saturating_sub(fixed_overhead));
let bar_raw = proportional_bar(value, global_max, effective_cells);
let bar_raw_w = UnicodeWidthStr::width(bar_raw.as_str());
let bar_pad = " ".repeat(effective_cells.saturating_sub(bar_raw_w));
let bar_col = format!("{bar_raw}{bar_pad}");
let value_str = format!("{value:.1}");
let pad = max_val_len.saturating_sub(value_str.len());
let value_col = format!("[{}{value_str}]", " ".repeat(pad));
let prefix = format!("{INDENT}{bar_col} {value_col} {ARROW_HEAD} ");
let prefix_w = UnicodeWidthStr::width(prefix.as_str());
let remaining = max_width.saturating_sub(prefix_w);
let target_truncated = truncate_to_width(target, remaining);
format!("{prefix}{target_truncated}")
}
fn truncate_to_width(s: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
let total = UnicodeWidthStr::width(s);
if total <= max_cols {
return s.to_string();
}
let budget = max_cols.saturating_sub(1);
let mut result = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > budget {
break;
}
result.push(ch);
used += cw;
}
result.push('\u{2026}'); result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::sankey::parse;
fn canonical_src() -> &'static str {
"sankey-beta
%% source,target,value
Agricultural 'waste',Bio-conversion,124.729
Bio-conversion,Liquid,0.597
Bio-conversion,Solid,280.322
Coal imports,Coal,11.606
Coal,Solid,75.571"
}
#[test]
fn source_nodes_appear_as_headers() {
let diag = parse(canonical_src()).unwrap();
let out = render(&diag, None);
assert!(
out.contains("Bio-conversion"),
"Bio-conversion header missing:\n{out}"
);
assert!(
out.contains("Coal imports"),
"Coal imports header missing:\n{out}"
);
assert!(out.contains("Coal"), "Coal header missing:\n{out}");
}
#[test]
fn arrow_glyphs_present() {
let diag = parse(canonical_src()).unwrap();
let out = render(&diag, None);
assert!(
out.contains(ARROW_HEAD),
"arrowhead glyph missing:\n{out}"
);
}
#[test]
fn all_target_names_appear_in_output() {
let diag = parse(canonical_src()).unwrap();
let out = render(&diag, None);
for name in &["Liquid", "Solid", "Coal"] {
assert!(
out.contains(name),
"target {name:?} missing from output:\n{out}"
);
}
}
#[test]
fn values_appear_in_output() {
let diag = parse(canonical_src()).unwrap();
let out = render(&diag, None);
assert!(
out.contains("124.7"),
"124.7 value missing from output:\n{out}"
);
assert!(out.contains("0.6"), "0.6 value missing from output:\n{out}");
assert!(
out.contains("280.3"),
"280.3 value missing from output:\n{out}"
);
}
#[test]
fn empty_sankey_renders_placeholder() {
let diag = Sankey::default();
let out = render(&diag, None);
assert!(out.contains("empty"), "empty placeholder missing:\n{out}");
}
#[test]
fn max_width_truncates_long_names() {
let src = "sankey-beta\nA Very Long Source Node Name That Exceeds Eighty Columns,B,10.0";
let diag = parse(src).unwrap();
let out = render(&diag, Some(40));
for line in out.lines() {
let w = UnicodeWidthStr::width(line);
assert!(w <= 40, "line exceeds max_width=40 (w={w}): {line:?}");
}
}
#[test]
fn single_flow_round_trip() {
let src = "sankey-beta\nSource,Target,42.5";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains("Source"), "source missing");
assert!(out.contains("Target"), "target missing");
assert!(out.contains("42.5"), "value missing");
}
#[test]
fn bar_eighths_boundary_values() {
assert_eq!(bar_eighths(0.0, 100.0, 10), 0, "zero value must map to 0 eighths");
assert_eq!(
bar_eighths(100.0, 100.0, 10),
80,
"max value must map to max_cells * 8 = 80 eighths"
);
assert_eq!(
bar_eighths(50.0, 100.0, 10),
40,
"half of max must map to 40 eighths"
);
assert_eq!(
bar_eighths(13.0, 100.0, 10),
10,
"13/100 of max with 10-cell budget must give 10 eighths"
);
}
#[test]
fn proportional_bar_full_fill_at_max() {
let bar = proportional_bar(100.0, 100.0, 10);
let full_cells: usize = bar.chars().filter(|&c| c == '█').count();
assert_eq!(full_cells, 10, "max-value bar must be exactly 10 full-block glyphs: {bar:?}");
let partial_count: usize = bar.chars().filter(|&c| "▏▎▍▌▋▊▉".contains(c)).count();
assert_eq!(partial_count, 0, "max-value bar must have no partial glyph: {bar:?}");
}
#[test]
fn proportional_bar_zero_is_empty() {
let bar = proportional_bar(0.0, 100.0, 10);
let any_block: usize = bar.chars().filter(|&c| "█▏▎▍▌▋▊▉".contains(c)).count();
assert_eq!(any_block, 0, "zero-value bar must contain no block glyphs: {bar:?}");
}
#[test]
fn proportional_bar_ratio_tracks_value_ratio() {
let src = "sankey-beta\nX,A,200.0\nX,B,80.0";
let diag = parse(src).unwrap();
let out = render(&diag, None);
assert!(out.contains('A'), "target A missing: {out}");
assert!(out.contains('B'), "target B missing: {out}");
assert!(out.contains("200"), "value 200 missing: {out}");
assert!(out.contains("80"), "value 80 missing: {out}");
let line_a = out
.lines()
.find(|l| l.contains("] ► A") || (l.contains("► A") && l.contains('█')))
.expect("flow line for target A (with a bar) not found in output");
let line_b = out
.lines()
.find(|l| l.contains("] ► B") || (l.contains("► B") && l.contains('█')))
.expect("flow line for target B (with a bar) not found in output");
let full_a = line_a.chars().filter(|&c| c == '█').count();
let full_b = line_b.chars().filter(|&c| c == '█').count();
assert!(
full_b > 0,
"flow B bar has no full-block glyphs (B line: {line_b:?})"
);
assert!(
full_a > 0,
"flow A bar has no full-block glyphs (A line: {line_a:?})"
);
let ratio = full_a as f64 / full_b as f64;
assert!(
ratio >= 1.8,
"full-cell ratio A/B = {ratio:.2} < 1.8 (A={full_a}, B={full_b})\nA: {line_a:?}\nB: {line_b:?}"
);
let max_full = full_a.max(full_b);
assert!(
max_full <= 40,
"longest bar has {max_full} full-block glyphs, expected ≤ 40 (scaling must cap it)"
);
assert!(out.contains('█'), "no block glyph at all in output:\n{out}");
}
#[test]
fn source_header_shows_total() {
let src = "sankey-beta\nX,A,200.0\nX,B,80.0";
let diag = parse(src).unwrap();
let out = render(&diag, None);
let header_line = out
.lines()
.find(|l| l.starts_with('X'))
.expect("source header X not found");
assert!(
header_line.contains("280") || header_line.contains("total"),
"source header must show total flow: {header_line:?}"
);
}
}