]> pintos-os.org Git - pspp/commitdiff
Initial support for style cycles
authorBen Pfaff <blp@cs.stanford.edu>
Wed, 6 May 2026 19:47:40 +0000 (12:47 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Wed, 6 May 2026 19:47:40 +0000 (12:47 -0700)
rust/pspp/src/spv/read/graph.rs
rust/pspp/src/spv/read/graph/style.rs
rust/pspp/src/spv/testdata/graph1.expected.svg

index fd2c73746e43aa96d4656d286a32278543322260..c06222b9462d4fa65ec44be58e2b1e907627359f 100644 (file)
@@ -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<DataSource>,
         warn: &mut dyn FnMut(GraphWarning),
     ) -> Result<svg::Document, GraphError> {
-        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<svg::node::element::Group, GraphError> {
         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 = <usize as NumCast>::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 = <usize as NumCast>::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<String>,
+
+    #[serde(rename = "@styleCycle")]
+    style_cycle: Option<String>,
 }
 
 #[derive(Deserialize, Debug)]
index 9140a21c162fb3cba9049f38b4efd2a10293c58a..9781997879f195594f62aaa89bfed2150948ae8c 100644 (file)
@@ -11,12 +11,18 @@ use crate::{
 #[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 pub struct StyleCycle {
+    #[serde(rename = "@id")]
+    pub id: Option<String>,
+
     #[serde(default, rename = "cycle")]
     cycles: Vec<Cycle>,
 }
 
 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<Style> = None;
         for cycle in &self.cycles {
             let len = cycle.styles.len();
@@ -28,6 +34,15 @@ impl StyleCycle {
         }
         style.unwrap_or_default()
     }
+
+    /// Returns the length of the cycle, that is, the number of styles before
+    /// the cycle repeats.
+    pub fn len(&self) -> usize {
+        self.cycles
+            .iter()
+            .map(|cycle| cycle.styles.len().max(1))
+            .product()
+    }
 }
 
 #[derive(Clone, Deserialize, Debug)]
index 1d7a1db288b0fd6b15a057a4890b4374b56e414c..13f5d1ed41c4bee025abef34d5868c938a9a2a6a 100644 (file)
@@ -65,10 +65,10 @@ X Axis
 Y Axis
 </text>
 <g id="element_simpleline_1">
-<path d="M618.75,51 L590.875,51 L563,51 L563,69.47619 L535.125,69.47619 L535.125,87.95238 L507.25,87.95238 L479.375,87.95238 L479.375,106.42857 L451.5,106.42857 L423.625,106.42857 L395.75,106.42857 L367.875,106.42857 L340,106.42857 L312.125,106.42857 L312.125,124.90476 L284.25,124.90476 L284.25,143.38095 L256.375,143.38095 L228.5,143.38095 L200.625,143.38095 L200.625,161.85715 L200.625,180.33333 L200.625,198.80952 L172.75,198.80952 L172.75,217.28572 L172.75,235.7619 L172.75,254.2381 L172.75,272.7143 L172.75,291.1905 L144.875,291.1905 L144.875,309.66666 L117,309.66666 L89.125,309.66666 L89.125,328.14285 L89.125,346.61905 L89.125,365.09525 L89.125,383.57144 L89.125,402.0476 L89.125,420.5238 L89.125,439 L61.25,439" fill="none" stroke="#000000"/>
+<path d="M618.75,51 L590.875,51 L563,51 L563,69.47619 L535.125,69.47619 L535.125,87.95238 L507.25,87.95238 L479.375,87.95238 L479.375,106.42857 L451.5,106.42857 L423.625,106.42857 L395.75,106.42857 L367.875,106.42857 L340,106.42857 L312.125,106.42857 L312.125,124.90476 L284.25,124.90476 L284.25,143.38095 L256.375,143.38095 L228.5,143.38095 L200.625,143.38095 L200.625,161.85715 L200.625,180.33333 L200.625,198.80952 L172.75,198.80952 L172.75,217.28572 L172.75,235.7619 L172.75,254.2381 L172.75,272.7143 L172.75,291.1905 L144.875,291.1905 L144.875,309.66666 L117,309.66666 L89.125,309.66666 L89.125,328.14285 L89.125,346.61905 L89.125,365.09525 L89.125,383.57144 L89.125,402.0476 L89.125,420.5238 L89.125,439 L61.25,439" fill="none" stroke="#3e58ac"/>
 </g>
 <g id="element_simpleline_1">
-<path d="M61.25,439 L618.75,51" fill="none" stroke="#000000"/>
+<path d="M61.25,439 L618.75,51" fill="none" stroke="#2eb848"/>
 </g>
 </g>
 </svg>