From 7251a0178f3317e81a16b9ff7679d60c89447dd0 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Wed, 6 May 2026 12:47:40 -0700 Subject: [PATCH] Initial support for style cycles --- rust/pspp/src/spv/read/graph.rs | 56 +++++++++++++++---- rust/pspp/src/spv/read/graph/style.rs | 17 +++++- .../pspp/src/spv/testdata/graph1.expected.svg | 4 +- 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/rust/pspp/src/spv/read/graph.rs b/rust/pspp/src/spv/read/graph.rs index fd2c73746e..c06222b946 100644 --- a/rust/pspp/src/spv/read/graph.rs +++ b/rust/pspp/src/spv/read/graph.rs @@ -388,6 +388,14 @@ impl Visualization { styles } + fn decode_style_cycles(&self) -> HashMap<&str, &StyleCycle> { + self.children + .iter() + .filter_map(|child| child.style_cycle()) + .filter_map(|style_cycle| style_cycle.id.as_deref().map(|s| (s, style_cycle))) + .collect() + } + fn pass<'a>( &'a self, graph: &'a Graph, @@ -486,12 +494,8 @@ impl Visualization { mut csv_data: Option, warn: &mut dyn FnMut(GraphWarning), ) -> Result { - for child in &self.children { - if let Some(style_cycle) = child.style_cycle() { - dbg!(style_cycle); - } - } let _styles = self.decode_styles(); + let style_cycles = self.decode_style_cycles(); for delimited_source in self .children .iter() @@ -598,7 +602,13 @@ impl Visualization { } for (graph_frame, graph) in frames.graphs() { - document.append(self.decode_graph(graph_frame, graph, &domains, &series)?); + document.append(self.decode_graph( + graph_frame, + graph, + &domains, + &series, + &style_cycles, + )?); } Ok(document) @@ -610,6 +620,7 @@ impl Visualization { graph: &Graph, domains: &BTreeMap<&str, Domain>, series: &BTreeMap<&str, Series>, + style_cycles: &HashMap<&str, &StyleCycle>, ) -> Result { let mut graph_svg = Group::new().with_id(&graph.id); let mut bin_widths = BTreeMap::new(); @@ -768,6 +779,7 @@ impl Visualization { Some(categoricals.insert_full(series.name.as_str(), series).0); } } + let mut styling = None; if let Some(style_by) = element.raw.style_by.first() && let Some(variable) = &style_by.variable { @@ -775,7 +787,13 @@ impl Visualization { .get(variable.as_str()) .ok_or_else(|| GraphError::MissingVariable(variable.clone()))?; if series.categorical { - categoricals.insert(series.name.as_str(), series); + let style_by_var_index = + categoricals.insert_full(series.name.as_str(), series).0; + if let Some(style_cycle_id) = style_by.style_cycle.as_deref() + && let Some(style_cycle) = style_cycles.get(style_cycle_id).copied() + { + styling = Some((style_by_var_index, style_cycle)); + } } } let categoricals = categoricals.into_values().collect_vec(); @@ -824,16 +842,27 @@ impl Visualization { .push(values); } for (categoricals, group) in &grouped_data { - let color = if let Some(color_index) = color_index { + let mut color = Color::BLACK; + if let Some(color_index) = color_index { let index = categoricals[color_index] .as_number() .unwrap_or_default() .unwrap_or_default(); let index = ::from(index).unwrap_or_default(); - PALETTE[index % PALETTE.len()] - } else { - Color::BLACK - }; + color = PALETTE[index % PALETTE.len()]; + } + if let Some((index, style_cycle)) = styling { + let index = categoricals[index] + .as_number() + .unwrap_or_default() + .unwrap_or_default(); + let index = ::from(index).unwrap_or_default(); + let style = style_cycle.get_style(index); + if let Some(style_color) = style.color { + color = style_color; + } + } + fn points( rect: &Rect, group: &[Vec<&SeriesValue>], @@ -2881,6 +2910,9 @@ struct ColorAesthetic { struct StyleBy { #[serde(rename = "@variable")] variable: Option, + + #[serde(rename = "@styleCycle")] + style_cycle: Option, } #[derive(Deserialize, Debug)] diff --git a/rust/pspp/src/spv/read/graph/style.rs b/rust/pspp/src/spv/read/graph/style.rs index 9140a21c16..9781997879 100644 --- a/rust/pspp/src/spv/read/graph/style.rs +++ b/rust/pspp/src/spv/read/graph/style.rs @@ -11,12 +11,18 @@ use crate::{ #[derive(Clone, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct StyleCycle { + #[serde(rename = "@id")] + pub id: Option, + #[serde(default, rename = "cycle")] cycles: Vec, } impl StyleCycle { - pub fn get_style(&self, mut index: usize) -> Style { + /// Constructs the style with the given 1-based `index` in this cycle. + pub fn get_style(&self, index: usize) -> Style { + // Treat index 0 as the last style in the cycle. + let mut index = index.checked_sub(1).unwrap_or_else(|| self.len() - 1); let mut style: Option