1use std::borrow::Cow;
43use std::marker::PhantomData;
44use std::time::Duration;
45
46use autumn_web::{Markup, html};
47use maud::Render;
48use serde::Serialize;
49
50use crate::data::{Color, Curve, Datum, Format, Scale, Series, cell, finite, utc};
51use crate::error::Error;
52
53#[cfg(test)]
55const ATTRIBUTES: &[&str] = &[
56 attr::KIND,
57 attr::DATA,
58 attr::SRC,
59 attr::REFRESH,
60 attr::LABEL,
61 attr::X_LABEL,
62 attr::Y_LABEL,
63 attr::FORMAT,
64 attr::X_FORMAT,
65 attr::COLORS,
66 attr::ASPECT,
67 attr::DURATION,
68 attr::REDUCED,
69 attr::LEGEND,
70 attr::X_SCALE,
71 attr::Y_SCALE,
72 attr::CURVE,
73 attr::HORIZONTAL,
74 attr::INNER,
75 attr::DOTS,
76 attr::RADIUS,
77 attr::Y_MIN,
78 attr::Y_MAX,
79];
80
81mod attr {
83 #[cfg(test)]
84 pub const KIND: &str = "data-d3";
85 #[cfg(test)]
86 pub const DATA: &str = "data-d3-data";
87 pub const SRC: &str = "data-d3-src";
88 pub const REFRESH: &str = "data-d3-refresh";
89 pub const LABEL: &str = "data-d3-label";
90 pub const X_LABEL: &str = "data-d3-x-label";
91 pub const Y_LABEL: &str = "data-d3-y-label";
92 pub const FORMAT: &str = "data-d3-format";
93 pub const X_FORMAT: &str = "data-d3-x-format";
94 pub const COLORS: &str = "data-d3-colors";
95 pub const ASPECT: &str = "data-d3-aspect";
96 pub const DURATION: &str = "data-d3-duration";
97 pub const REDUCED: &str = "data-d3-reduced";
98 pub const LEGEND: &str = "data-d3-legend";
99 pub const X_SCALE: &str = "data-d3-x-scale";
100 pub const Y_SCALE: &str = "data-d3-y-scale";
101 pub const CURVE: &str = "data-d3-curve";
102 pub const HORIZONTAL: &str = "data-d3-horizontal";
103 pub const INNER: &str = "data-d3-inner";
104 pub const DOTS: &str = "data-d3-dots";
105 pub const RADIUS: &str = "data-d3-radius";
106 pub const Y_MIN: &str = "data-d3-y-min";
107 pub const Y_MAX: &str = "data-d3-y-max";
108}
109
110pub(crate) const BUILT_IN: [&str; 5] = ["bar", "line", "area", "scatter", "pie"];
112
113const ASPECT_RANGE: (f64, f64) = (0.2, 10.0);
115const MAX_DURATION_MS: u128 = 10_000;
116const MIN_REFRESH_MS: u128 = 1_000;
117const MAX_REFRESH_MS: u128 = 86_400_000;
118const MAX_INNER: f64 = 0.95;
119const RADIUS_RANGE: (f64, f64) = (1.0, 50.0);
120const MAX_COLORS: usize = 16;
121const MAX_KIND_LEN: usize = 64;
122
123mod sealed {
124 pub trait Sealed {}
125}
126
127pub trait Kind: sealed::Sealed {}
129pub trait Axes: Kind {}
131pub trait Xy: Axes {}
133pub trait Curved: Xy {}
135pub trait Legend: Kind {}
137pub trait BuiltIn: Kind {
139 const NAME: &'static str;
141}
142
143macro_rules! kind {
144 ($(#[$doc:meta] $ty:ident = $name:literal: $($tr:ident),*;)*) => {$(
145 #[$doc]
146 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
147 pub struct $ty;
148 impl sealed::Sealed for $ty {}
149 impl Kind for $ty {}
150 impl BuiltIn for $ty {
151 const NAME: &'static str = $name;
152 }
153 $(impl $tr for $ty {})*
154 )*};
155}
156
157kind! {
158 Bar = "bar": Axes;
160 Line = "line": Axes, Xy, Curved, Legend;
162 Area = "area": Axes, Xy, Curved, Legend;
164 Scatter = "scatter": Axes, Xy, Legend;
166 Pie = "pie": Legend;
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct Custom;
174impl sealed::Sealed for Custom {}
175impl Kind for Custom {}
176impl Axes for Custom {}
177impl Xy for Custom {}
178impl Curved for Custom {}
179
180#[derive(Debug, Clone, PartialEq)]
182enum Payload {
183 Empty,
185 Categories(Vec<Datum>),
187 Series(Vec<Series>),
189 Json(String),
191}
192
193#[derive(Debug, Clone, PartialEq)]
207#[must_use]
208pub struct Chart<K: Kind> {
209 kind: Cow<'static, str>,
210 payload: Payload,
211 attrs: Vec<(&'static str, String)>,
212 id: Option<String>,
213 class: Option<String>,
214 caption: Option<String>,
215 table: bool,
216 _kind: PhantomData<K>,
217}
218
219impl<K: Kind> Chart<K> {
220 const fn with(kind: Cow<'static, str>, payload: Payload) -> Self {
221 Self {
222 kind,
223 payload,
224 attrs: Vec::new(),
225 id: None,
226 class: None,
227 caption: None,
228 table: true,
229 _kind: PhantomData,
230 }
231 }
232
233 fn set(mut self, name: &'static str, value: impl Into<String>) -> Self {
235 let value = value.into();
236 match self.attrs.iter_mut().find(|(n, _)| *n == name) {
237 Some(slot) => slot.1 = value,
238 None => self.attrs.push((name, value)),
239 }
240 self
241 }
242
243 fn set_number(self, name: &'static str, value: f64) -> Self {
245 match finite(value) {
246 Some(v) => self.set(name, v.to_string()),
247 None => self.unset(name),
248 }
249 }
250
251 fn unset(mut self, name: &'static str) -> Self {
252 self.attrs.retain(|(n, _)| *n != name);
253 self
254 }
255
256 #[must_use]
260 pub fn attribute(&self, name: &str) -> Option<&str> {
261 self.attrs
262 .iter()
263 .find(|(n, _)| *n == name)
264 .map(|(_, v)| v.as_str())
265 }
266
267 #[must_use]
269 pub fn kind(&self) -> &str {
270 &self.kind
271 }
272
273 pub fn id(mut self, id: impl Into<String>) -> Self {
275 self.id = Some(id.into());
276 self
277 }
278
279 pub fn class(mut self, class: impl Into<String>) -> Self {
281 self.class = Some(class.into());
282 self
283 }
284
285 pub fn label(self, label: impl Into<String>) -> Self {
288 self.set(attr::LABEL, label)
289 }
290
291 pub fn caption(mut self, caption: impl Into<String>) -> Self {
293 self.caption = Some(caption.into());
294 self
295 }
296
297 pub fn format(self, format: Format) -> Self {
299 self.set(attr::FORMAT, format.into_spec())
300 }
301
302 pub fn x_label(self, label: impl Into<String>) -> Self {
304 self.set(attr::X_LABEL, label)
305 }
306
307 pub fn y_label(self, label: impl Into<String>) -> Self {
309 self.set(attr::Y_LABEL, label)
310 }
311
312 pub fn colors(self, colors: impl IntoIterator<Item = Color>) -> Self {
315 let list: Vec<String> = colors
316 .into_iter()
317 .take(MAX_COLORS)
318 .map(|c| c.to_string())
319 .collect();
320 if list.is_empty() {
321 return self.unset(attr::COLORS);
322 }
323 self.set(attr::COLORS, list.join(" "))
324 }
325
326 pub fn aspect(self, ratio: f64) -> Self {
329 self.set_number(attr::ASPECT, ratio.clamp(ASPECT_RANGE.0, ASPECT_RANGE.1))
330 }
331
332 pub fn duration(self, duration: Duration) -> Self {
334 let ms = duration.as_millis().min(MAX_DURATION_MS);
335 self.set(attr::DURATION, ms.to_string())
336 }
337
338 pub fn animate_reduced_motion(self) -> Self {
340 self.set(attr::REDUCED, "animate")
341 }
342
343 pub fn src(self, url: impl Into<String>) -> Self {
346 self.set(attr::SRC, url)
347 }
348
349 pub fn refresh(self, every: Duration) -> Self {
352 let ms = every.as_millis().clamp(MIN_REFRESH_MS, MAX_REFRESH_MS);
353 self.set(attr::REFRESH, ms.to_string())
354 }
355
356 pub const fn table(mut self, show: bool) -> Self {
358 self.table = show;
359 self
360 }
361
362 #[must_use]
364 pub fn data_json(&self) -> Option<String> {
365 match &self.payload {
366 Payload::Empty => None,
367 Payload::Categories(data) => serde_json::to_string(data).ok(),
368 Payload::Series(data) => serde_json::to_string(data).ok(),
369 Payload::Json(json) => Some(json.clone()),
370 }
371 }
372
373 fn table_markup(&self) -> Markup {
376 match &self.payload {
377 Payload::Empty => return html! { div class="d3-table" {} },
378 Payload::Json(_) => return html! {},
379 Payload::Categories(_) | Payload::Series(_) => {}
380 }
381 let caption = self.attribute(attr::LABEL).or(self.caption.as_deref());
382 let x_head = self.attribute(attr::X_LABEL);
383 let y_head = self.attribute(attr::Y_LABEL).unwrap_or("Value");
384 let time = self.attribute(attr::X_SCALE) == Some(Scale::Time.as_str());
385 html! {
386 div class="d3-table" { table {
387 @if let Some(caption) = caption { caption { (caption) } }
388 @match &self.payload {
389 Payload::Categories(data) => {
390 thead { tr { th scope="col" { (x_head.unwrap_or("Label")) } th scope="col" { (y_head) } } }
391 tbody {
392 @for d in data {
393 tr { th scope="row" { (d.label()) } td { (cell(d.value().unwrap_or(f64::NAN))) } }
394 }
395 }
396 }
397 Payload::Series(data) => {
398 thead { tr { th scope="col" { "Series" } th scope="col" { (x_head.unwrap_or("x")) } th scope="col" { (y_head) } } }
399 tbody {
400 @for s in data {
401 @for p in s.points() {
402 tr {
403 th scope="row" { (s.name()) }
404 td { @if time { (utc(p.x)) } @else { (cell(p.x)) } }
405 td { (cell(p.y)) }
406 }
407 }
408 }
409 }
410 }
411 Payload::Empty | Payload::Json(_) => {}
412 }
413 } }
414 }
415 }
416}
417
418impl<K: BuiltIn> Chart<K> {
419 pub fn from_src(url: impl Into<String>) -> Self {
428 Self::with(Cow::Borrowed(K::NAME), Payload::Empty).src(url)
429 }
430}
431
432impl<K: Axes> Chart<K> {
433 pub fn y_min(self, min: f64) -> Self {
435 self.set_number(attr::Y_MIN, min)
436 }
437
438 pub fn y_max(self, max: f64) -> Self {
440 self.set_number(attr::Y_MAX, max)
441 }
442}
443
444impl<K: Xy> Chart<K> {
445 pub fn x_scale(self, scale: Scale) -> Self {
447 self.set(attr::X_SCALE, scale.as_str())
448 }
449
450 pub fn y_scale(self, scale: Scale) -> Self {
453 let scale = if scale == Scale::Time {
454 Scale::Linear
455 } else {
456 scale
457 };
458 self.set(attr::Y_SCALE, scale.as_str())
459 }
460
461 pub fn x_format(self, format: Format) -> Self {
463 self.set(attr::X_FORMAT, format.into_spec())
464 }
465}
466
467impl<K: Curved> Chart<K> {
468 pub fn curve(self, curve: Curve) -> Self {
470 self.set(attr::CURVE, curve.as_str())
471 }
472
473 pub fn dots(self) -> Self {
475 self.set(attr::DOTS, "true")
476 }
477}
478
479impl<K: Legend> Chart<K> {
480 pub fn legend(self, show: bool) -> Self {
483 self.set(attr::LEGEND, if show { "true" } else { "false" })
484 }
485}
486
487impl Chart<Bar> {
488 pub fn bar<D: Into<Datum>>(data: impl IntoIterator<Item = D>) -> Self {
490 let data = data.into_iter().map(Into::into).collect();
491 Self::with(Cow::Borrowed(Bar::NAME), Payload::Categories(data))
492 }
493
494 pub fn horizontal(self) -> Self {
496 self.set(attr::HORIZONTAL, "true")
497 }
498}
499
500impl Chart<Pie> {
501 pub fn pie<D: Into<Datum>>(data: impl IntoIterator<Item = D>) -> Self {
503 let data = data.into_iter().map(Into::into).collect();
504 Self::with(Cow::Borrowed(Pie::NAME), Payload::Categories(data))
505 }
506
507 pub fn donut(self, inner: f64) -> Self {
510 self.set_number(attr::INNER, inner.clamp(0.0, MAX_INNER))
511 }
512}
513
514impl Chart<Line> {
515 pub fn line(series: impl IntoIterator<Item = Series>) -> Self {
517 Self::with(
518 Cow::Borrowed(Line::NAME),
519 Payload::Series(series.into_iter().collect()),
520 )
521 }
522}
523
524impl Chart<Area> {
525 pub fn area(series: impl IntoIterator<Item = Series>) -> Self {
528 Self::with(
529 Cow::Borrowed(Area::NAME),
530 Payload::Series(series.into_iter().collect()),
531 )
532 }
533}
534
535impl Chart<Scatter> {
536 pub fn scatter(series: impl IntoIterator<Item = Series>) -> Self {
538 Self::with(
539 Cow::Borrowed(Scatter::NAME),
540 Payload::Series(series.into_iter().collect()),
541 )
542 }
543
544 pub fn radius(self, px: f64) -> Self {
546 self.set_number(attr::RADIUS, px.clamp(RADIUS_RANGE.0, RADIUS_RANGE.1))
547 }
548}
549
550impl Chart<Custom> {
551 pub fn custom(name: impl Into<String>) -> Result<Self, Error> {
568 let name = name.into();
569 let valid = !name.is_empty()
570 && name.len() <= MAX_KIND_LEN
571 && name.starts_with(|c: char| c.is_ascii_lowercase())
572 && name
573 .bytes()
574 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
575 && !BUILT_IN.contains(&name.as_str());
576 if !valid {
577 return Err(Error::InvalidKind(name));
578 }
579 Ok(Self::with(Cow::Owned(name), Payload::Empty))
580 }
581
582 pub fn json<T: Serialize + ?Sized>(mut self, data: &T) -> Result<Self, Error> {
589 self.payload = Payload::Json(serde_json::to_string(data)?);
590 Ok(self)
591 }
592}
593
594impl<K: Kind> Render for Chart<K> {
595 fn render(&self) -> Markup {
596 let class = self
597 .class
598 .as_ref()
599 .map_or_else(|| "d3-chart".to_owned(), |c| format!("d3-chart {c}"));
600 let a = |name| self.attribute(name);
601 html! {
602 figure class=(class) id=[self.id.as_deref()] data-d3=(self.kind)
603 data-d3-data=[self.data_json()]
604 data-d3-src=[a(attr::SRC)] data-d3-refresh=[a(attr::REFRESH)]
605 data-d3-label=[a(attr::LABEL)]
606 data-d3-x-label=[a(attr::X_LABEL)] data-d3-y-label=[a(attr::Y_LABEL)]
607 data-d3-format=[a(attr::FORMAT)] data-d3-x-format=[a(attr::X_FORMAT)]
608 data-d3-colors=[a(attr::COLORS)] data-d3-aspect=[a(attr::ASPECT)]
609 data-d3-duration=[a(attr::DURATION)] data-d3-reduced=[a(attr::REDUCED)]
610 data-d3-legend=[a(attr::LEGEND)]
611 data-d3-x-scale=[a(attr::X_SCALE)] data-d3-y-scale=[a(attr::Y_SCALE)]
612 data-d3-curve=[a(attr::CURVE)] data-d3-horizontal=[a(attr::HORIZONTAL)]
613 data-d3-inner=[a(attr::INNER)] data-d3-dots=[a(attr::DOTS)]
614 data-d3-radius=[a(attr::RADIUS)]
615 data-d3-y-min=[a(attr::Y_MIN)] data-d3-y-max=[a(attr::Y_MAX)] {
616 @if let Some(caption) = &self.caption {
617 figcaption class="d3-caption" { (caption) }
618 }
619 @if self.table { (self.table_markup()) }
620 }
621 }
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::data::Point;
629 use proptest::prelude::*;
630
631 fn render<K: Kind>(chart: &Chart<K>) -> String {
632 chart.render().into_string()
633 }
634
635 fn data<K: Kind>(chart: &Chart<K>) -> serde_json::Value {
637 let json = chart.data_json().expect("inline data");
638 serde_json::from_str(&json).expect("valid JSON")
639 }
640
641 #[test]
642 fn bar_chart_renders_a_figure_with_kind_data_and_table() {
643 let chart = Chart::bar([("Apples", 3.0), ("Pears", 5.5)])
644 .id("fruit")
645 .label("Fruit");
646 let html = render(&chart);
647 assert!(
648 html.starts_with(r#"<figure class="d3-chart" id="fruit" data-d3="bar""#),
649 "{html}"
650 );
651 assert!(
652 html.contains(r#"data-d3-data="[{"label":"Apples","value":3.0},{"label":"Pears","value":5.5}]""#),
653 "{html}"
654 );
655 assert!(html.contains(r#"data-d3-label="Fruit""#), "{html}");
656 assert!(html.contains("<caption>Fruit</caption>"), "{html}");
657 assert!(
658 html.contains(r#"<th scope="row">Apples</th><td>3</td>"#),
659 "{html}"
660 );
661 assert!(
662 html.contains(r#"<th scope="col">Label</th><th scope="col">Value</th>"#),
663 "{html}"
664 );
665 }
666
667 #[test]
668 fn attributes_follow_the_builder_calls() {
669 let chart = Chart::bar([("a", 1.0)])
670 .x_label("Fruit")
671 .y_label("Kg")
672 .format(Format::decimal(1))
673 .colors([Color::hex(0x00ff_0000), Color::hex(0x0000_ff00)])
674 .aspect(2.0)
675 .duration(Duration::from_millis(250))
676 .animate_reduced_motion()
677 .y_min(0.0)
678 .y_max(10.0)
679 .horizontal()
680 .class("wide");
681 let html = render(&chart);
682 for (name, value) in [
683 ("data-d3-x-label", "Fruit"),
684 ("data-d3-y-label", "Kg"),
685 ("data-d3-format", ",.1f"),
686 ("data-d3-colors", "#ff0000 #00ff00"),
687 ("data-d3-aspect", "2"),
688 ("data-d3-duration", "250"),
689 ("data-d3-reduced", "animate"),
690 ("data-d3-y-min", "0"),
691 ("data-d3-y-max", "10"),
692 ("data-d3-horizontal", "true"),
693 ] {
694 assert_eq!(chart.attribute(name), Some(value), "{name}");
695 assert!(
696 html.contains(&format!(r#"{name}="{value}""#)),
697 "{name}: {html}"
698 );
699 }
700 assert!(
701 html.starts_with(r#"<figure class="d3-chart wide""#),
702 "{html}"
703 );
704 assert!(
705 html.contains(r#"<th scope="col">Fruit</th><th scope="col">Kg</th>"#),
706 "{html}"
707 );
708 }
709
710 #[test]
711 fn later_calls_replace_earlier_ones() {
712 let chart = Chart::bar([("a", 1.0)]).label("one").label("two");
713 assert_eq!(chart.attribute("data-d3-label"), Some("two"));
714 assert_eq!(render(&chart).matches("data-d3-label").count(), 1);
715 let chart = chart.colors([]);
716 assert_eq!(chart.attribute("data-d3-colors"), None);
717 }
718
719 #[test]
720 fn numbers_are_clamped() {
721 let chart = Chart::pie([("a", 1.0)]).donut(2.0).aspect(100.0);
722 assert_eq!(chart.attribute("data-d3-inner"), Some("0.95"));
723 assert_eq!(chart.attribute("data-d3-aspect"), Some("10"));
724 let chart = Chart::pie([("a", 1.0)]).donut(-1.0).aspect(0.0);
725 assert_eq!(chart.attribute("data-d3-inner"), Some("0"));
726 assert_eq!(chart.attribute("data-d3-aspect"), Some("0.2"));
727 let chart = Chart::scatter([]).radius(0.0);
728 assert_eq!(chart.attribute("data-d3-radius"), Some("1"));
729 let chart = Chart::scatter([]).radius(99.0);
730 assert_eq!(chart.attribute("data-d3-radius"), Some("50"));
731 let chart = Chart::bar([("a", 1.0)])
732 .duration(Duration::from_mins(1))
733 .src("/d")
734 .refresh(Duration::from_millis(10));
735 assert_eq!(chart.attribute("data-d3-duration"), Some("10000"));
736 assert_eq!(chart.attribute("data-d3-refresh"), Some("1000"));
737 let chart = chart.refresh(Duration::from_secs(1_000_000));
738 assert_eq!(chart.attribute("data-d3-refresh"), Some("86400000"));
739 }
740
741 #[test]
742 fn non_finite_options_are_dropped() {
743 let chart = Chart::bar([("a", 1.0)])
744 .y_min(1.0)
745 .y_min(f64::NAN)
746 .y_max(f64::INFINITY);
747 assert_eq!(chart.attribute("data-d3-y-min"), None);
748 assert_eq!(chart.attribute("data-d3-y-max"), None);
749 let chart = Chart::pie([("a", 1.0)]).donut(f64::NAN).aspect(f64::NAN);
750 assert_eq!(chart.attribute("data-d3-inner"), None);
751 assert_eq!(chart.attribute("data-d3-aspect"), None);
752 let chart = Chart::scatter([]).radius(f64::NAN);
753 assert_eq!(chart.attribute("data-d3-radius"), None);
754 }
755
756 #[test]
757 fn xy_charts_take_series() {
758 let series = [
759 Series::new("a", [(1.0, 2.0), (2.0, f64::NAN)]),
760 Series::new("b", [Point::new(1.0, 3.0)]),
761 ];
762 let chart = Chart::line(series.clone())
763 .curve(Curve::Monotone)
764 .x_scale(Scale::Log)
765 .y_scale(Scale::Log)
766 .x_format(Format::si(2))
767 .dots();
768 assert_eq!(chart.kind(), "line");
769 assert_eq!(
770 data(&chart),
771 serde_json::json!([
772 {"name": "a", "points": [[1.0, 2.0], [2.0, null]]},
773 {"name": "b", "points": [[1.0, 3.0]]}
774 ])
775 );
776 for (name, value) in [
777 ("data-d3-curve", "monotone"),
778 ("data-d3-x-scale", "log"),
779 ("data-d3-y-scale", "log"),
780 ("data-d3-x-format", ".2~s"),
781 ("data-d3-dots", "true"),
782 ] {
783 assert_eq!(chart.attribute(name), Some(value), "{name}");
784 }
785 let html = render(&chart);
786 assert!(
787 html.contains(
788 r#"<th scope="col">Series</th><th scope="col">x</th><th scope="col">Value</th>"#
789 ),
790 "{html}"
791 );
792 assert!(
793 html.contains(r#"<th scope="row">a</th><td>2</td><td>—</td>"#),
794 "{html}"
795 );
796 assert_eq!(Chart::area(series.clone()).kind(), "area");
797 assert_eq!(Chart::scatter(series).kind(), "scatter");
798 }
799
800 #[test]
801 fn time_scale_is_not_a_y_scale() {
802 let chart = Chart::line([]).y_scale(Scale::Time);
803 assert_eq!(chart.attribute("data-d3-y-scale"), Some("linear"));
804 }
805
806 #[test]
807 fn time_x_values_show_as_dates_in_the_table() {
808 let chart =
809 Chart::line([Series::new("s", [(1_767_225_600_000.0, 1.0)])]).x_scale(Scale::Time);
810 let html = render(&chart);
811 assert!(html.contains("<td>2026-01-01</td><td>1</td>"), "{html}");
812 }
813
814 #[test]
815 fn markup_escapes_text() {
816 let chart = Chart::bar([("<script>", 1.0)]).label("a\"b").caption("<i>");
817 let html = render(&chart);
818 assert!(!html.contains("<script>"), "{html}");
819 assert!(!html.contains("<i>"), "{html}");
820 assert!(html.contains("<script>"), "{html}");
821 assert!(html.contains(r#"data-d3-label="a"b""#), "{html}");
822 }
823
824 #[test]
825 fn caption_renders_a_figcaption() {
826 let html = render(&Chart::pie([("a", 1.0)]).caption("Share"));
827 assert!(
828 html.contains(r#"<figcaption class="d3-caption">Share</figcaption>"#),
829 "{html}"
830 );
831 }
832
833 #[test]
834 fn table_can_be_left_out() {
835 let html = render(&Chart::bar([("a", 1.0)]).table(false));
836 assert!(!html.contains("d3-table"), "{html}");
837 }
838
839 #[test]
840 fn caption_is_the_table_caption_without_a_label() {
841 let html = render(&Chart::bar([("a", 1.0)]).caption("Share"));
842 assert!(html.contains("<caption>Share</caption>"), "{html}");
843 }
844
845 #[test]
846 fn pie_tables_take_column_titles() {
847 let html = render(
848 &Chart::pie([("a", 1.0)])
849 .x_label("Fruit")
850 .y_label("Share")
851 .legend(true),
852 );
853 assert!(
854 html.contains(r#"<th scope="col">Fruit</th><th scope="col">Share</th>"#),
855 "{html}"
856 );
857 assert_eq!(
858 Chart::area([]).dots().attribute("data-d3-dots"),
859 Some("true")
860 );
861 }
862
863 #[test]
864 fn limits_match_parse_js() {
865 let parse = include_str!("../assets/parse.js");
866 let code: String = parse
867 .lines()
868 .filter(|l| !l.trim_start().starts_with("//") && !l.trim_start().starts_with('*'))
869 .collect();
870 for js in [
871 format!("aspect), {}, {})", ASPECT_RANGE.0, ASPECT_RANGE.1),
872 format!("duration), 0, {MAX_DURATION_MS})"),
873 format!("refresh), {MIN_REFRESH_MS}, {MAX_REFRESH_MS})"),
874 format!("inner), 0, {MAX_INNER})"),
875 format!("radius), {}, {})", RADIUS_RANGE.0, RADIUS_RANGE.1),
876 format!("MAX_COLORS = {MAX_COLORS};"),
877 ] {
878 assert!(
880 code.replace('_', "").contains(&js.replace('_', "")),
881 "parse.js code lacks {js}"
882 );
883 }
884 }
885
886 #[test]
887 fn from_src_has_no_inline_data() {
888 let chart = Chart::<Line>::from_src("/api/x").refresh(Duration::from_secs(5));
889 let html = render(&chart);
890 assert!(html.contains(r#"data-d3="line""#), "{html}");
891 assert!(html.contains(r#"data-d3-src="/api/x""#), "{html}");
892 assert!(html.contains(r#"data-d3-refresh="5000""#), "{html}");
893 assert!(!html.contains("data-d3-data"), "{html}");
894 assert!(
895 html.contains(r#"<div class="d3-table"></div>"#),
896 "empty wrapper for the runtime: {html}"
897 );
898 assert!(!html.contains("<table"), "no empty table: {html}");
899 }
900
901 #[test]
902 fn custom_charts_validate_the_kind_name() {
903 for good in ["network", "a", "tree-map2"] {
904 assert!(Chart::custom(good).is_ok(), "{good}");
905 }
906 let long = "a".repeat(65);
907 for bad in ["", "Net", "1a", "a b", "a_b", "bar", "pie", long.as_str()] {
908 assert!(
909 matches!(Chart::custom(bad), Err(Error::InvalidKind(_))),
910 "{bad}"
911 );
912 }
913 }
914
915 #[test]
916 fn custom_charts_carry_json_and_xy_options() {
917 let chart = Chart::custom("network")
918 .expect("valid")
919 .json(&serde_json::json!({"nodes": [1]}))
920 .expect("serializes")
921 .x_label("x")
922 .curve(Curve::Step);
923 let html = render(&chart);
924 assert!(html.contains(r#"data-d3="network""#), "{html}");
925 assert!(
926 html.contains(r#"data-d3-data="{"nodes":[1]}""#),
927 "{html}"
928 );
929 assert!(html.contains(r#"data-d3-curve="step""#), "{html}");
930 assert!(
931 !html.contains("d3-table"),
932 "custom data has no table: {html}"
933 );
934 }
935
936 #[test]
937 fn custom_json_errors_are_reported() {
938 let mut map = std::collections::BTreeMap::new();
939 map.insert((1, 2), 3);
940 let error = Chart::custom("x")
941 .expect("valid")
942 .json(&map)
943 .expect_err("bad keys");
944 assert!(matches!(error, Error::Json(_)), "{error}");
945 }
946
947 #[test]
948 fn every_listed_attribute_is_rendered() {
949 for name in &ATTRIBUTES[2..] {
950 let html = render(&Chart::<Bar>::from_src("/s").set(name, "v"));
951 assert!(html.contains(&format!(r#" {name}="v""#)), "{name}: {html}");
952 }
953 assert_eq!(&ATTRIBUTES[..2], [attr::KIND, attr::DATA]);
954 }
955
956 #[test]
957 fn every_emitted_attribute_is_known_to_the_runtime() {
958 let parse = include_str!("../assets/parse.js");
959 for name in ATTRIBUTES {
960 assert!(
961 parse.contains(&format!("\"{name}\"")),
962 "parse.js lacks {name}"
963 );
964 }
965 for kind in BUILT_IN {
966 assert!(
967 parse.contains(&format!("\"{kind}\"")),
968 "parse.js lacks kind {kind}"
969 );
970 }
971 }
972
973 proptest! {
974 #[test]
976 fn number_attributes_are_always_finite(a in any::<f64>(), b in any::<f64>(), c in any::<f64>()) {
977 let charts = [
978 Chart::bar([("x", a)]).y_min(a).y_max(b).aspect(c).attrs,
979 Chart::pie([("x", a)]).donut(b).attrs,
980 Chart::scatter([Series::new("s", [(a, b)])]).radius(c).attrs,
981 ];
982 for attrs in charts {
983 for (name, value) in attrs {
984 if let Ok(n) = value.parse::<f64>() {
985 prop_assert!(n.is_finite(), "{} = {}", name, value);
986 }
987 }
988 }
989 }
990
991 #[test]
993 fn inline_data_is_valid_json(label in ".*", v in any::<f64>()) {
994 let chart = Chart::bar([(label.clone(), v)]);
995 let parsed = data(&chart);
996 prop_assert_eq!(parsed[0]["label"].as_str(), Some(label.as_str()));
997 prop_assert_eq!(parsed[0]["value"].is_null(), !v.is_finite());
998 }
999 }
1000}