]> pintos-os.org Git - pspp/commitdiff
work on bar graphs
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 7 May 2026 19:01:34 +0000 (12:01 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 7 May 2026 19:20:35 +0000 (12:20 -0700)
rust/pspp/src/spv/read/graph.rs
rust/pspp/src/spv/read/graph/style.rs
rust/pspp/src/spv/testdata/graph3.expected.svg
rust/pspp/src/spv/testdata/graph4.expected.svg

index 7d05f07393f90b4067cc2d9db384ae1aee00802f..ea8240ae4ad56d5acd01d82100ac82785d9fc774 100644 (file)
@@ -421,29 +421,51 @@ impl Visualization {
                 && let Some(domain_name) = &dimension.domain
                 && let Some(domain) = domains.get(domain_name.as_str())
             {
-                domain.clone()
+                Some(domain.clone())
             } else {
-                Domain::default()
+                None
             };
-            if let Domain::Interval { min, max, exact } = &mut domain
-                && (!*exact || min.is_none() || max.is_none())
-            {
-                for element in &elements {
-                    if let Some(variable) = &element.variables[axis]
-                        && !element.ignore_minmax[axis]
-                    {
-                        for value in &variable.values {
-                            if let Some(Some(number)) = value.value.as_number() {
-                                if min.is_none_or(|min| number < min) {
-                                    *min = Some(number);
-                                }
-                                if max.is_none_or(|max| number > max) {
-                                    *max = Some(number);
+            for element in &elements {
+                if let Some(variable) = &element.variables[axis]
+                    && !element.ignore_minmax[axis]
+                {
+                    let domain = domain.get_or_insert_with(|| {
+                        if variable.categorical {
+                            Domain::new_categorical()
+                        } else {
+                            Domain::new_interval()
+                        }
+                    });
+                    match domain {
+                        Domain::Interval { min, max, exact } => {
+                            if !*exact || min.is_none() || max.is_none() {
+                                for value in &variable.values {
+                                    if let Some(Some(number)) = value.value.as_number() {
+                                        if min.is_none_or(|min| number < min) {
+                                            *min = Some(number);
+                                        }
+                                        if max.is_none_or(|max| number > max) {
+                                            *max = Some(number);
+                                        }
+                                    }
                                 }
                             }
                         }
+                        Domain::Categorical { values } => {
+                            for value in &variable.values {
+                                values
+                                    .entry(value.value.clone())
+                                    .or_insert_with(|| value.label.clone());
+                            }
+                        }
                     }
                 }
+            }
+
+            let mut domain = domain.unwrap_or_default();
+            if let Domain::Interval { min, max, exact } = &mut domain
+                && (!*exact || min.is_none() || max.is_none())
+            {
                 match min {
                     Some(number) => {
                         if *number > 0.0 {
@@ -933,17 +955,43 @@ impl Visualization {
                             })
                         }
 
+                        fn ranges(
+                            rect: &Rect,
+                            group: &[Vec<&SeriesValue>],
+                            axis_domains: &EnumMap<Axis3, AxisDomain>,
+                        ) -> impl Iterator<Item = (Range<f64>, f64)> {
+                            group.iter().filter_map(|values| {
+                                let x = axis_domains[Axis3::X]
+                                    .domain
+                                    .lookup_range(&values.get(0)?.value)?;
+                                let x =
+                                    rect.0[Axis2::X].frac(x.start)..rect.0[Axis2::X].frac(x.end);
+
+                                let y = match values.get(1) {
+                                    Some(value) => {
+                                        axis_domains[Axis3::Y].domain.lookup(&value.value)?
+                                    }
+                                    None => 0.5,
+                                };
+                                let y = rect.0[Axis2::Y].frac(1.0 - y);
+                                Some((x, y))
+                            })
+                        }
+
                         let mut element_svg = Group::new().with_id(&element.raw.id);
                         match element.graphic {
                             Graphic::Interval => {
-                                for (x, y) in points(&rect, group, &axis_domains) {
+                                // Size defaults to 33%.
+                                for (x, y0) in ranges(&rect, group, &axis_domains) {
+                                    let y1 = rect.0[Axis2::Y].1;
                                     element_svg.append(
-                                        Path::new().set("fill", "none").set("stroke", "black").set(
-                                            "d",
-                                            Data::new()
-                                                .move_to((x, rect.0[Axis2::Y].1))
-                                                .line_to((x, y)),
-                                        ),
+                                        Rectangle::new()
+                                            .set("fill", color.display_css().to_string())
+                                            .set("stroke", color2.display_css().to_string())
+                                            .set("x", x.start)
+                                            .set("y", y0)
+                                            .set("width", (x.end - x.start).max(1.0))
+                                            .set("height", y1 - y0),
                                     );
                                 }
                             }
@@ -1136,6 +1184,7 @@ impl Debug for Series {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("Series")
             .field("name", &self.name)
+            .field("categorical", &self.categorical)
             .finish_non_exhaustive()
     }
 }
@@ -1620,15 +1669,23 @@ enum Domain {
 
 impl Default for Domain {
     fn default() -> Self {
+        Self::new_interval()
+    }
+}
+
+impl Domain {
+    fn new_categorical() -> Self {
+        Self::Categorical {
+            values: IndexMap::new(),
+        }
+    }
+    fn new_interval() -> Self {
         Self::Interval {
             min: None,
             max: None,
             exact: false,
         }
     }
-}
-
-impl Domain {
     fn is_interval(&self) -> bool {
         matches!(self, Self::Interval { .. })
     }
@@ -1639,12 +1696,16 @@ impl Domain {
         }
     }
     fn lookup(&self, value: &Datum<String>) -> Option<f64> {
+        self.lookup_range(value)
+            .map(|range| range.start.midpoint(range.end))
+    }
+    fn lookup_range(&self, value: &Datum<String>) -> Option<Range<f64>> {
         match self {
             Domain::Categorical { values } => {
                 if let Some(index) = values.get_index_of(value) {
                     let left = index as f64 / values.len() as f64;
                     let right = (index + 1) as f64 / values.len() as f64;
-                    Some(left.midpoint(right))
+                    Some(left..right)
                 } else {
                     None
                 }
@@ -1657,7 +1718,8 @@ impl Domain {
                 if let Some(Some(number)) = value.as_number()
                     && (*min..=*max).contains(&number)
                 {
-                    Some((number - *min) / (*max - *min))
+                    let value = (number - *min) / (*max - *min);
+                    Some(value..value)
                 } else {
                     None
                 }
@@ -2473,6 +2535,13 @@ struct Element<'a> {
     style: Cow<'a, Style>,
     graphic: Graphic,
     variables: EnumMap<Axis3, Option<Series>>,
+
+    /// Whether to ignore this element for the purpose of computing minimum and
+    /// maximum ordinates on an axis.
+    ///
+    /// This is set to true for statistics that produce data for an axis but
+    /// shouldn't expand the axis range, e.g. probability distribution
+    /// functions.
     ignore_minmax: EnumMap<Axis3, bool>,
 }
 
@@ -2566,6 +2635,7 @@ impl<'a> Element<'a> {
 
                         let total = frequencies.values().sum::<f64>();
                         let mut y = x.clone();
+                        y.categorical = false;
                         for value in &mut y.values {
                             let frequency =
                                 frequencies.get(&value.value).copied().unwrap_or_default();
@@ -4134,6 +4204,13 @@ impl LocationLength {
             LocationLength::Percent(percentage) => parent_length.map(|p| p * (percentage / 100.0)),
         }
     }
+
+    fn as_percent(&self) -> Option<f64> {
+        match *self {
+            LocationLength::Absolute(_) => None,
+            LocationLength::Percent(percent) => Some(percent),
+        }
+    }
 }
 
 impl FromStr for LocationLength {
index 9781997879f195594f62aaa89bfed2150948ae8c..d53b46a488ca63322d518ffcedfe634328f7d856 100644 (file)
@@ -19,20 +19,29 @@ pub struct StyleCycle {
 }
 
 impl StyleCycle {
-    /// Constructs the style with the given 1-based `index` in this cycle.
     pub fn get_style(&self, index: usize) -> Style {
+        let mut iter = self.get_style_components(index);
+        let style = iter.next().cloned().unwrap_or_default();
+        iter.fold(style, |a, b| a.overridden_with(b))
+    }
+
+    /// Iterates through the styles for the given 1-based `index` in this cycle.
+    ///
+    /// There might be no styles, if `self.cycles` is empty or all of the
+    /// elements in `self.cycles` are empty.
+    pub fn get_style_components(&self, index: usize) -> impl Iterator<Item = &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();
-            if len != 0 {
-                let s = &cycle.styles[index % len];
-                style = Some(style.map_or_else(|| s.clone(), |style| style.overridden_with(s)));
-                index /= len;
-            }
-        }
-        style.unwrap_or_default()
+        self.cycles
+            .iter()
+            .flat_map(move |cycle| match cycle.styles.len() {
+                0 => None,
+                len => {
+                    let s = &cycle.styles[index % len];
+                    index /= len;
+                    Some(s)
+                }
+            })
     }
 
     /// Returns the length of the cycle, that is, the number of styles before
index 5d607a1798c26652b2e9638e39714ba4e081001e..b8ebc991275a34fc4bcb99b79964e9cd646755d4 100644 (file)
@@ -1,17 +1,13 @@
 <svg height="500" style="font-family: &quot;Sans Serif&quot;" viewBox="0 0 625 500" width="625" xmlns="http://www.w3.org/2000/svg">
 <g id="frame_data_1">
 <line stroke="black" x1="70.25" x2="618.75" y1="439" y2="439"/>
-<line stroke="black" x1="95.18181818181819" x2="95.18181818181819" y1="439" y2="449"/>
-<text dominant-baseline="hanging" text-anchor="middle" x="95.18181818181819" y="449">
-0.0
-</text>
-<line stroke="black" x1="344.5" x2="344.5" y1="439" y2="449"/>
-<text dominant-baseline="hanging" text-anchor="middle" x="344.5" y="449">
-1.0
+<line stroke="black" x1="207.375" x2="207.375" y1="439" y2="449"/>
+<text dominant-baseline="hanging" text-anchor="middle" x="207.375" y="449">
+Value0
 </text>
-<line stroke="black" x1="593.8181818181818" x2="593.8181818181818" y1="439" y2="449"/>
-<text dominant-baseline="hanging" text-anchor="middle" x="593.8181818181818" y="449">
-2.0
+<line stroke="black" x1="481.625" x2="481.625" y1="439" y2="449"/>
+<text dominant-baseline="hanging" text-anchor="middle" x="481.625" y="449">
+Value1
 </text>
 <text dominant-baseline="hanging" text-anchor="middle" x="344.5" y="472">
 V0
@@ -61,12 +57,12 @@ Percent
 Percent
 </text>
 <g id="element_bar_1">
-<path d="M344.5,219.5 L344.5,126.93304" fill="none" stroke="black"/>
-<path d="M593.8182,219.5 L593.8182,15.214286" fill="none" stroke="black"/>
+<rect fill="#d3ce97" height="92.56696428571429" stroke="#000000" width="274.25" x="70.25" y="126.93303571428571"/>
+<rect fill="#d3ce97" height="204.2857142857143" stroke="#000000" width="274.25" x="344.5" y="15.214285714285701"/>
 </g>
 <g id="element_bar_1">
-<path d="M344.5,439 L344.5,317.70535" fill="none" stroke="black"/>
-<path d="M593.8182,439 L593.8182,311.32144" fill="none" stroke="black"/>
+<rect fill="#d3ce97" height="121.29464285714289" stroke="#000000" width="274.25" x="70.25" y="317.7053571428571"/>
+<rect fill="#d3ce97" height="127.67857142857144" stroke="#000000" width="274.25" x="344.5" y="311.32142857142856"/>
 </g>
 </g>
 </svg>
index 2d54e6ad3cfd4e62ece3d08a1b36c74a07c58d09..099f3f9e6accbc358d68acbde26a6b4836a8282e 100644 (file)
 0.0
 </text>
 <g id="element_element_1">
-<path d="M246.55646,231 L246.55646,118" fill="none" stroke="black"/>
+<rect fill="#3e58ac" height="113" stroke="#000000" width="1" x="246.55645161290323" y="118"/>
 </g>
 <g id="element_element_1">
-<path d="M191.00807,231 L191.00807,118" fill="none" stroke="black"/>
+<rect fill="#2eb848" height="113" stroke="#000000" width="1" x="191.00806451612905" y="118"/>
 </g>
 <g id="element_element_1">
-<path d="M468.75,462 L468.75,349" fill="none" stroke="black"/>
+<rect fill="#2eb848" height="113" stroke="#000000" width="1" x="468.75" y="349"/>
 </g>
 </g>
 </svg>