Skip to main content

motion_content/diagram/
chart.rs

1//! Deterministic label placement and bounded Cartesian line charts.
2
3use crate::Scale;
4
5use motion_core::{Node, Paint, Point, Rect, Size, StrokeStyle, ValidationError};
6
7/// A measured label anchored to a local point, with stable priority ordering.
8#[derive(Clone, Copy, Debug)]
9pub struct Label {
10    anchor: Point,
11    size: Size,
12    priority: i32,
13}
14
15impl Label {
16    /// Uses measured dimensions without narrowing paragraph glyph bounds.
17    pub fn new(anchor: Point, size: Size) -> Self {
18        Self {
19            anchor,
20            size,
21            priority: 0,
22        }
23    }
24
25    /// Higher priorities are placed first; equal priorities preserve input order.
26    pub fn priority(mut self, priority: i32) -> Self {
27        self.priority = priority;
28        self
29    }
30}
31
32/// Eight candidate positions per label; unplaceable labels are explicitly omitted.
33/// Placement is greedy: accepted labels are never moved to make room for later ones.
34/// Stable ordering does not guarantee a globally optimal or temporally smooth layout.
35///
36/// ```
37/// use motion_content::prelude::*;
38/// use motion_core::prelude::*;
39/// use motion_core::{
40///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
41///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
42/// };
43/// use motion_render::{Composition, RenderPrepare, RenderOptions, Renderer, render_still};
44/// use motion_content::diagram::*;
45/// let labels = [Label::new(Point::new(50.0, 50.0), Size::new(40.0, 30.0))];
46/// let positions = LabelLayout::new(Rect::new(0.0, 0.0, 100.0, 100.0))
47///     .gap(2.0)
48///     .place(&labels)?;
49/// assert_eq!(positions, [Some(Rect::new(52.0, 18.0, 92.0, 48.0))]);
50/// # Ok::<(), motion_core::ValidationError>(())
51/// ```
52///
53#[derive(Clone, Debug)]
54pub struct LabelLayout {
55    bounds: Rect,
56    gap: f64,
57    obstacles: Vec<Rect>,
58}
59
60impl LabelLayout {
61    /// Creates a placement viewport with a four-pixel separation.
62    pub fn new(bounds: Rect) -> Self {
63        Self {
64            bounds,
65            gap: 4.0,
66            obstacles: Vec::new(),
67        }
68    }
69
70    /// Replaces finite spacing in `0..=1024` pixels.
71    pub fn gap(mut self, pixels: f64) -> Self {
72        self.gap = pixels;
73        self
74    }
75
76    /// Adds fixed artwork to avoid; at most 256 obstacles are accepted by `place`.
77    pub fn obstacle(mut self, bounds: Rect) -> Self {
78        self.obstacles.push(bounds);
79        self
80    }
81
82    /// Returns positions in input order. `None` means no collision-free candidate fits.
83    /// Placement is stateless and bounded to 256 labels and 256 obstacles.
84    /// Invalid geometry, nonpositive label/viewport dimensions, invalid spacing or
85    /// exceeded counts return [`std::io::ErrorKind::InvalidInput`], not omitted labels.
86    pub fn place(&self, labels: &[Label]) -> Result<Vec<Option<Rect>>, ValidationError> {
87        if !valid_label_rect(self.bounds)
88            || self.bounds.is_empty()
89            || !self.gap.is_finite()
90            || !(0.0..=1024.0).contains(&self.gap)
91            || labels.len() > 256
92            || self.obstacles.len() > 256
93            || self.obstacles.iter().any(|rect| !valid_label_rect(*rect))
94            || labels.iter().any(|label| {
95                ![
96                    label.anchor.x,
97                    label.anchor.y,
98                    label.size.width,
99                    label.size.height,
100                ]
101                .iter()
102                .all(|value| value.is_finite())
103                    || label.size.width <= 0.0
104                    || label.size.height <= 0.0
105            })
106        {
107            return Err(invalid("Invalid label geometry, gap or placement budget"));
108        }
109        let mut occupied = self.obstacles.clone();
110        let mut positions = vec![None; labels.len()];
111        let mut order: Vec<_> = (0..labels.len()).collect();
112        order.sort_by_key(|index| (std::cmp::Reverse(labels[*index].priority), *index));
113        for index in order {
114            let label = labels[index];
115            let (left, top) = (label.anchor.x, label.anchor.y);
116            let (width, height) = (label.size.width, label.size.height);
117            let gap = self.gap;
118            let candidates = [
119                (left + gap, top - gap - height),
120                (left - gap - width, top - gap - height),
121                (left + gap, top + gap),
122                (left - gap - width, top + gap),
123                (left - width * 0.5, top - gap - height),
124                (left - width * 0.5, top + gap),
125                (left + gap, top - height * 0.5),
126                (left - gap - width, top - height * 0.5),
127            ];
128            for (left, top) in candidates {
129                let rect = Rect::from_origin_size(Point::new(left, top), label.size);
130                let padded = rect.inset(-gap);
131                if !valid_label_rect(padded) || rect.is_empty() {
132                    return Err(invalid("Label candidate arithmetic is not representable"));
133                }
134                if rect.left >= self.bounds.left
135                    && rect.top >= self.bounds.top
136                    && rect.right <= self.bounds.right
137                    && rect.bottom <= self.bounds.bottom
138                    && occupied
139                        .iter()
140                        .all(|other| padded.intersect(*other).is_empty())
141                {
142                    positions[index] = Some(rect);
143                    occupied.push(rect);
144                    break;
145                }
146            }
147        }
148        Ok(positions)
149    }
150}
151
152fn valid_label_rect(rect: Rect) -> bool {
153    rect.is_finite()
154        && rect.width().is_finite()
155        && rect.height().is_finite()
156        && rect.width() >= 0.0
157        && rect.height() >= 0.0
158}
159
160fn inside(rect: Rect, bounds: Rect) -> bool {
161    rect.left >= bounds.left
162        && rect.top >= bounds.top
163        && rect.right <= bounds.right
164        && rect.bottom <= bounds.bottom
165}
166
167fn overlaps(first: Rect, second: Rect) -> bool {
168    !first.intersect(second).is_empty()
169}
170
171fn invalid(message: &str) -> ValidationError {
172    ValidationError::invalid("chart", "input", message)
173}
174
175fn tick_label_bounds(bounds: Rect, coordinate: f64, size: Size, vertical: bool) -> Rect {
176    let (left, top) = if vertical {
177        (
178            bounds.left - 10.0 - size.width,
179            coordinate - size.height * 0.5,
180        )
181    } else {
182        (coordinate - size.width * 0.5, bounds.bottom + 10.0)
183    };
184    Rect::from_origin_size(Point::new(left, top), size)
185}
186
187#[derive(Clone, Debug)]
188struct Series {
189    points: Vec<Option<(f64, f64)>>,
190    paint: Paint,
191    stroke: StrokeStyle,
192}
193
194/// Multi-series Cartesian curves using caller-supplied linear/log scales.
195/// The plot rectangle excludes tick labels. Their placement viewport extends
196/// 96 pixels to either side, 40 above and 72 below the plot; reserve surrounding space.
197/// Curves/grid are clipped to the plot, while labels remain outside that clip.
198#[derive(Clone, Debug)]
199pub struct LineChart {
200    bounds: Rect,
201    horizontal: Scale,
202    vertical: Scale,
203    series: Vec<Series>,
204    ticks: usize,
205}
206
207impl LineChart {
208    /// Builds curves/grid and project-font measured tick labels without
209    /// system-font discovery. Curves use paints and stroke styles.
210    pub fn node(
211        &self,
212        context: &motion_core::SceneContext<'_>,
213    ) -> motion_core::Result<motion_core::Node> {
214        use motion_core::{NodeModifiers, Shape};
215        let fonts = context
216            .assets()
217            .fonts()
218            .and_then(|environment| environment.downcast_ref::<crate::FontLibrary>())
219            .ok_or_else(|| invalid("charts require explicit project fonts"))?;
220        let bounds = self.bounds;
221        let mut plot = Vec::new();
222        let mut labels = Vec::new();
223        for (vertical, scale) in [(false, self.horizontal), (true, self.vertical)] {
224            for value in scale.nice_ticks(self.ticks)? {
225                let coordinate = scale.map(value)?;
226                let mut path = motion_core::Path2D::new();
227                if vertical {
228                    path.try_move_to(motion_core::Point::new(bounds.left, coordinate))?;
229                    path.try_line_to(motion_core::Point::new(bounds.right, coordinate))?;
230                } else {
231                    path.try_move_to(motion_core::Point::new(coordinate, bounds.top))?;
232                    path.try_line_to(motion_core::Point::new(coordinate, bounds.bottom))?;
233                }
234                plot.push(motion_core::Node::from(
235                    path.stroke(1.0, motion_core::Color::rgb(215, 222, 222)),
236                ));
237                let value = if value != 0.0 && (value.abs() >= 100000.0 || value.abs() < 0.001) {
238                    format!("{value:.1e}")
239                } else {
240                    format!("{value:.3}")
241                        .trim_end_matches('0')
242                        .trim_end_matches('.')
243                        .to_owned()
244                };
245                let text = crate::Text::new(value)
246                    .size(14.0)
247                    .fill(motion_core::Color::rgb(35, 52, 48));
248                let measured = text.layout(fonts)?.bounds();
249                let size = measured.size();
250                labels.push((
251                    tick_label_bounds(self.bounds, coordinate, size, vertical),
252                    text,
253                ));
254            }
255        }
256        for series in &self.series {
257            let stroke = series.stroke;
258            let mut path = motion_core::Path2D::new();
259            let mut drawing = false;
260            for point in &series.points {
261                if let Some((horizontal, vertical)) = point {
262                    let point = motion_core::Point::new(
263                        self.horizontal.map(*horizontal)?,
264                        self.vertical.map(*vertical)?,
265                    );
266                    if drawing {
267                        path.try_line_to(point)?;
268                    } else {
269                        path.try_move_to(point)?;
270                    }
271                    drawing = true;
272                } else {
273                    drawing = false;
274                }
275            }
276            plot.push(Node::from(
277                path.stroke(stroke.width, series.paint.clone())
278                    .stroke_style(stroke),
279            ));
280        }
281        let mut axis = motion_core::Path2D::new();
282        axis.try_move_to(motion_core::Point::new(bounds.left, bounds.top))?;
283        axis.try_line_to(motion_core::Point::new(bounds.left, bounds.bottom))?;
284        axis.try_line_to(motion_core::Point::new(bounds.right, bounds.bottom))?;
285        plot.push(motion_core::Node::from(
286            axis.stroke(1.0, motion_core::Color::BLACK),
287        ));
288        let viewport = Rect::new(
289            bounds.left - 96.0,
290            bounds.top - 40.0,
291            bounds.right + 96.0,
292            bounds.bottom + 72.0,
293        );
294        let mut occupied = Vec::new();
295        let mut nodes = vec![
296            motion_core::Node::group(plot).clip_with(bounds, motion_core::ClipMode::Geometric),
297        ];
298        for (position, text) in labels {
299            let padded = position.inset(-2.0);
300            if inside(padded, viewport) && occupied.iter().all(|other| !overlaps(padded, *other)) {
301                occupied.push(padded);
302                nodes.push(motion_core::Node::from(text).translate(position.left, position.top));
303            }
304        }
305        Ok(motion_core::Node::group(nodes))
306    }
307
308    /// Requires each scale range to match its plot edges, in either direction.
309    pub fn new(bounds: Rect, horizontal: Scale, vertical: Scale) -> Result<Self, ValidationError> {
310        let horizontal_range = horizontal.range();
311        let vertical_range = vertical.range();
312        if !valid_label_rect(bounds)
313            || bounds.is_empty()
314            || bounds.width() > 65536.0
315            || bounds.height() > 65536.0
316            || horizontal_range.0.min(horizontal_range.1) != bounds.left
317            || horizontal_range.0.max(horizontal_range.1) != bounds.right
318            || vertical_range.0.min(vertical_range.1) != bounds.top
319            || vertical_range.0.max(vertical_range.1) != bounds.bottom
320        {
321            return Err(invalid(
322                "Chart scales must span the finite positive plot bounds",
323            ));
324        }
325        Ok(Self {
326            bounds,
327            horizontal,
328            vertical,
329            series: Vec::new(),
330            ticks: 6,
331        })
332    }
333
334    /// Replaces the maximum number of nice ticks per axis, in `2..=32`.
335    pub fn ticks(mut self, count: usize) -> Result<Self, ValidationError> {
336        if !(2..=32).contains(&count) {
337            return Err(invalid("Chart ticks require 2..=32"));
338        }
339        self.ticks = count;
340        Ok(self)
341    }
342
343    /// Adds a curve; all points must be finite and valid in both scales.
344    pub fn series(
345        self,
346        points: impl IntoIterator<Item = (f64, f64)>,
347        paint: impl Into<Paint>,
348        stroke: StrokeStyle,
349    ) -> Result<Self, ValidationError> {
350        self.gapped_series(points.into_iter().map(Some), paint, stroke)
351    }
352
353    /// Adds a curve where `None` breaks the path rather than connecting missing data.
354    /// At most 32 series and 65536 total entries (including gaps) are retained.
355    pub fn gapped_series(
356        mut self,
357        points: impl IntoIterator<Item = Option<(f64, f64)>>,
358        paint: impl Into<Paint>,
359        stroke: StrokeStyle,
360    ) -> Result<Self, ValidationError> {
361        if self.series.len() == 32 {
362            return Err(invalid("Chart exceeds 32 series"));
363        }
364        stroke
365            .validate("chart.series.stroke")
366            .map_err(|error| invalid(&error.to_string()))?;
367        let paint = paint.into();
368        paint
369            .validate()
370            .map_err(|error| invalid(&error.to_string()))?;
371        let remaining = 65536
372            - self
373                .series
374                .iter()
375                .map(|series| series.points.len())
376                .sum::<usize>();
377        let points: Vec<_> = points.into_iter().take(remaining + 1).collect();
378        if points.len() > remaining {
379            return Err(invalid("Chart exceeds 65536 data entries"));
380        }
381        for point in points.iter().flatten() {
382            self.horizontal.map(point.0)?;
383            self.vertical.map(point.1)?;
384        }
385        self.series.push(Series {
386            points,
387            paint,
388            stroke,
389        });
390        Ok(self)
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::FontLibrary;
398    use motion_core::{Assets, PixelSize, SceneContext, Time, Timeline};
399    use motion_core::{Color, LineCap, LineJoin};
400    use motion_core::{Scene, TimeLiteral};
401    use motion_render::{RenderOptions, render_still};
402
403    fn stroke() -> StrokeStyle {
404        StrokeStyle {
405            width: 2.0,
406            cap: LineCap::Round,
407            join: LineJoin::Round,
408        }
409    }
410
411    #[test]
412    fn chart_tick_labels_keep_axis_alignment() {
413        let plot = Rect::new(100.0, 40.0, 500.0, 240.0);
414        let size = Size::new(17.0, 18.0);
415        for coordinate in [100.0, 300.0, 500.0] {
416            let label = tick_label_bounds(plot, coordinate, size, false);
417            assert_eq!(label.left + label.width() * 0.5, coordinate);
418            assert_eq!(label.top, 250.0);
419            assert!(!overlaps(label, plot));
420        }
421        for coordinate in [40.0, 140.0, 240.0] {
422            let label = tick_label_bounds(plot, coordinate, size, true);
423            assert_eq!(label.top + label.height() * 0.5, coordinate);
424            assert_eq!(label.right, 90.0);
425            assert!(!overlaps(label, plot));
426        }
427        let size = Size::new(9.0, 20.0);
428        let horizontal = tick_label_bounds(plot, plot.left, size, false);
429        let vertical = tick_label_bounds(plot, plot.bottom, size, true);
430        let padded = |rect: Rect| rect.inset(-2.0);
431        assert!(!overlaps(padded(horizontal), padded(vertical)));
432    }
433
434    #[test]
435    fn labels_are_stable_bounded_nonoverlapping_and_priority_ordered() -> Result<(), ValidationError>
436    {
437        let layout = LabelLayout::new(Rect::new(0.0, 0.0, 100.0, 100.0)).gap(2.0);
438        let labels = vec![Label::new(Point::new(50.0, 50.0), Size::new(40.0, 30.0)); 12];
439        let positions = layout.place(&labels)?;
440        assert_eq!(positions, layout.place(&labels)?);
441        assert!(positions.iter().any(Option::is_none));
442        let placed: Vec<_> = positions.into_iter().flatten().collect();
443        for (index, rect) in placed.iter().enumerate() {
444            assert!(
445                rect.left >= 0.0 && rect.top >= 0.0 && rect.right <= 100.0 && rect.bottom <= 100.0
446            );
447            assert!(
448                placed[index + 1..]
449                    .iter()
450                    .all(|other| rect.intersect(*other).is_empty())
451            );
452        }
453        let labels = [
454            Label::new(Point::new(50.0, 50.0), Size::new(40.0, 30.0)),
455            Label::new(Point::new(50.0, 50.0), Size::new(40.0, 30.0)).priority(10),
456        ];
457        let positions = layout.place(&labels)?;
458        assert_eq!(positions[1], Some(Rect::new(52.0, 18.0, 92.0, 48.0)));
459        assert!(
460            LabelLayout::new(layout.bounds)
461                .gap(f64::NAN)
462                .place(&labels)
463                .is_err()
464        );
465        Ok(())
466    }
467
468    #[test]
469    fn labels_preserve_sub_f32_coordinates_and_reject_invalid_geometry()
470    -> Result<(), ValidationError> {
471        let layout =
472            LabelLayout::new(Rect::new(100_000_000.0, 0.0, 100_000_100.0, 100.0)).gap(0.25);
473        let labels = [Label::new(
474            Point::new(100_000_000.125, 50.0),
475            Size::new(1.0, 2.0),
476        )];
477        let expected = Some(Rect::new(100_000_000.375, 47.75, 100_000_001.375, 49.75));
478        assert_eq!(layout.place(&labels)?, [expected]);
479        assert_eq!(layout.place(&labels)?, [expected]);
480        assert!(
481            LabelLayout::new(Rect::new(1.0, 0.0, 0.0, 1.0))
482                .place(&labels)
483                .is_err()
484        );
485        assert!(
486            layout
487                .clone()
488                .obstacle(Rect::new(0.0, 0.0, f64::INFINITY, 1.0))
489                .place(&labels)
490                .is_err()
491        );
492        assert!(
493            layout
494                .place(&[Label::new(Point::ORIGIN, Size::new(f64::NAN, 1.0))])
495                .is_err()
496        );
497        assert!(
498            layout
499                .place(&[Label::new(Point::new(f64::MAX, 1.0), Size::new(1.0, 1.0))])
500                .is_err()
501        );
502        assert_eq!(
503            layout.place(&[Label::new(Point::new(1.0, 1.0), Size::new(f64::MAX, 1.0))])?,
504            [None]
505        );
506        Ok(())
507    }
508
509    #[test]
510    fn chart_rejects_bad_scales_values_and_unbounded_input() -> Result<(), ValidationError> {
511        let bounds = Rect::new(100.0, 40.0, 500.0, 240.0);
512        let chart = LineChart::new(
513            bounds,
514            Scale::log10((1.0, 100.0), (100.0, 500.0))?,
515            Scale::linear((0.0, 10.0), (240.0, 40.0))?,
516        )?;
517        let stroke = stroke();
518        let paint = Color::from_hex(0x118677);
519        assert!(chart.clone().series([(0.0, 1.0)], paint, stroke).is_err());
520        assert!(
521            chart
522                .clone()
523                .series([(1.0, f64::NAN)], paint, stroke)
524                .is_err()
525        );
526        assert!(
527            chart
528                .clone()
529                .series(std::iter::repeat((1.0, 1.0)), paint, stroke)
530                .is_err()
531        );
532        assert!(chart.clone().ticks(33).is_err());
533        assert!(
534            chart
535                .gapped_series([Some((1.0, 0.0)), None, Some((100.0, 10.0))], paint, stroke)
536                .is_ok()
537        );
538        Ok(())
539    }
540
541    #[test]
542    #[ignore = "requires explicit MOTION_TEST_FONT; no system discovery"]
543    fn chart_builds_a_valid_scene_with_gaps_and_logarithmic_axes() -> motion_core::Result {
544        let chart = LineChart::new(
545            Rect::new(100.0, 50.0, 500.0, 250.0),
546            Scale::log10((1.0, 100.0), (100.0, 500.0))?,
547            Scale::linear((0.0, 10.0), (250.0, 50.0))?,
548        )?
549        .gapped_series(
550            [
551                Some((1.0, 1.0)),
552                Some((10.0, 5.0)),
553                None,
554                Some((100.0, 9.0)),
555            ],
556            Color::from_hex(0x118677),
557            stroke(),
558        )?;
559        let font =
560            std::env::var_os("MOTION_TEST_FONT").ok_or_else(|| invalid("Set MOTION_TEST_FONT"))?;
561        let mut fonts = FontLibrary::new();
562        fonts.load_file(font)?;
563        let resources = Assets::new(".")?.with_fonts(fonts);
564        let scene = (move |context: &SceneContext<'_>| chart.node(context).expect("valid chart"))
565            .with_duration(1.seconds());
566        let timeline = Timeline::try_new(Size::new(640.0, 360.0), 30)?
567            .try_track(|track| track.try_push(scene))?;
568        let options = RenderOptions {
569            size: Some(PixelSize::new(640, 360)),
570            resources: Some(resources),
571            ..RenderOptions::default()
572        };
573        let first = render_still(&timeline, Time::ZERO, &options)?;
574        let replay = render_still(&timeline, Time::ZERO, &options)?;
575        assert_eq!(first.bytes(), replay.bytes());
576        assert!(
577            first
578                .bytes()
579                .as_chunks::<4>()
580                .0
581                .iter()
582                .filter(|pixel| pixel[3] != 0)
583                .count()
584                > 100
585        );
586        Ok(())
587    }
588}