From 4d981b43aa528ef50d658cbf072194e11be90a5d Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Thu, 7 May 2026 12:01:34 -0700 Subject: [PATCH] work on bar graphs --- rust/pspp/src/spv/read/graph.rs | 133 ++++++++++++++---- rust/pspp/src/spv/read/graph/style.rs | 31 ++-- .../pspp/src/spv/testdata/graph3.expected.svg | 24 ++-- .../pspp/src/spv/testdata/graph4.expected.svg | 6 +- 4 files changed, 138 insertions(+), 56 deletions(-) diff --git a/rust/pspp/src/spv/read/graph.rs b/rust/pspp/src/spv/read/graph.rs index 7d05f07393..ea8240ae4a 100644 --- a/rust/pspp/src/spv/read/graph.rs +++ b/rust/pspp/src/spv/read/graph.rs @@ -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, + ) -> impl Iterator, 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) -> Option { + self.lookup_range(value) + .map(|range| range.start.midpoint(range.end)) + } + fn lookup_range(&self, value: &Datum) -> Option> { 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>, + + /// 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, } @@ -2566,6 +2635,7 @@ impl<'a> Element<'a> { let total = frequencies.values().sum::(); 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 { + match *self { + LocationLength::Absolute(_) => None, + LocationLength::Percent(percent) => Some(percent), + } + } } impl FromStr for LocationLength { diff --git a/rust/pspp/src/spv/read/graph/style.rs b/rust/pspp/src/spv/read/graph/style.rs index 9781997879..d53b46a488 100644 --- a/rust/pspp/src/spv/read/graph/style.rs +++ b/rust/pspp/src/spv/read/graph/style.rs @@ -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 { // 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