1use crate::Scale;
4
5use motion_core::{Node, Paint, Point, Rect, Size, StrokeStyle, ValidationError};
6
7#[derive(Clone, Copy, Debug)]
9pub struct Label {
10 anchor: Point,
11 size: Size,
12 priority: i32,
13}
14
15impl Label {
16 pub fn new(anchor: Point, size: Size) -> Self {
18 Self {
19 anchor,
20 size,
21 priority: 0,
22 }
23 }
24
25 pub fn priority(mut self, priority: i32) -> Self {
27 self.priority = priority;
28 self
29 }
30}
31
32#[derive(Clone, Debug)]
54pub struct LabelLayout {
55 bounds: Rect,
56 gap: f64,
57 obstacles: Vec<Rect>,
58}
59
60impl LabelLayout {
61 pub fn new(bounds: Rect) -> Self {
63 Self {
64 bounds,
65 gap: 4.0,
66 obstacles: Vec::new(),
67 }
68 }
69
70 pub fn gap(mut self, pixels: f64) -> Self {
72 self.gap = pixels;
73 self
74 }
75
76 pub fn obstacle(mut self, bounds: Rect) -> Self {
78 self.obstacles.push(bounds);
79 self
80 }
81
82 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#[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 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 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 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 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 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), motion_core::fps!(30))?
567 .try_track(|track| track.try_push(scene))?
568 .build()?;
569 let options = RenderOptions {
570 size: Some(PixelSize::new(640, 360)),
571 resources: Some(resources),
572 ..RenderOptions::default()
573 };
574 let first = render_still(&timeline, Time::ZERO, &options)?;
575 let replay = render_still(&timeline, Time::ZERO, &options)?;
576 assert_eq!(first.bytes(), replay.bytes());
577 assert!(
578 first
579 .bytes()
580 .as_chunks::<4>()
581 .0
582 .iter()
583 .filter(|pixel| pixel[3] != 0)
584 .count()
585 > 100
586 );
587 Ok(())
588 }
589}