Skip to main content

motion_content/
diagram.rs

1//! Diagram annotations and tick axes.
2
3mod chart;
4mod scale;
5
6pub use chart::{Label, LabelLayout, LineChart};
7pub use scale::Scale;
8
9use crate::Text;
10
11use motion_core::{
12    Camera, Color, LineCap, LineJoin, Node, NodeModifiers, Path2D, PathVerb, Point, Rect,
13    SceneContext, Shape, Size, StrokeStyle, ValidationError,
14};
15
16fn invalid(message: &str) -> ValidationError {
17    ValidationError::invalid("diagram", "input", message)
18}
19
20fn valid(bounds: Rect) -> bool {
21    bounds.is_finite()
22        && bounds.width().is_finite()
23        && bounds.height().is_finite()
24        && !bounds.is_empty()
25}
26
27/// A text label and leader, with explicitly authored label dimensions.
28#[derive(Clone, Debug)]
29pub struct Callout {
30    text: String,
31    target: Point,
32    bounds: Rect,
33    color: Color,
34    font_size: f64,
35    leader_width: f64,
36}
37
38impl Callout {
39    /// Captures a target and opposite-corner label bounds; validation is deferred.
40    pub fn new(text: impl Into<String>, target: Point, bounds: Rect) -> Self {
41        Self {
42            text: text.into(),
43            target,
44            bounds,
45            color: Color::from_hex(0x23272c),
46            font_size: 14.0,
47            leader_width: 1.5,
48        }
49    }
50
51    /// Replaces the text and leader color.
52    pub fn color(mut self, color: Color) -> Self {
53        self.color = color;
54        self
55    }
56
57    /// Replaces logical font size, valid in (0,4096].
58    pub fn font_size(mut self, size: f64) -> Self {
59        self.font_size = size;
60        self
61    }
62
63    /// Replaces the positive logical leader width.
64    pub fn leader_width(mut self, width: f64) -> Self {
65        self.leader_width = width;
66        self
67    }
68
69    fn attachment(&self) -> Result<Point, ValidationError> {
70        if !valid(self.bounds)
71            || ![
72                self.target.x,
73                self.target.y,
74                self.font_size,
75                self.leader_width,
76            ]
77            .iter()
78            .all(|value| value.is_finite())
79            || self.font_size <= 0.0
80            || self.font_size > 4096.0
81            || self.leader_width <= 0.0
82        {
83            return Err(invalid(
84                "Callout requires finite geometry, positive font/leader sizes and nonempty label bounds",
85            ));
86        }
87        Ok(Point::new(
88            self.target.x.clamp(self.bounds.left, self.bounds.right),
89            self.target.y.clamp(self.bounds.top, self.bounds.bottom),
90        ))
91    }
92
93    /// Builds ordinary text and path nodes without discovering fonts.
94    pub fn node(self) -> motion_core::Result<Node> {
95        let attachment = self.attachment()?;
96        let mut nodes = Vec::new();
97        if attachment != self.target {
98            let mut path = Path2D::new();
99            path.try_move_to(self.target)?;
100            path.try_line_to(attachment)?;
101            nodes.push(Node::from(path.stroke(self.leader_width, self.color)));
102        }
103        nodes.push(
104            Node::from(
105                Text::new(self.text)
106                    .size(self.font_size)
107                    .max_width(self.bounds.width())
108                    .fill(self.color),
109            )
110            .translate(self.bounds.left, self.bounds.top)
111            .clip_with(self.bounds, motion_core::ClipMode::Geometric),
112        );
113        Ok(Node::group(nodes))
114    }
115
116    /// Projects the world target while keeping label dimensions in viewport space.
117    /// Outside targets hide the whole callout, but invalid hidden settings still fail.
118    pub fn node_in(
119        mut self,
120        context: &SceneContext<'_>,
121        camera: Camera,
122    ) -> motion_core::Result<Node> {
123        self.target = camera.world_to_screen(context, self.target)?;
124        self.attachment()?;
125        if !context.bounds().contains(self.target) {
126            return Ok(Node::empty());
127        }
128        Ok(self
129            .node()?
130            .clip_with(context.bounds(), motion_core::ClipMode::Geometric))
131    }
132}
133
134/// A horizontal/vertical axis with fixed-size, nonoverlapping labels.
135#[derive(Clone, Debug)]
136pub struct Ruler {
137    scale: Scale,
138    baseline: f64,
139    vertical: bool,
140    tick_count: usize,
141    nice: bool,
142    tick_length: f64,
143    line_width: f64,
144    font_size: f64,
145    label_size: Size,
146    color: Color,
147    unit: String,
148}
149
150impl Ruler {
151    fn new(scale: Scale, baseline: f64, vertical: bool) -> Self {
152        Self {
153            scale,
154            baseline,
155            vertical,
156            tick_count: 5,
157            nice: false,
158            tick_length: 6.0,
159            line_width: 1.0,
160            font_size: 12.0,
161            label_size: Size::new(96.0, 24.0),
162            color: Color::from_hex(0x23272c),
163            unit: String::new(),
164        }
165    }
166
167    /// Creates an X-axis at the supplied Y coordinate.
168    pub fn horizontal(scale: Scale, baseline: f64) -> Self {
169        Self::new(scale, baseline, false)
170    }
171
172    /// Creates a Y-axis at the supplied X coordinate.
173    pub fn vertical(scale: Scale, baseline: f64) -> Self {
174        Self::new(scale, baseline, true)
175    }
176
177    /// Selects uniform ticks including endpoints; valid count is 2..=256.
178    pub fn ticks(mut self, count: usize) -> Self {
179        self.tick_count = count;
180        self.nice = false;
181        self
182    }
183
184    /// Selects at most the supplied number of aligned ticks; valid budget is 2..=256.
185    pub fn nice_ticks(mut self, count: usize) -> Self {
186        self.tick_count = count;
187        self.nice = true;
188        self
189    }
190
191    /// Replaces finite nonnegative tick length.
192    pub fn tick_length(mut self, length: f64) -> Self {
193        self.tick_length = length;
194        self
195    }
196
197    /// Replaces positive finite axis/mark width.
198    pub fn line_width(mut self, width: f64) -> Self {
199        self.line_width = width;
200        self
201    }
202
203    /// Replaces label font size in (0,4096].
204    pub fn font_size(mut self, size: f64) -> Self {
205        self.font_size = size;
206        self
207    }
208
209    /// Replaces explicitly authored positive label dimensions.
210    pub fn label_size(mut self, size: Size) -> Self {
211        self.label_size = size;
212        self
213    }
214
215    /// Replaces axis and label color.
216    pub fn color(mut self, color: Color) -> Self {
217        self.color = color;
218        self
219    }
220
221    /// Replaces the formatting suffix, without changing physical units.
222    pub fn unit(mut self, unit: impl Into<String>) -> Self {
223        self.unit = unit.into();
224        self
225    }
226
227    /// Builds nodes with two-decimal default numeric labels.
228    pub fn node(&self) -> motion_core::Result<Node> {
229        self.node_with_labels(|value| {
230            let value = if value == 0.0 { 0.0 } else { value };
231            let number = if value != 0.0 && !(0.001..100000.0).contains(&value.abs()) {
232                format!("{value:.2e}")
233            } else {
234                format!("{value:.2}")
235            };
236            if self.unit.is_empty() {
237                number
238            } else {
239                format!("{number} {}", self.unit)
240            }
241        })
242    }
243
244    fn recipe(
245        &self,
246        mut formatter: impl FnMut(f64) -> String,
247    ) -> motion_core::Result<(Path2D, Vec<(String, Rect)>)> {
248        if ![
249            self.baseline,
250            self.tick_length,
251            self.line_width,
252            self.font_size,
253            self.label_size.width,
254            self.label_size.height,
255        ]
256        .iter()
257        .all(|value| value.is_finite())
258            || self.tick_length < 0.0
259            || self.line_width <= 0.0
260            || self.font_size <= 0.0
261            || self.font_size > 4096.0
262            || self.label_size.width <= 0.0
263            || self.label_size.height <= 0.0
264        {
265            return Err(invalid("Invalid ruler geometry or label dimensions").into());
266        }
267        let ticks = if self.nice {
268            self.scale.nice_ticks(self.tick_count)?
269        } else {
270            self.scale.ticks(self.tick_count)?
271        };
272        let point = |coordinate| {
273            if self.vertical {
274                Point::new(self.baseline, coordinate)
275            } else {
276                Point::new(coordinate, self.baseline)
277            }
278        };
279        let range = self.scale.range();
280        let mut outline = Path2D::new();
281        let mut append = |first: Point, second: Point| -> motion_core::Result {
282            let mut path = Path2D::new();
283            path.try_move_to(first)?;
284            path.try_line_to(second)?;
285            let style = StrokeStyle {
286                width: self.line_width,
287                cap: LineCap::Round,
288                join: LineJoin::Round,
289            };
290            for verb in style.outline(&path, "ruler.stroke")?.verbs() {
291                match *verb {
292                    PathVerb::MoveTo(point) => outline.try_move_to(point)?,
293                    PathVerb::LineTo(point) => outline.try_line_to(point)?,
294                    PathVerb::QuadraticTo(control, end) => {
295                        outline.try_quadratic_to(control, end)?
296                    }
297                    PathVerb::CubicTo(first, second, end) => {
298                        outline.try_cubic_to(first, second, end)?
299                    }
300                    PathVerb::Close => outline.try_close()?,
301                }
302            }
303            Ok(())
304        };
305        append(point(range.0), point(range.1))?;
306        let mut labels: Vec<(String, Rect)> = Vec::new();
307        for value in ticks {
308            let coordinate = self.scale.map(value)?;
309            let first = point(coordinate);
310            let end = if self.vertical {
311                Point::new(first.x + self.tick_length, first.y)
312            } else {
313                Point::new(first.x, first.y + self.tick_length)
314            };
315            append(first, end)?;
316            let origin = if self.vertical {
317                Point::new(end.x + 6.0, coordinate - self.label_size.height * 0.5)
318            } else {
319                Point::new(coordinate - self.label_size.width * 0.5, end.y + 6.0)
320            };
321            let bounds = Rect::from_origin_size(origin, self.label_size);
322            if !valid(bounds) {
323                return Err(invalid("Ruler label bounds are not representable").into());
324            }
325            if labels
326                .last()
327                .is_some_and(|(_, previous)| !previous.intersect(bounds).is_empty())
328            {
329                return Err(invalid(
330                    "Ruler label boxes overlap; reduce ticks or adjust label size",
331                )
332                .into());
333            }
334            labels.push((formatter(value), bounds));
335        }
336        Ok((outline, labels))
337    }
338
339    /// Eagerly formats once per tick and returns text plus one union fill.
340    /// The common fill prevents axis/mark intersections from multiplying alpha.
341    pub fn node_with_labels(
342        &self,
343        formatter: impl FnMut(f64) -> String,
344    ) -> motion_core::Result<Node> {
345        let (outline, labels) = self.recipe(formatter)?;
346        let mut nodes = vec![Node::from(outline.fill(self.color))];
347        nodes.extend(labels.into_iter().map(|(text, bounds)| {
348            Node::from(
349                Text::new(text)
350                    .size(self.font_size)
351                    .max_width(bounds.width())
352                    .fill(self.color),
353            )
354            .translate(bounds.left, bounds.top)
355            .clip_with(bounds, motion_core::ClipMode::Geometric)
356        }));
357        Ok(Node::group(nodes))
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn rulers_preserve_axis_ticks_formatting_and_fixed_label_geometry() -> motion_core::Result {
367        let scale = Scale::linear((0.0, 100.0), (100.0, 500.0))?;
368        let ruler = Ruler::horizontal(scale, 30.0).ticks(3).unit("AU");
369        let mut visited = Vec::new();
370        let (outline, labels) = ruler.recipe(|value| {
371            visited.push(value);
372            format!("{value:.2} AU")
373        })?;
374        assert_eq!(visited, [0.0, 50.0, 100.0]);
375        assert_eq!(
376            labels[0],
377            ("0.00 AU".into(), Rect::new(52.0, 42.0, 148.0, 66.0))
378        );
379        assert_eq!(labels.len(), 3);
380        assert!(outline.bounding_box().contains(Point::new(100.0, 30.0)));
381        assert!(outline.bounding_box().contains(Point::new(500.0, 30.0)));
382        let vertical = Ruler::vertical(Scale::log10((1.0, 10000.0), (40.0, 440.0))?, 20.0)
383            .ticks(3)
384            .label_size(Size::new(80.0, 20.0));
385        let mut values = Vec::new();
386        let (_, labels) = vertical.recipe(|value| {
387            values.push(value);
388            format!("{value:.0} km")
389        })?;
390        assert_eq!(values, [1.0, 100.0, 10000.0]);
391        assert_eq!(
392            labels[0],
393            ("1 km".into(), Rect::new(32.0, 30.0, 112.0, 50.0))
394        );
395        Ok(())
396    }
397
398    #[test]
399    fn nice_rulers_keep_selection_replacement_empty_axis_and_density_errors() -> motion_core::Result
400    {
401        let base = Ruler::horizontal(Scale::linear((3.0, 97.0), (100.0, 860.0))?, 30.0);
402        let mut values = Vec::new();
403        base.clone().ticks(1).nice_ticks(6).recipe(|value| {
404            values.push(value);
405            value.to_string()
406        })?;
407        assert_eq!(values, [20.0, 40.0, 60.0, 80.0]);
408        let mut values = Vec::new();
409        base.nice_ticks(1).ticks(3).recipe(|value| {
410            values.push(value);
411            value.to_string()
412        })?;
413        assert_eq!(values, [3.0, 50.0, 97.0]);
414        let (axis, labels) = Ruler::vertical(Scale::linear((3.1, 3.9), (0.0, 100.0))?, 0.0)
415            .nice_ticks(2)
416            .recipe(|_| panic!("no aligned ticks"))?;
417        assert!(labels.is_empty());
418        assert!(!axis.bounding_box().is_empty());
419        let scale = Scale::linear((0.0, 1.0), (0.0, 100.0))?;
420        for count in [1, 5, 257] {
421            assert!(
422                Ruler::horizontal(scale, 0.0)
423                    .nice_ticks(count)
424                    .node()
425                    .is_err()
426            );
427        }
428        let base = Ruler::horizontal(scale, 0.0)
429            .ticks(2)
430            .label_size(Size::new(24.0, 18.0));
431        assert!(base.clone().node().is_ok());
432        assert!(base.clone().tick_length(0.0).node().is_ok());
433        for invalid in [
434            base.clone().line_width(0.0),
435            base.clone().font_size(f64::NAN),
436            base.clone().font_size(4097.0),
437            base.clone().tick_length(-1.0),
438            base.label_size(Size::default()),
439        ] {
440            assert!(invalid.node().is_err());
441        }
442        Ok(())
443    }
444
445    #[test]
446    fn callouts_keep_nearest_attachment_projection_and_hidden_validation() -> motion_core::Result {
447        let bounds = Rect::new(40.0, 20.0, 100.0, 50.0);
448        assert_eq!(
449            Callout::new("Target", Point::new(10.0, 30.0), bounds).attachment()?,
450            Point::new(40.0, 30.0)
451        );
452        assert_eq!(
453            Callout::new("Target", Point::new(50.0, 30.0), bounds).attachment()?,
454            Point::new(50.0, 30.0)
455        );
456        let cache = motion_core::SceneCache::new(0);
457        let context = SceneContext::new(
458            motion_core::Time::ZERO,
459            motion_core::fps!(30),
460            Size::new(320.0, 180.0),
461            &cache,
462        );
463        let view = Camera {
464            zoom: 2.0,
465            ..Camera::default()
466        };
467        assert_eq!(
468            view.world_to_screen(&context, Point::new(10.0, 0.0))?,
469            Point::new(180.0, 90.0)
470        );
471        let hidden =
472            Callout::new("Target", Point::new(200.0, 0.0), bounds).node_in(&context, view)?;
473        assert!(
474            hidden
475                .compile(
476                    context.size(),
477                    motion_core::PixelSize::new(320, 180),
478                    motion_core::ValidationLimits::default()
479                )?
480                .commands
481                .is_empty()
482        );
483        assert!(
484            Callout::new("Target", Point::new(200.0, 0.0), bounds)
485                .font_size(0.0)
486                .node_in(&context, view)
487                .is_err()
488        );
489        Ok(())
490    }
491}