use super::{find_header, first_word, read_quoted, Envelope, ParseError, Preamble, TitleSyntax};
pub const KEYWORDS: &[&str] = &["treemap-beta", "treemap"];
pub const KEYWORD: &str = "treemap";
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub name: String,
pub value: Option<f64>,
pub class: Option<String>,
pub children: Vec<Node>,
}
impl Node {
pub fn total(&self) -> f64 {
match self.value {
Some(v) => v,
None => self.children.iter().map(Node::total).sum(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Treemap {
pub preamble: Preamble,
pub roots: Vec<Node>,
pub class_defs: Vec<(String, String)>,
}
pub fn is_treemap(src: &str) -> bool {
find_header(src, KEYWORDS).is_some()
}
pub fn parse(src: &str) -> Result<Treemap, ParseError> {
let Some(source) = find_header(src, KEYWORDS) else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut tm = Treemap::default();
let mut env = Envelope::new(source.front_matter_title);
let mut flat: Vec<(usize, Node)> = Vec::new();
let mut feed: Vec<(usize, String)> = Vec::new();
if !source.header_rest.is_empty() {
feed.push((source.header_index + 1, source.header_rest.clone()));
}
for (i, line) in source
.lines
.iter()
.enumerate()
.skip(source.header_index + 1)
{
feed.push((i + 1, line.clone()));
}
for (number, line) in feed {
if line.trim().is_empty() {
continue;
}
if env.read(&line, TitleSyntax::Terminal) {
continue;
}
let indent = line.chars().take_while(|c| *c == ' ' || *c == '\t').count();
let t = line.trim();
if let Some(rest) = super::strip_ci(t, "classDef") {
let rest = rest.trim().trim_end_matches(';');
let (name, styles) = match rest.split_once(char::is_whitespace) {
Some((n, s)) => (n.trim().to_string(), s.trim().to_string()),
None => (rest.to_string(), String::new()),
};
if name.is_empty() {
return Err(ParseError::Invalid {
line: number,
message: "classDef needs a name".to_string(),
});
}
tm.class_defs.push((name, styles));
continue;
}
let chars: Vec<char> = t.chars().collect();
let Some((name, used)) = read_quoted(&chars, 0) else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
};
let consumed: usize = chars[..used].iter().map(|c| c.len_utf8()).sum();
let mut rest = t[consumed..].trim();
let mut class = None;
if let Some((before, after)) = rest.split_once(":::") {
class = Some(after.trim().to_string());
rest = before.trim();
}
let node = if rest.is_empty() {
Node {
name,
value: None,
class,
children: Vec::new(),
}
} else {
let Some(value_text) = rest.strip_prefix([':', ',']) else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: rest.to_string(),
});
};
let value_text = value_text.trim();
let Some(value) = number_with_commas(value_text) else {
return Err(ParseError::BadNumber {
line: number,
text: value_text.to_string(),
why: "a leaf's value must be a number",
});
};
Node {
name,
value: Some(value),
class,
children: Vec::new(),
}
};
flat.push((indent, node));
}
tm.preamble = env.preamble;
tm.roots = build_hierarchy(flat);
if tm.roots.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "node",
});
}
if tm.roots.iter().map(Node::total).sum::<f64>() <= 0.0 {
return Err(ParseError::Invalid {
line: 0,
message: "every value is zero, so no tile has any area".to_string(),
});
}
Ok(tm)
}
fn build_hierarchy(flat: Vec<(usize, Node)>) -> Vec<Node> {
let mut arena: Vec<Node> = Vec::with_capacity(flat.len());
let mut children: Vec<Vec<usize>> = Vec::with_capacity(flat.len());
let mut roots: Vec<usize> = Vec::new();
let mut stack: Vec<(usize, usize)> = Vec::new();
for (indent, node) in flat {
let is_leaf = node.value.is_some();
arena.push(node);
children.push(Vec::new());
let me = arena.len() - 1;
while stack.last().is_some_and(|(_, level)| *level >= indent) {
stack.pop();
}
match stack.last() {
Some((parent, _)) => children[*parent].push(me),
None => roots.push(me),
}
if !is_leaf {
stack.push((me, indent));
}
}
fn build(i: usize, arena: &[Node], children: &[Vec<usize>]) -> Node {
let mut node = arena[i].clone();
node.children = children[i]
.iter()
.map(|c| build(*c, arena, children))
.collect();
node
}
roots.iter().map(|r| build(*r, &arena, &children)).collect()
}
fn number_with_commas(text: &str) -> Option<f64> {
if text.is_empty()
|| !text
.bytes()
.all(|b| b.is_ascii_digit() || b == b'.' || b == b',')
{
return None;
}
let cleaned: String = text.chars().filter(|c| *c != ',').collect();
super::parse_number(&cleaned, false)
}