From be3fdaaee59443303cace50dcd586b84843435f5 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Tue, 23 Jun 2026 16:49:50 -0700 Subject: [PATCH] Work on facet and axis labeling. Doesn't pass tests yet. --- rust/pspp/src/output/drivers/cairo.rs | 4 + rust/pspp/src/spv/read/graph.rs | 778 ++++++++++++++++++++++---- rust/pspp/src/spv/testdata/graph5.xml | 4 +- 3 files changed, 669 insertions(+), 117 deletions(-) diff --git a/rust/pspp/src/output/drivers/cairo.rs b/rust/pspp/src/output/drivers/cairo.rs index 67362805db..7494d1741a 100644 --- a/rust/pspp/src/output/drivers/cairo.rs +++ b/rust/pspp/src/output/drivers/cairo.rs @@ -30,6 +30,10 @@ fn px_to_xr(x: isize) -> isize { x * 3 * (SCALE as isize * 72 / 96) / 3 } +pub fn pt_to_xr(x: f64) -> isize { + (x * SCALE as f64 + 0.5) as isize +} + pub fn xr_to_pt(x: isize) -> f64 { x as f64 / SCALE as f64 } diff --git a/rust/pspp/src/spv/read/graph.rs b/rust/pspp/src/spv/read/graph.rs index 62b0f4e829..a384616ede 100644 --- a/rust/pspp/src/spv/read/graph.rs +++ b/rust/pspp/src/spv/read/graph.rs @@ -53,7 +53,7 @@ use crate::{ locale::{Item, Locale, data::EN}, output::{ SpvGraphMembers, SpvInfo, SpvMembers, - drivers::cairo::xr_to_pt, + drivers::cairo::{pt_to_xr, xr_to_pt}, pivot::{Axis2, Axis3, Length, ParseLengthError, look::Color}, }, spv::{ @@ -599,7 +599,7 @@ impl Visualization { && let Some(top) = frames.frames[0].position[Axis2::Y].start() && let Some(height) = frames.frames[0].position[Axis2::Y].length() { - document.assign("viewBox", (left, top, left + width, top + height)); + document.assign("viewBox", (left, top, left + width + 75.0, top + height)); document.assign("width", width); document.assign("height", height); } @@ -710,21 +710,104 @@ impl Visualization { if axis_domains[Axis3::X].label.is_some() { rect.0[Axis2::Y].1 -= font_height; } + dbg!(&rect); let facet_levels = graph.facet_levels(); let faceting = graph .faceting() .map(|f| f.decode(&series, &facet_levels)) .unwrap_or_default(); + dbg!(&faceting); - let x_facets = FacetIterator::new(&faceting[Axis2::X]); + let x_facets = FacetIterator::new(&faceting[Axis2::X]).into_values(); + dbg!(AxisFacets::new(&faceting[Axis2::X])); let nx_facets = x_facets.len(); - let y_facets = FacetIterator::new(&faceting[Axis2::Y]); + let y_facets = FacetIterator::new(&faceting[Axis2::Y]).into_values(); + dbg!(AxisFacets::new(&faceting[Axis2::Y])); let ny_facets = y_facets.len(); + let mut x_headings = FacetHeadings::new( + Side::Bottom, + &faceting[Axis2::X], + rect.0[Axis2::X].length(), + graph.x_dimension(), + &mut font_metrics, + ); + let mut y_headings = FacetHeadings::new( + Side::Top, + &faceting[Axis2::Y], + rect.0[Axis2::Y].length() - x_headings.total_height(), + graph.y_dimension(), + &mut font_metrics, + ); + for _ in 0..5 { + println!( + "{:.0} x {:.0}", + x_headings.total_height(), + y_headings.total_height() + ); + x_headings = FacetHeadings::new( + Side::Bottom, + &faceting[Axis2::X], + rect.0[Axis2::X].length() - y_headings.total_height(), + graph.x_dimension(), + &mut font_metrics, + ); + y_headings = FacetHeadings::new( + Side::Top, + &faceting[Axis2::Y], + rect.0[Axis2::Y].length() - x_headings.total_height(), + graph.y_dimension(), + &mut font_metrics, + ); + } + + let inner = rect.inset( + [ + y_headings.height(Side::Top), + y_headings.height(Side::Bottom), + ], + [ + x_headings.height(Side::Top), + x_headings.height(Side::Bottom), + ], + ); + + let top = x_headings + .render(Side::Top) + .set("c:description", "X axis facets above graph") + .set( + "transform", + format!("translate({} {})", rect.0[Axis2::X].0, rect.0[Axis2::Y].0), + ); + let bottom = x_headings + .render(Side::Bottom) + .set("c:description", "X axis facets below graph") + .set( + "transform", + format!("translate({} {})", inner.0[Axis2::X].0, inner.0[Axis2::Y].1,), + ); + + let left = y_headings + .render(Side::Top) + .set("c:description", "Y axis facets on left side of graph") + .set("transform", { + let x = rect.0[Axis2::X].0; + let y = inner.0[Axis2::Y].1; + format!("rotate(-90 {x} {y}) translate({x} {y})") + }); + let right = y_headings + .render(Side::Bottom) + .set("c:description", "Y axis facets on right side of graph") + .set("transform", { + let x = rect.0[Axis2::X].1; + let y = inner.0[Axis2::Y].0; + format!("rotate(90 {x} {y}) translate({x} {y})") + }); + for (x_index, _x_facet) in x_facets.clone().enumerate() { { - let rect = rect.subrect(x_index, nx_facets, ny_facets - 1, ny_facets); + let rect = inner.subrect(x_index, nx_facets, ny_facets - 1, ny_facets); // X axis. let y = rect.0[Axis2::Y].1; let x_span = rect.0[Axis2::X]; @@ -736,46 +819,25 @@ impl Visualization { .set("y2", y) .set("stroke", "black"), ); - for (frac, label) in axis_domains[Axis3::X].ticks() { + for (frac, _label) in axis_domains[Axis3::X].ticks() { let svg_x = rect.0[Axis2::X].frac(frac); - graph_svg = graph_svg - .add( - Line::new() - .set("x1", svg_x) - .set("x2", svg_x) - .set("y1", y) - .set("y2", y + 10.0) - .set("stroke", "black"), - ) - .add( - svg::node::element::Text::new(label) - .set("x", svg_x) - .set("y", y + 10.0) - .set("dominant-baseline", "hanging") - .set("text-anchor", "middle"), - ); + graph_svg = graph_svg.add( + Line::new() + .set("x1", svg_x) + .set("x2", svg_x) + .set("y1", y) + .set("y2", y + 10.0) + .set("stroke", "black"), + ); } } - if let Some(label) = &axis_domains[Axis3::X].label - && let Some(label) = label.text() - { - let x = rect.0[Axis2::X].midpoint(); - let y = rect.0[Axis2::Y].1 + 10.0 + font_height; - graph_svg.append( - svg::node::element::Text::new(label) - .set("x", x) - .set("y", y) - .set("dominant-baseline", "hanging") - .set("text-anchor", "middle"), - ); - } } - for (y_index, y_facet) in y_facets.clone().enumerate() { - let rect = rect.subrect(0, nx_facets, y_index, ny_facets); + for (y_index, _y_facet) in y_facets.clone().enumerate() { + let subrect = inner.subrect(0, nx_facets, y_index, ny_facets); // Y axis. - let x = rect.0[Axis2::X].0; - let y_span = rect.0[Axis2::Y]; + let x = subrect.0[Axis2::X].0; + let y_span = subrect.0[Axis2::Y]; graph_svg.append( Line::new() .set("y1", y_span.0) @@ -785,69 +847,22 @@ impl Visualization { .set("stroke", "black") .set("c:description", format!("Y axis {y_index}")), ); - for (frac, label) in axis_domains[Axis3::Y].ticks() { - let svg_y = rect.0[Axis2::Y].frac(1.0 - frac); - graph_svg = graph_svg - .add( - Line::new() - .set("y1", svg_y) - .set("y2", svg_y) - .set("x1", x) - .set("x2", x - 10.0) - .set("stroke", "black"), - ) - .add( - svg::node::element::Text::new(label) - .set("y", svg_y) - .set("x", x - 10.0) - .set("dominant-baseline", "middle") - .set("text-anchor", "end"), - ); - } - - let label = zip(&y_facet, &faceting[Axis2::Y]) - .map(|(value, facet)| { - if let Some(label) = &value.label { - label.clone() - } else { - facet.series.formatter.display(&value.value).to_string() - } - }) - .join(", "); - if !label.is_empty() { - let x = x - 10.0 - y_axis_text_width - font_height; - let y = rect.0[Axis2::Y].midpoint(); - graph_svg.append( - svg::node::element::Text::new(label) - .set("x", x) - .set("y", y) - .set("dominant-baseline", "hanging") - .set("text-anchor", "middle") - .set("transform", format!("rotate(-90, {x}, {y})")) - .set("c:description", "Row facet label"), + for (frac, _label) in axis_domains[Axis3::Y].ticks() { + let svg_y = subrect.0[Axis2::Y].frac(1.0 - frac); + graph_svg = graph_svg.add( + Line::new() + .set("y1", svg_y) + .set("y2", svg_y) + .set("x1", x) + .set("x2", x - 10.0) + .set("stroke", "black"), ); } } - if let Some(label) = &axis_domains[Axis3::Y].label - && let Some(label) = label.text() - { - let x = rect.0[Axis2::X].0 - 10.0 - y_axis_text_width - font_height; - let y = rect.0[Axis2::Y].midpoint(); - graph_svg.append( - svg::node::element::Text::new(label) - .set("x", x) - .set("y", y) - .set("dominant-baseline", "hanging") - .set("text-anchor", "middle") - .set("transform", format!("rotate(-90, {x}, {y})")) - .set("c:description", "Y axis label"), - ) - } - for (x_index, x_facet) in x_facets.enumerate() { for (y_index, y_facet) in y_facets.clone().enumerate() { - let rect = rect.subrect(x_index, nx_facets, y_index, ny_facets); + let rect = inner.subrect(x_index, nx_facets, y_index, ny_facets); if cell_style.color.is_some() { graph_svg.append( @@ -1208,6 +1223,12 @@ impl Visualization { { graph_svg.append(defs); } + graph_svg.append(top); + graph_svg.append(bottom); + graph_svg.append(left); + graph_svg.append(right); + //graph_svg.append(rect.as_svg().set("fill", "none").set("stroke", "red")); + //graph_svg.append(inner.as_svg().set("fill", "none").set("stroke", "green")); Ok(graph_svg) } } @@ -1229,6 +1250,12 @@ impl FontMetrics { let (w, h) = self.layout.size(); enum_map! { Axis2::X => xr_to_pt(w as isize), Axis2::Y => xr_to_pt(h as isize) } } + fn measure_height_for_width(&mut self, s: &str, width: f64) -> f64 { + self.layout.set_width(pt_to_xr(width) as i32); + self.layout.set_text(s); + let (_w, h) = self.layout.size(); + xr_to_pt(h as isize) + } } #[derive(Clone, Debug)] @@ -1278,10 +1305,11 @@ impl Series { categorical: bool, formatter: Formatter, ) -> Self { - let categorical_values = if categorical { + let unique_values = if categorical { values .iter() .unique_by(|sv| sv.value.clone()) + .sorted_by(|a, b| a.value.cmp(&b.value)) .cloned() .collect() } else { @@ -1295,7 +1323,7 @@ impl Series { key: false, categorical, values, - unique_values: categorical_values, + unique_values, formatter, } } @@ -1583,6 +1611,7 @@ impl Variable for SourceVariable { // XXX warn about missing variable Vec::new() }; + dbg!(&data); let formatter = self.format.as_ref().map_or_else( || Formatter::default_for_locale(locale), |format| Formatter::new(format, locale), @@ -2947,6 +2976,14 @@ impl Graph { }) } + fn x_dimension(&self) -> Option<&Dimension> { + self.coordinates().next()?.dimensions().next() + } + + fn y_dimension(&self) -> Option<&Dimension> { + self.coordinates().next()?.dimensions().skip(1).next() + } + fn elements(&self) -> impl Iterator { self.children.iter().filter_map(|child| child.element()) } @@ -3416,8 +3453,7 @@ struct Facet<'a> { } impl Faceting { - /// Returns the faceting for each axis, as variable names from innermost to - /// outermost. + /// Returns the faceting for each axis, from innermost to outermost. /// /// This could decode `nest` and `blend` but those are super rare so we only /// decode `cross` instead. @@ -3443,6 +3479,7 @@ impl Faceting { enum FacetingChild { Dot(Cross), Cross(Cross), + Nest(Nest), Layer(Layer), } @@ -3458,6 +3495,9 @@ impl FacetingChild { FacetingChild::Cross(cross) | FacetingChild::Dot(cross) => { cross.decode(series, facet_levels, faceting, 0, variable_index) } + FacetingChild::Nest(nest) => { + nest.decode(series, facet_levels, faceting, 0, variable_index) + } FacetingChild::Layer(_layer) => { // XXX warn } } @@ -4074,6 +4114,20 @@ struct FacetLevel { #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct Axis { + /// For an X-axis, false means that the axis appears below the graph, true + /// means that it appears above the graph. + /// + /// For a Y-axis, false means that the axis appears to the left of the + /// graph, true means that it appears to the right of the graph. + #[serde(rename = "@opposite", default)] + opposite: bool, + + /// If false, `label` appears once per facet along the axis. + /// + /// If true, `label` appears once along the axis. + #[serde(rename = "@combineAxisLabels", default)] + combine_axis_labels: bool, + label: Option