diff --git a/ext/crates/sseq/src/charting/mod.rs b/ext/crates/sseq/src/charting/mod.rs index 4dde744944..2b0d0821dc 100644 --- a/ext/crates/sseq/src/charting/mod.rs +++ b/ext/crates/sseq/src/charting/mod.rs @@ -2,9 +2,11 @@ use std::fmt::Display; use crate::coordinates::{Bidegree, BidegreeGenerator}; +pub mod seqsee; pub mod svg; pub mod tikz; +pub use seqsee::SeqSeeBackend; pub use svg::SvgBackend; pub use tikz::TikzBackend; diff --git a/ext/crates/sseq/src/charting/seqsee.rs b/ext/crates/sseq/src/charting/seqsee.rs new file mode 100644 index 0000000000..6d530c296f --- /dev/null +++ b/ext/crates/sseq/src/charting/seqsee.rs @@ -0,0 +1,234 @@ +use std::{collections::BTreeSet, fmt::Display, io}; + +use serde_json::{Map, Value, json}; + +use crate::{ + charting::{Backend, Orientation}, + coordinates::{Bidegree, BidegreeGenerator}, +}; + +/// A [`Backend`] that emits [SeqSee](https://github.com/JoeyBF/SeqSee) JSON. +/// +/// SeqSee is a generic visualization tool for spectral sequences. It consumes a JSON file +/// conforming to its [schema] and produces a self-contained, interactive HTML chart. This backend +/// targets that schema so that the charts produced elsewhere in this crate can be rendered by +/// SeqSee. +/// +/// The document is assembled in memory and written out when the backend is dropped, since the JSON +/// object must group all nodes together and all edges together, whereas the [`Backend`] callbacks +/// interleave them. +/// +/// [schema]: https://raw.githubusercontent.com/JoeyBF/SeqSee/refs/heads/master/seqsee/input_schema.json +pub struct SeqSeeBackend { + out: T, + max: Bidegree, + /// Map from node id to its SeqSee node object. + nodes: Map, + /// The list of SeqSee edge objects. + edges: Vec, + /// The set of style names referenced by edges. Each becomes an attribute alias in the header so + /// that the edges referencing it validate against the schema. + styles: BTreeSet, +} + +impl SeqSeeBackend { + pub fn new(out: T) -> Self { + Self { + out, + max: Bidegree::zero(), + nodes: Map::new(), + edges: Vec::new(), + styles: BTreeSet::new(), + } + } +} + +/// Whether a style name denotes a differential, i.e. it is `d` followed by a page number. +/// +/// Differentials are given a distinct color to match the other backends in this crate. +fn is_differential(style: &str) -> bool { + style + .strip_prefix('d') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +impl Backend for SeqSeeBackend { + type Error = io::Error; + + const EXT: &'static str = "json"; + + fn header(&mut self, max: Bidegree) -> Result<(), Self::Error> { + self.max = max; + Ok(()) + } + + // SeqSee draws its own grid based on the chart dimensions in the header, so there is nothing to + // emit for the grid lines. + fn line(&mut self, _start: Bidegree, _end: Bidegree, _style: &str) -> Result<(), Self::Error> { + Ok(()) + } + + // SeqSee draws its own axis labels, so there is nothing to emit here. + fn text( + &mut self, + _b: Bidegree, + _content: impl Display, + _orientation: Orientation, + ) -> Result<(), Self::Error> { + Ok(()) + } + + fn node(&mut self, b: Bidegree, n: usize) -> Result<(), Self::Error> { + if n == 0 || b.x() > self.max.x() || b.y() > self.max.y() { + return Ok(()); + } + + for k in 0..n { + // The `{:#}` (alternate) form of the `Display` impl is `(x,y,idx)`. Reusing it here and + // in `structline` keeps node ids and edge endpoints in sync. + let id = format!("{:#}", BidegreeGenerator::new(b, k)); + self.nodes.insert( + id, + json!({ + "x": b.x(), + "y": b.y(), + "position": k, + }), + ); + } + Ok(()) + } + + fn structline( + &mut self, + source: BidegreeGenerator, + target: BidegreeGenerator, + style: Option<&str>, + ) -> Result<(), Self::Error> { + if source.x() > self.max.x() + || source.y() > self.max.y() + || target.x() > self.max.x() + || target.y() > self.max.y() + { + return Ok(()); + } + + let mut edge = Map::new(); + edge.insert("source".to_string(), Value::String(format!("{source:#}"))); + edge.insert("target".to_string(), Value::String(format!("{target:#}"))); + if let Some(style) = style { + self.styles.insert(style.to_string()); + edge.insert("attributes".to_string(), json!([style])); + } + self.edges.push(Value::Object(edge)); + Ok(()) + } +} + +impl Drop for SeqSeeBackend { + fn drop(&mut self) { + // Register every referenced style as an attribute alias so that the edges validate. + let mut attributes = Map::new(); + for style in &self.styles { + let attr = if is_differential(style) { + json!([{ "color": "blue" }]) + } else { + json!([]) + }; + attributes.insert(style.clone(), attr); + } + + let document = json!({ + "header": { + "chart": { + "width": { "min": 0, "max": self.max.x() }, + "height": { "min": 0, "max": self.max.y() }, + }, + "aliases": { + "attributes": attributes, + }, + }, + "nodes": std::mem::take(&mut self.nodes), + "edges": std::mem::take(&mut self.edges), + }); + + let _ = serde_json::to_writer_pretty(&mut self.out, &document); + let _ = writeln!(self.out); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_seqsee_output() { + let mut buf: Vec = Vec::new(); + { + let mut backend = SeqSeeBackend::new(&mut buf); + // `init` draws the grid and axis labels via `line`/`text`, which are no-ops here. + backend.init(Bidegree::x_y(2, 2)).unwrap(); + + backend.node(Bidegree::x_y(0, 0), 1).unwrap(); + backend.node(Bidegree::x_y(0, 1), 1).unwrap(); + backend.node(Bidegree::x_y(1, 1), 1).unwrap(); + + // A multiplication by `h0` (a filtration-one product). + backend + .structline( + BidegreeGenerator::new(Bidegree::x_y(0, 0), 0), + BidegreeGenerator::new(Bidegree::x_y(0, 1), 0), + Some("h0"), + ) + .unwrap(); + // A `d2` differential. + backend + .structline( + BidegreeGenerator::new(Bidegree::x_y(1, 1), 0), + BidegreeGenerator::new(Bidegree::x_y(0, 1), 0), + Some("d2"), + ) + .unwrap(); + // Out of bounds: dropped silently. + backend.node(Bidegree::x_y(5, 5), 1).unwrap(); + } + + // Compare parsed values rather than the raw string so the assertion does not depend on + // JSON object key ordering, which varies with whether serde_json's `preserve_order` + // feature is enabled by feature unification in the surrounding build. + let produced: Value = serde_json::from_slice(&buf).unwrap(); + let expected = json!({ + "header": { + "chart": { + "width": { "min": 0, "max": 2 }, + "height": { "min": 0, "max": 2 }, + }, + "aliases": { + "attributes": { + "h0": [], + "d2": [{ "color": "blue" }], + }, + }, + }, + "nodes": { + "(0,0,0)": { "x": 0, "y": 0, "position": 0 }, + "(0,1,0)": { "x": 0, "y": 1, "position": 0 }, + "(1,1,0)": { "x": 1, "y": 1, "position": 0 }, + }, + "edges": [ + { "source": "(0,0,0)", "target": "(0,1,0)", "attributes": ["h0"] }, + { "source": "(1,1,0)", "target": "(0,1,0)", "attributes": ["d2"] }, + ], + }); + assert_eq!(produced, expected); + } + + #[test] + fn test_is_differential() { + assert!(is_differential("d2")); + assert!(is_differential("d17")); + assert!(!is_differential("d")); + assert!(!is_differential("h0")); + assert!(!is_differential("delta")); + } +} diff --git a/ext/examples/chart.rs b/ext/examples/chart.rs index 5cb02c9258..7b55bffcb4 100644 --- a/ext/examples/chart.rs +++ b/ext/examples/chart.rs @@ -3,13 +3,20 @@ use ext::{ chain_complex::{ChainComplex, FreeChainComplex}, utils::query_module, }; -use sseq::charting::SvgBackend; +use sseq::charting::{SeqSeeBackend, SvgBackend, TikzBackend}; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; let resolution = query_module(None, false)?; + let format = query::with_default("Output format (svg/tikz/seqsee)", "svg", |x| match x { + "svg" | "tikz" | "seqsee" => Ok(x.to_string()), + _ => Err(format!( + "unknown format '{x}'; expected one of svg, tikz, seqsee" + )), + }); + let sseq = resolution.to_sseq(); let products: Vec<_> = resolution .algebra() @@ -18,12 +25,20 @@ fn main() -> anyhow::Result<()> { .map(|(name, op_deg, op_idx)| (name, resolution.filtration_one_products(op_deg, op_idx))) .collect(); - sseq.write_to_graph( - SvgBackend::new(std::io::stdout()), - 2, - false, - products.iter(), - |_| Ok(()), - )?; + let out = std::io::stdout(); + match format.as_str() { + "svg" => { + sseq.write_to_graph(SvgBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + } + "tikz" => { + sseq.write_to_graph(TikzBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + } + "seqsee" => { + sseq.write_to_graph(SeqSeeBackend::new(out), 2, false, products.iter(), |_| { + Ok(()) + })? + } + _ => unreachable!(), + } Ok(()) }