1mod 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#[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 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 pub fn color(mut self, color: Color) -> Self {
53 self.color = color;
54 self
55 }
56
57 pub fn font_size(mut self, size: f64) -> Self {
59 self.font_size = size;
60 self
61 }
62
63 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 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 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#[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 pub fn horizontal(scale: Scale, baseline: f64) -> Self {
169 Self::new(scale, baseline, false)
170 }
171
172 pub fn vertical(scale: Scale, baseline: f64) -> Self {
174 Self::new(scale, baseline, true)
175 }
176
177 pub fn ticks(mut self, count: usize) -> Self {
179 self.tick_count = count;
180 self.nice = false;
181 self
182 }
183
184 pub fn nice_ticks(mut self, count: usize) -> Self {
186 self.tick_count = count;
187 self.nice = true;
188 self
189 }
190
191 pub fn tick_length(mut self, length: f64) -> Self {
193 self.tick_length = length;
194 self
195 }
196
197 pub fn line_width(mut self, width: f64) -> Self {
199 self.line_width = width;
200 self
201 }
202
203 pub fn font_size(mut self, size: f64) -> Self {
205 self.font_size = size;
206 self
207 }
208
209 pub fn label_size(mut self, size: Size) -> Self {
211 self.label_size = size;
212 self
213 }
214
215 pub fn color(mut self, color: Color) -> Self {
217 self.color = color;
218 self
219 }
220
221 pub fn unit(mut self, unit: impl Into<String>) -> Self {
223 self.unit = unit.into();
224 self
225 }
226
227 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 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}