From 0f13edaabedf3013734367cfcaf849e3b239b853 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 01:40:36 -0800 Subject: [PATCH] Graphs: centered labels with kept leading stay centered Category labels beside an axis and labels in pie wedges are centered on their place by the default leading. A multi-line label that keeps more leading or paragraph spacing ran down from that place instead. The graph now puts the middle of such a label's first and last baselines on its place. --- crates/engine/src/cmd/graph/labels.rs | 38 ++++++++++++++++++++------- crates/engine/src/cmd/graph/tests.rs | 26 ++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index faf301899..9d521c5d9 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -48,17 +48,21 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> /// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the /// alignment. pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle { - let kept = match role { - Role::Value => &styles.values_para, - Role::Category => &styles.categories_para, - Role::Legend => &styles.legend_para, - }; - let mut para = kept.clone().unwrap_or_default(); + let mut para = kept_para(styles, role).cloned().unwrap_or_default(); // A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label). para.keep_spacing_in_range(); para } +/// The paragraph attributes labels of `role` keep, if any. +fn kept_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::ParaStyle> { + match role { + Role::Value => styles.values_para.as_ref(), + Role::Category => styles.categories_para.as_ref(), + Role::Legend => styles.legend_para.as_ref(), + } +} + /// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp. pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias { let kept = match role { @@ -85,6 +89,11 @@ impl Gen<'_> { self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5) } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { + let t = self.text_object(role, at, s, justify); + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + } + /// Label `s` of `role` set at `at` and laid out, not yet in the document. + fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); t.para = label_para(self.styles, role); @@ -92,14 +101,25 @@ impl Gen<'_> { t.para.justify = justify; t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + t } /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred /// on `at` (labels beside an axis or in a wedge) instead of running down from it. pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { let lines = s.split('|').count(); - let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0) } else { at }; - self.text(role, at, &s.replace('|', "\n"), justify) + // Default leading puts the first and last baselines this far above and below `at`. + let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0; + let start = if middle { Point::new(at.x, at.y - half) } else { at }; + let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); + // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An + // empty first or last line has no baseline, and such a label stays where the default leading puts it. + if middle && kept_para(self.styles, role).is_some() { + let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); + if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ys.len() == lines) { + t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; + } + } + Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c5316e765..7f85763a7 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -4344,6 +4344,32 @@ fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() { assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}"); } +#[test] +fn a_centered_label_with_kept_leading_stays_centered_when_the_graph_is_drawn_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = ",2024,2025\nQ|1,3,2\nQ2,5,4\nQ3,-1,6"; + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let middle = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "Q\n1")).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let before = middle(&s); + let l = text_id(&s, id, "Axes", "Q\n1"); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("text.setFormat", &json!({"autoLeading": 400, "spaceBefore": 6})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let after = middle(&s); + assert!((after - before).abs() < 0.01, "the two-line category label keeps its middle: {before} {after}"); +} + #[test] fn a_legend_across_top_measures_its_labels_with_system_layout() { let mut s = Session::new();