use unicode_width::UnicodeWidthStr;
use crate::sankey::Sankey;
const DEFAULT_WIDTH: usize = 80;
const MIN_WIDTH: usize = 20;
const SHAFT: &str = "\u{2500}"; const ARROW_HEAD: &str = "\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);
let mut out = String::new();
if diag.flows.is_empty() {
out.push_str("(empty sankey diagram)");
return out;
}
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 max_val_len = diag
.flows
.iter()
.map(|f| format!("{:.1}", f.value).len())
.max()
.unwrap_or(1);
let first = true;
let mut first_source = first;
for source in &sources {
if !first_source {
out.push('\n');
}
first_source = false;
let header = truncate_to_width(source, 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, width);
out.push_str(&arc_line);
out.push('\n');
}
}
while out.ends_with('\n') {
out.pop();
}
out
}
fn format_arc(target: &str, value: f64, max_val_len: usize, max_width: usize) -> String {
const INDENT: &str = " ";
const OPEN_SHAFT: &str = "\u{2500}\u{2500}[";
let value_str = format!("{value:.1}");
let pad = max_val_len.saturating_sub(value_str.len());
let bracket_content = format!("{value_str}{}", " ".repeat(pad));
let suffix = format!("{SHAFT}{SHAFT}{ARROW_HEAD} ");
let prefix = format!("{INDENT}{OPEN_SHAFT}{bracket_content}]{suffix}");
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\n"), "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}");
assert!(out.contains(SHAFT), "shaft 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");
}
}