use super::edit::{regenerate, spec_of, target}; use super::series::{switch_xy, transpose}; use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, bool_or, json, str_param}; // ---------- data ---------- /// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value). struct Cell { text: String, quoted: bool, } impl Cell { fn number(&self) -> Option { if self.quoted { None } else { self.text.parse::().ok().filter(|v| v.is_finite()) } } } /// Split `s` at each `sep` outside straight quotes (a `""` inside quotes toggles twice, so stays inside). fn split_unquoted(s: &str, sep: char) -> Vec<&str> { let mut parts = vec![]; let mut quoted = false; let mut start = 0; for (i, ch) in s.char_indices() { if ch == '"' { quoted = !quoted; } else if ch == sep && !quoted { parts.push(s.get(start..i).unwrap_or_default()); start = i + ch.len_utf8(); } } parts.push(s.get(start..).unwrap_or_default()); parts } /// One CSV cell: a cell wholly in quotes keeps its inner text (`""` = `"`), others are trimmed. fn cell(c: &str) -> Cell { let c = c.trim(); match c.strip_prefix('"').and_then(|c| c.strip_suffix('"')) { Some(inner) => Cell { text: inner.replace("\"\"", "\""), quoted: true }, None => Cell { text: c.trim_matches('"').to_string(), quoted: false }, } } pub(super) fn parse_csv(csv: &str) -> (Vec, Vec, Vec>>) { // Records end at line breaks outside quotes; a record is tab-separated if it has a tab outside quotes. let rows: Vec> = split_unquoted(csv, '\n') .into_iter() .map(|l| l.trim_end_matches('\r')) .filter(|l| !l.trim().is_empty()) .map(|l| { let tabs = split_unquoted(l, '\t'); let parts = if tabs.len() > 1 { tabs } else { split_unquoted(l, ',') }; parts.into_iter().map(cell).collect() }) .collect(); let Some(first) = rows.first() else { return (vec![], vec![], vec![]) }; // A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value. let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty()); // A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`). // All-empty series names mean no legend. let empty = |c: &Cell| c.text.is_empty() && !c.quoted; let header = first.first().is_some_and(empty) || first.iter().skip(1).any(label) || (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty)); let mut series: Vec = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] }; if series.iter().all(String::is_empty) { series.clear(); } let data = rows.get(usize::from(header)..).unwrap_or_default(); // The first column holds the category labels if any row starts with one; then it does for every row, so a // row with an empty label doesn't shift its values one place left. let labelled = data.iter().any(|r| r.first().is_some_and(label)); let mut categories = vec![]; let mut values = vec![]; for r in data { if labelled { categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default()); } values.push(r.iter().skip(labelled as usize).map(Cell::number).collect()); } (series, categories, values) } pub(super) fn to_csv(g: &GraphSpec) -> String { g.to_csv() } /// Apply CSV graph data to the graph root `id` and rebuild its art (Variables data merge), as `graph.setData` with /// `csv` does. Data the graph already holds leaves the graph as it is. pub(crate) fn apply_csv(d: &mut Document, id: NodeId, csv: &str) -> Result<()> { let Some(node) = d.node(id) else { return Err(EngineError::NoNode(id)); }; let Some(current) = node.extras().graph.as_deref() else { return Err(bad("graph.applyCsv", format!("node {} is not a graph", id.0))); }; let mut spec = current.clone(); apply_data(&mut spec, &json!({ "csv": csv })); keep_right_series(&mut spec); if spec == *current { return Ok(()); } regenerate(d, id, spec) } /// Drop right-axis series the data no longer has, so a series added later starts on the left. fn keep_right_series(g: &mut GraphSpec) { let count = g.rows.iter().map(Vec::len).max().unwrap_or(0); g.right_series.retain(|i| *i < count); } pub(super) fn apply_data(g: &mut GraphSpec, p: &Value) -> bool { let mut changed = false; if let Some(csv) = str_param(p, "csv") { let (s, c, r) = parse_csv(csv); g.series = s; g.categories = c; g.set_cells(r); changed = true; } let strings = |v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::>()); if let Some(s) = p.get("series").and_then(strings) { g.series = s; changed = true; } if let Some(c) = p.get("categories").and_then(strings) { g.categories = c; changed = true; } if let Some(rows) = p.get("rows").and_then(Value::as_array) { // `null` (or anything that isn't a finite number) is a blank cell. g.set_cells( rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(), ); changed = true; } drop_empty_rows(g); changed } /// Drop rows with neither a value nor a label; a label-only row stays, its values blank. Each kept row keeps its own /// width, so a dropped row wider than the rest adds no blank series. fn drop_empty_rows(g: &mut GraphSpec) { let blank: std::collections::HashSet<[usize; 2]> = g.blanks.iter().copied().collect(); let mut cells: Vec>> = g .rows .iter() .take(vectorcraft_doc::MAX_GRAPH_CATEGORIES) .enumerate() .map(|(c, r)| r.iter().take(vectorcraft_doc::MAX_GRAPH_SERIES).enumerate().map(|(s, v)| (!blank.contains(&[c, s])).then_some(*v)).collect()) .collect(); let keep: Vec = (0..cells.len()) .map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty())) .collect(); if keep.contains(&false) || cells.len() < g.rows.len() { let mut k = keep.iter(); cells.retain(|_| k.next().copied().unwrap_or(false)); let mut k = keep.iter(); g.categories.retain(|_| k.next().copied().unwrap_or(true)); g.set_cells(cells); } } pub(super) fn fmt_value(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { // Beyond the i64 range the cast would saturate; every f64 that large is a whole number, so // `{:.0}` prints its exact digits. if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") } } else { format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string() } } pub(super) fn set_data(s: &mut Session, p: &Value) -> Result { const C: &str = "graph.setData"; let id = target(s, p, C)?; let mut spec = spec_of(s, id)?; let flip = bool_or(p, "transpose", false); let swap = bool_or(p, "switchXY", false); if swap && spec.kind != GraphKind::Scatter { return Err(bad(C, "Switch x/y is for scatter graphs")); } // Transpose and Switch x/y act after any new data in the same call. let changed = apply_data(&mut spec, p); if flip { // The categories become series: at most as many as a graph has series. if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES { return Err(bad( C, format!("a graph has at most {} series, so it transposes with at most that many categories", vectorcraft_doc::MAX_GRAPH_SERIES), )); } transpose(&mut spec); drop_empty_rows(&mut spec); } if swap { switch_xy(&mut spec); } if !(changed || flip || swap) { // Blank cells come back as null, so the rows can be edited and sent back as they are. return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() })); } keep_right_series(&mut spec); s.edit("Graph Data", |d, _| regenerate(d, id, spec))?; Ok(json!({ "id": id.0 })) }