Files
vectorcraft/crates/engine/src/cmd/graph/labels.rs
T
dotCooCoo 9a08f64a04 Graphs: Appearance commands and the Eyedropper update a series' stored paint
paint.setFill, paint.setStroke, paint.none, Apply Fill to Stroke and Apply
Stroke to Fill stored a series' paint on the graph. appearance.setItem, the
Eyedropper, appearance.transfer, Clear Appearance, Reduce to Basic Appearance
and the add, remove, move and duplicate row commands stored no paint or weight.
An Eyedropper pick of a blue fill at 40% onto a red column drew red at 40%
after the next redraw.

These commands and Show All Hidden Attributes now store the paint and weight
of the topmost visible fill and stroke of a path directly in a series group,
in the same undo step. A color, weight or opacity that a command writes to that
row replaces the stored value even when the mark's own value does not change. A
row that has no paint before and after the command does not change the stored
paint. These commands store no stroke for a series that draws Filled Lines
bands, and a band no longer takes its color from an empty stored stroke.

The stored opacity also comes from the topmost visible row: hiding a top row
stores the row under it, and an opacity set on a hidden top row is not stored.

Follows #5501.
2026-11-08 12:01:39 +07:00

208 lines
9.7 KiB
Rust

use super::axis::LABEL_SIZE;
use super::columns::{Allowance, ColumnArt};
use super::markers::fit_design;
use super::series::{default_series_paint, series_marks};
use super::{
Affine, Appearance, Arc, BezPath, CharStyle, Color, Document, GraphSpec, Justify, Node, NodeId, NodeKind, Paint, PathData, Point, Rect,
TextObject, refresh_bounds, shapes,
};
pub(super) struct Gen<'a> {
pub(super) d: &'a mut Document,
pub(super) out: Vec<Arc<Node>>,
/// The labels' kept character styles.
pub(super) styles: &'a vectorcraft_doc::LabelStyles,
/// What the column designs may still add.
pub(super) columns: Allowance,
}
/// What a graph label shows, for its kept character style.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Role {
Value,
Category,
Legend,
}
/// The character style labels of `role` are set in: the kept one, else 9 pt black.
pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) -> CharStyle {
let kept = match role {
Role::Value => &styles.values,
Role::Category => &styles.categories,
Role::Legend => &styles.legend,
};
let mut st = kept.clone().unwrap_or_else(|| CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() });
// A file's style within the limits the Character controls keep to (it is copied onto every label).
let within = |v: f64, lo: f64, hi: f64, or: f64| if v.is_finite() { v.clamp(lo, hi) } else { or };
st.size = within(st.size, 0.1, 1296.0, LABEL_SIZE);
st.leading = st.leading.map(|l| within(l, 0.1, 5000.0, LABEL_SIZE * 1.2));
st.tracking = within(st.tracking, -1000.0, 10000.0, 0.0);
st.h_scale = within(st.h_scale, 1.0, 10000.0, 100.0);
st.v_scale = within(st.v_scale, 1.0, 10000.0, 100.0);
st.stroke_width = within(st.stroke_width, 0.0, 1000.0, 0.0);
st.font_family = st.font_family.chars().take(256).collect();
st.font_style = st.font_style.chars().take(256).collect();
st
}
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
label_style(styles, role).size
}
impl Gen<'_> {
pub(super) fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc<Node> {
Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w)))
}
pub(super) fn line(&mut self, a: Point, b: Point) -> Arc<Node> {
let mut bp = BezPath::new();
bp.move_to(a);
bp.line_to(b);
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 style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
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))))
}
/// 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)
}
pub(super) fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
g.name = Some(name.into());
Arc::new(g)
}
/// A series' line joining its data points (Connect Data Points): a 1 pt stroke in the series colour, or with Draw
/// Filled Lines a band FILLED_LINE_WIDTH wide filled with it and outlined in black.
pub(super) fn data_line(&mut self, g: &GraphSpec, s: usize, pd: PathData, filled: bool) -> Arc<Node> {
let band = if filled {
vectorcraft_pathops::outline_stroke(&pd, FILLED_LINE_WIDTH, vectorcraft_pathops::Cap::Butt, vectorcraft_pathops::Join::Miter, 4.0)
} else {
PathData::default()
};
// A run the outline can't be made for (its points all in one place) stays a thin line.
if !band.is_empty() {
// The band takes the series' color, set as a fill or, on a thin line, as a stroke with paint; a thin black outline.
let slot = g.series_paints.get(s);
let fill =
slot.and_then(|p| p.fill.clone().or_else(|| p.stroke.clone().filter(|st| !st.is_none()))).unwrap_or_else(|| default_series_paint(s));
return self.path(band, fill, Paint::solid(Color::BLACK), 0.25);
}
let (fill, stroke, w) = series_marks(g, s, false, default_series_paint(s), 1.0);
self.path(pd, fill, stroke, w)
}
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
pub(super) fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
match design.and_then(|a| fit_design(self.d, a, cell)) {
Some(n) => self.design_copy(n),
None => self.path(shapes::rectangle(cell), fill, stroke, w),
}
}
/// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design
/// draws nothing else, its [`Self::marker`].
pub(super) fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) {
Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell),
_ => None,
};
match fitted {
Some(n) => self.design_copy(n),
None => self.marker(marker, cell, paint),
}
}
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
/// paths directly inside its group and the design keeps its own paint.
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
}
/// A series group. Empty series are omitted by the caller; the index is stored on the group
/// so later paint edits find the series without counting siblings.
pub(super) fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
g.name = Some(name.into());
g.extras_mut().series_index = u32::try_from(index).ok();
Arc::new(g)
}
}
/// Add Drop Shadow: how far the shadow sits down and right of its mark, and how dark it is.
const SHADOW_OFFSET: f64 = 2.0;
pub(super) const SHADOW_OPACITY: f32 = 0.35;
/// Draw Filled Lines: how wide a filled line is.
const FILLED_LINE_WIDTH: f64 = 3.0;
const FILLED_LINE_BUDGET: usize = 20_000;
/// Does graph `g`, with `series` series, draw Filled Lines (up to FILLED_LINE_BUDGET data points)?
pub(super) fn filled_lines_on(g: &GraphSpec, series: usize) -> bool {
g.filled_lines && g.rows.len().saturating_mul(series) <= FILLED_LINE_BUDGET
}
/// Most marks a graph draws shadows for (each is a copy).
pub(super) const SHADOW_BUDGET: usize = 200_000;
/// Mark `n`'s shadow: a black copy moved down and right, its fill and stroke (whichever it has) black.
pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
// Generated marks carry only paint and a stroke width, so that's all a shadow keeps.
let mut c = n.clone();
c.transform(Affine::translate((SHADOW_OFFSET, SHADOW_OFFSET)), false);
let fill = if c.appearance.fill_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) };
let stroke = if c.appearance.stroke_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) };
c.appearance = Appearance::basic(fill, stroke, c.appearance.stroke_width()).into();
Arc::new(d.reid(&c))
}
/// How wide label `s` is set (its widest line), without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
let style = label_style(styles, Role::Legend);
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
refresh_bounds(&mut t);
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
}
/// A label with nothing to show (empty, or only `|` line breaks).
pub(super) fn blank_label(s: &str) -> bool {
s.chars().all(|c| c == '|')
}
/// Text node `t` with its characters painted `colour`.
pub(super) fn recolour_text(t: &Arc<Node>, colour: Color) -> Arc<Node> {
let mut n = (**t).clone();
if let NodeKind::Text(text) = &mut n.kind {
for run in &mut text.runs {
run.style.fill = Paint::solid(colour);
}
}
Arc::new(n)
}
/// The stretches of two or more points between blank cells, which a line connects.
pub(super) fn runs(pts: &[Option<Point>]) -> Vec<Vec<Point>> {
pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::<Vec<_>>()).filter(|r| r.len() > 1).collect()
}
pub(super) fn polyline(pts: &[Point], closed: bool) -> PathData {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
bp.move_to(*p);
} else {
bp.line_to(*p);
}
}
if closed {
bp.close_path();
}
PathData::from_bezpath(&bp)
}