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.
This commit is contained in:
dotCooCoo
2026-11-11 01:40:36 -08:00
parent da765088a0
commit 0f13edaabe
2 changed files with 55 additions and 9 deletions
+29 -9
View File
@@ -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<Node> {
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<Node> {
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<f64> = 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<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
+26
View File
@@ -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();