]> pintos-os.org Git - pspp/commitdiff
better color support
authorBen Pfaff <blp@cs.stanford.edu>
Wed, 6 May 2026 22:52:39 +0000 (15:52 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 7 May 2026 16:26:01 +0000 (09:26 -0700)
rust/pspp/src/spv/read/graph.rs
rust/pspp/src/spv/testdata/graph2.expected.svg

index c06222b9462d4fa65ec44be58e2b1e907627359f..7d05f07393f90b4067cc2d9db384ae1aee00802f 100644 (file)
@@ -403,10 +403,13 @@ impl Visualization {
         series: &BTreeMap<&str, Series>,
         bin_widths: &mut BTreeMap<String, f64>,
         axis_domains: &mut EnumMap<Axis3, AxisDomain<'a>>,
+        styles: &'a HashMap<String, Style>,
     ) -> Result<Vec<Element<'a>>, GraphError> {
         let elements = graph
             .elements()
-            .map(|(raw, graphic)| Element::new(raw, graphic, series, bin_widths, axis_domains))
+            .map(|(raw, graphic)| {
+                Element::new(raw, graphic, series, bin_widths, axis_domains, styles)
+            })
             .collect::<Result<Vec<_>, _>>()?;
         let coordinates = graph
             .coordinates()
@@ -494,7 +497,6 @@ impl Visualization {
         mut csv_data: Option<DataSource>,
         warn: &mut dyn FnMut(GraphWarning),
     ) -> Result<svg::Document, GraphError> {
-        let _styles = self.decode_styles();
         let style_cycles = self.decode_style_cycles();
         for delimited_source in self
             .children
@@ -524,12 +526,7 @@ impl Visualization {
             .map_or(&EN, |lang| Locale::for_lang(lang));
         let series = self.decode_series(locale, data, warn);
         let domains = self.decode_domains(&series, warn);
-        let _styles = self
-            .children
-            .iter()
-            .filter_map(|child| child.style())
-            .filter_map(|style| style.id.as_ref().map(|id| (id.as_str(), style)))
-            .collect::<HashMap<_, _>>();
+        let styles = self.decode_styles();
         let frames = self.decode_frames();
 
         /*
@@ -608,6 +605,7 @@ impl Visualization {
                 &domains,
                 &series,
                 &style_cycles,
+                &styles,
             )?);
         }
 
@@ -621,6 +619,7 @@ impl Visualization {
         domains: &BTreeMap<&str, Domain>,
         series: &BTreeMap<&str, Series>,
         style_cycles: &HashMap<&str, &StyleCycle>,
+        styles: &HashMap<String, Style>,
     ) -> Result<svg::node::element::Group, GraphError> {
         let mut graph_svg = Group::new().with_id(&graph.id);
         let mut bin_widths = BTreeMap::new();
@@ -631,6 +630,7 @@ impl Visualization {
             &series,
             &mut bin_widths,
             &mut axis_domains,
+            styles,
         )?;
         let elements = self.pass(
             &graph,
@@ -638,6 +638,7 @@ impl Visualization {
             &series,
             &mut bin_widths,
             &mut axis_domains,
+            styles,
         )?;
 
         let mut font_metrics = FontMetrics::new();
@@ -768,7 +769,11 @@ impl Visualization {
                 for element in &elements {
                     let mut categoricals = IndexMap::new();
                     let mut color_index = None;
-                    if let Some(color) = element.raw.color.first()
+                    if let Some(color) = element
+                        .raw
+                        .color
+                        .iter()
+                        .find(|color| color.affect.unwrap_or_default().includes_main())
                         && let Some(variable) = &color.variable
                     {
                         let series = series
@@ -779,6 +784,22 @@ impl Visualization {
                                 Some(categoricals.insert_full(series.name.as_str(), series).0);
                         }
                     }
+                    let mut color2_index = None;
+                    if let Some(color2) = element
+                        .raw
+                        .color
+                        .iter()
+                        .find(|color| color.affect.unwrap_or_default().includes_secondary())
+                        && let Some(variable) = &color2.variable
+                    {
+                        let series = series
+                            .get(variable.as_str())
+                            .ok_or_else(|| GraphError::MissingVariable(variable.clone()))?;
+                        if series.categorical {
+                            color2_index =
+                                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
@@ -843,6 +864,9 @@ impl Visualization {
                     }
                     for (categoricals, group) in &grouped_data {
                         let mut color = Color::BLACK;
+                        if let Some(style_color) = element.style.color {
+                            color = style_color;
+                        }
                         if let Some(color_index) = color_index {
                             let index = categoricals[color_index]
                                 .as_number()
@@ -863,6 +887,30 @@ impl Visualization {
                             }
                         }
 
+                        let mut color2 = Color::BLACK;
+                        if let Some(style_color) = element.style.color2 {
+                            color2 = style_color;
+                        }
+                        if let Some(color2_index) = color2_index {
+                            let index = categoricals[color2_index]
+                                .as_number()
+                                .unwrap_or_default()
+                                .unwrap_or_default();
+                            let index = <usize as NumCast>::from(index).unwrap_or_default();
+                            color2 = 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.color2 {
+                                color2 = style_color;
+                            }
+                        }
+
                         fn points(
                             rect: &Rect,
                             group: &[Vec<&SeriesValue>],
@@ -1018,8 +1066,8 @@ impl Visualization {
                                 }
                                 element_svg.append(
                                     Polygon::new()
-                                        .set("fill", "black")
-                                        .set("stroke", "black")
+                                        .set("fill", color.display_css().to_string())
+                                        .set("stroke", color2.display_css().to_string())
                                         .set("points", data),
                                 );
                             }
@@ -1298,7 +1346,7 @@ impl EmbeddedSource {
     }
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum DelimitedFileSourceMissing {
     #[default]
@@ -1751,7 +1799,7 @@ struct CategoryOrder {
     order: String,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum SortMethod {
     Data,
@@ -2422,6 +2470,7 @@ struct ValueMapEntry {
 
 struct Element<'a> {
     raw: &'a RawElement,
+    style: Cow<'a, Style>,
     graphic: Graphic,
     variables: EnumMap<Axis3, Option<Series>>,
     ignore_minmax: EnumMap<Axis3, bool>,
@@ -2434,6 +2483,7 @@ impl<'a> Element<'a> {
         series: &BTreeMap<&str, Series>,
         bin_widths: &mut BTreeMap<String, f64>,
         axis_domains: &mut EnumMap<Axis3, AxisDomain<'a>>,
+        styles: &'a HashMap<String, Style>,
     ) -> Result<Self, GraphError> {
         let mut variables = EnumMap::default();
         for (coordinate, axis) in
@@ -2674,6 +2724,13 @@ impl<'a> Element<'a> {
         }
         Ok(Self {
             raw,
+            style: if let Some(style) = raw.style.as_deref()
+                && let Some(style) = styles.get(style)
+            {
+                Cow::Borrowed(style)
+            } else {
+                Cow::Owned(Style::default())
+            },
             graphic,
             variables,
             ignore_minmax,
@@ -2765,7 +2822,7 @@ impl GraphChild {
     }
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum BreakRepresentation {
     Gap,
@@ -2806,8 +2863,9 @@ enum Graphic {
     Polygon,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, Default)]
 #[serde(rename_all = "camelCase")]
+#[serde(default)]
 struct RawElement {
     #[serde(rename = "@id")]
     id: Option<String>,
@@ -2815,18 +2873,15 @@ struct RawElement {
     #[serde(rename = "@breakRepresentation")]
     break_representation: Option<BreakRepresentation>,
 
-    #[serde(default, rename = "@closed")]
+    #[serde(rename = "@closed")]
     closed: bool,
 
-    #[serde(default, rename = "@preserveStraightLines")]
+    #[serde(rename = "@preserveStraightLines")]
     preserve_straight_lines: bool,
 
-    #[serde(default, rename = "@centerIn3D")]
+    #[serde(rename = "@centerIn3D")]
     center_in_3d: bool,
 
-    #[serde(default)]
-    position: Vec<Coordinate>,
-
     #[serde(rename = "@medianStyle")]
     median_style: Option<String>,
 
@@ -2842,15 +2897,17 @@ struct RawElement {
     #[serde(rename = "@extremeStyle")]
     extreme_style: Option<String>,
 
-    #[serde(default, rename = "$value")]
+    #[serde(rename = "@style")]
+    style: Option<String>,
+
+    #[serde(rename = "$value")]
     statistics: Vec<Statistic>,
 
+    position: Vec<Coordinate>,
     x: Option<Coordinate>,
     y: Option<Coordinate>,
     z: Option<Coordinate>,
-    #[serde(default)]
     color: Vec<ColorAesthetic>,
-    #[serde(default)]
     style_by: Vec<StyleBy>,
 }
 
@@ -2924,7 +2981,7 @@ struct ColorMapping {
     to: String,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum ColorScale {
     BipolarBlack,
@@ -2938,14 +2995,25 @@ enum ColorScale {
     Interpolated,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum ColorAffect {
     Both,
+    #[serde(alias = "fill")]
+    #[default]
     Main,
+    #[serde(alias = "stroke")]
     Secondary,
-    Stroke,
-    Fill,
+}
+
+impl ColorAffect {
+    pub fn includes_main(&self) -> bool {
+        matches!(self, ColorAffect::Both | ColorAffect::Main)
+    }
+
+    pub fn includes_secondary(&self) -> bool {
+        matches!(self, ColorAffect::Both | ColorAffect::Secondary)
+    }
 }
 
 #[derive(Deserialize, Debug)]
@@ -3084,7 +3152,7 @@ impl Location {
     }
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum LocationMethod {
     Fixed,
@@ -3416,7 +3484,7 @@ struct Scale {
     method: Method,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum Method {
     Linear,
@@ -3491,7 +3559,7 @@ struct BinStatistic {
     dimensions: String,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum BinShape {
     #[default]
@@ -3501,7 +3569,7 @@ enum BinShape {
     Voronoi,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum BinLocation {
     /// Center of the bin, disregarding the data in the bin.
@@ -3513,7 +3581,7 @@ enum BinLocation {
     Centroid,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum BinMethod {
     /// Bin across all the dimensions, without aggregating.  All the points in a
@@ -3565,7 +3633,7 @@ struct SummaryStatistic {
     augment: Augment,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum SummaryMethod {
     Mean,
@@ -3604,7 +3672,7 @@ enum SummaryMethod {
     FractionTrue,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum SummaryBase {
     /// From data for a chart or panel.
@@ -3618,7 +3686,7 @@ enum SummaryBase {
     Coordinate,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "camelCase")]
 enum Augment {
     /// Treat missing values as zero.
@@ -3649,7 +3717,7 @@ struct DistributionStatistic {
     segments: Option<isize>,
 }
 
-#[derive(Deserialize, Debug, Default)]
+#[derive(Copy, Clone, Deserialize, Debug, Default, PartialEq, Eq)]
 #[serde(rename_all = "lowercase")]
 enum Distribution {
     Uniform,
index 621915534c71ec6d3aec9a4b834457807d7d94d3..24dad4c95c55ed37fe5593e856b6703cb2864f2c 100644 (file)
@@ -47,10 +47,10 @@ V2
 60.0
 </text>
 <g id="area_11560">
-<polygon fill="black" points="44.174999839782714 95.20000000000002 44.174999839782714 51.93561445783134 50.52499951934814 74.5083373493976 56.87499919891357 87.6757590361446 63.224998878478985 91.43787951807231 69.57499855804444 93.31893975903617 75.92499823760986 93.31893975903617 107.674996635437 93.31893975903617 114.02499631500243 93.31893975903617 120.37499599456787 83.91363855421689 126.7249956741333 48.17349397590362 133.0749953536987 87.6757590361446 139.42499503326417 91.43787951807231 158.47499407196045 91.43787951807231 158.47499407196045 95.20000000000002" stroke="black"/>
+<polygon fill="#f5f5f5" points="44.174999839782714 95.20000000000002 44.174999839782714 51.93561445783134 50.52499951934814 74.5083373493976 56.87499919891357 87.6757590361446 63.224998878478985 91.43787951807231 69.57499855804444 93.31893975903617 75.92499823760986 93.31893975903617 107.674996635437 93.31893975903617 114.02499631500243 93.31893975903617 120.37499599456787 83.91363855421689 126.7249956741333 48.17349397590362 133.0749953536987 87.6757590361446 139.42499503326417 91.43787951807231 158.47499407196045 91.43787951807231 158.47499407196045 95.20000000000002" stroke="#000000"/>
 </g>
 <g id="area_11564">
-<polygon fill="black" points="107.674996635437 95.20000000000002 107.674996635437 91.39200000000001 114.02499631500243 91.39200000000001 120.37499599456787 72.35200000000002 126.7249956741333 0 133.0749953536987 79.96800000000002 139.42499503326417 87.58400000000002 158.47499407196045 87.58400000000002 158.47499407196045 95.20000000000002" stroke="black"/>
+<polygon fill="#0000ff" points="107.674996635437 95.20000000000002 107.674996635437 91.39200000000001 114.02499631500243 91.39200000000001 120.37499599456787 72.35200000000002 126.7249956741333 0 133.0749953536987 79.96800000000002 139.42499503326417 87.58400000000002 158.47499407196045 87.58400000000002 158.47499407196045 95.20000000000002" stroke="#000000"/>
 </g>
 </g>
 <g id="graph_11578">
@@ -98,10 +98,10 @@ V1
 50.0
 </text>
 <g id="area_11579">
-<polygon fill="black" points="212.17499343109097 95.20000000000002 212.17499343109097 92.75897435897437 231.22499246978748 82.99487179487181 243.9249918289181 85.43589743589746 250.274991508484 87.87692307692309 262.9749908676146 92.75897435897437 275.67499022674525 92.75897435897437 282.02498990631113 82.99487179487181 294.72498926544176 90.31794871794872 307.4249886245724 75.67179487179489 313.77498830413833 0 326.4749876632689 73.23076923076925 326.4749876632689 95.20000000000002" stroke="black"/>
+<polygon fill="#f5f5f5" points="212.17499343109097 95.20000000000002 212.17499343109097 92.75897435897437 231.22499246978748 82.99487179487181 243.9249918289181 85.43589743589746 250.274991508484 87.87692307692309 262.9749908676146 92.75897435897437 275.67499022674525 92.75897435897437 282.02498990631113 82.99487179487181 294.72498926544176 90.31794871794872 307.4249886245724 75.67179487179489 313.77498830413833 0 326.4749876632689 73.23076923076925 326.4749876632689 95.20000000000002" stroke="#000000"/>
 </g>
 <g id="area_11583">
-<polygon fill="black" points="212.17499343109097 95.20000000000002 212.17499343109097 89.91111111111113 231.22499246978748 68.75555555555556 243.9249918289181 74.04444444444447 250.274991508484 79.33333333333336 262.9749908676146 89.91111111111113 275.67499022674525 89.91111111111113 282.02498990631113 74.04444444444447 307.4249886245724 79.33333333333336 313.77498830413833 63.46666666666668 326.4749876632689 52.8888888888889 326.4749876632689 95.20000000000002" stroke="black"/>
+<polygon fill="#0000ff" points="212.17499343109097 95.20000000000002 212.17499343109097 89.91111111111113 231.22499246978748 68.75555555555556 243.9249918289181 74.04444444444447 250.274991508484 79.33333333333336 262.9749908676146 89.91111111111113 275.67499022674525 89.91111111111113 282.02498990631113 74.04444444444447 307.4249886245724 79.33333333333336 313.77498830413833 63.46666666666668 326.4749876632689 52.8888888888889 326.4749876632689 95.20000000000002" stroke="#000000"/>
 </g>
 </g>
 <g id="graph_11597">
@@ -141,10 +141,10 @@ V3
 30.0
 </text>
 <g id="area_11598">
-<polygon fill="black" points="380.17498750305174 95.20000000000002 380.17498750305174 87.9651529193698 392.87498878479005 91.58257645968492 399.2249894256592 66.26061167747916 405.5749900665283 33.7037998146432 411.92499070739746 22.851529193697868 418.2749913482666 48.17349397590362 424.62499198913576 59.025764596848944 430.97499263000486 66.26061167747916 437.324993270874 91.58257645968492 450.02499455261227 91.58257645968492 469.07499647521973 91.58257645968492 488.12499839782714 91.58257645968492 488.12499839782714 95.20000000000002" stroke="black"/>
+<polygon fill="#f5f5f5" points="380.17498750305174 95.20000000000002 380.17498750305174 87.9651529193698 392.87498878479005 91.58257645968492 399.2249894256592 66.26061167747916 405.5749900665283 33.7037998146432 411.92499070739746 22.851529193697868 418.2749913482666 48.17349397590362 424.62499198913576 59.025764596848944 430.97499263000486 66.26061167747916 437.324993270874 91.58257645968492 450.02499455261227 91.58257645968492 469.07499647521973 91.58257645968492 488.12499839782714 91.58257645968492 488.12499839782714 95.20000000000002" stroke="#000000"/>
 </g>
 <g id="area_11602">
-<polygon fill="black" points="399.2249894256592 95.20000000000002 399.2249894256592 51.26153846153848 405.5749900665283 0 411.92499070739746 14.646153846153839 418.2749913482666 43.93846153846154 424.62499198913576 80.55384615384617 469.07499647521973 87.87692307692309 488.12499839782714 87.87692307692309 488.12499839782714 95.20000000000002" stroke="black"/>
+<polygon fill="#0000ff" points="399.2249894256592 95.20000000000002 399.2249894256592 51.26153846153848 405.5749900665283 0 411.92499070739746 14.646153846153839 418.2749913482666 43.93846153846154 424.62499198913576 80.55384615384617 469.07499647521973 87.87692307692309 488.12499839782714 87.87692307692309 488.12499839782714 95.20000000000002" stroke="#000000"/>
 </g>
 </g>
 </svg>