Skip to main content

autumn_plugin_d3/
chart.rs

1//! The typed chart builder: [`Chart`].
2//!
3//! A chart renders one `<figure data-d3="KIND">` element. The builder writes
4//! `data-d3-*` attributes. `init.js` reads them and draws an SVG. The
5//! figure also holds a data table, so the data is readable without
6//! JavaScript and by screen readers.
7//!
8//! The type parameter is the chart kind. Kind-specific options only
9//! compile on their kinds:
10//!
11//! ```compile_fail
12//! use autumn_plugin_d3::Chart;
13//!
14//! // `donut` is a pie option. A bar chart does not have it.
15//! let _ = Chart::bar([("a", 1.0)]).donut(0.5);
16//! ```
17//!
18//! ```compile_fail
19//! # use autumn_plugin_d3::Chart;
20//! // A bar chart has one series: no legend.
21//! let _ = Chart::bar([("a", 1.0)]).legend(true);
22//! ```
23//!
24//! ```compile_fail
25//! # use autumn_plugin_d3::{Chart, Curve};
26//! // Scatter dots have no curve.
27//! let _ = Chart::scatter([]).curve(Curve::Step);
28//! ```
29//!
30//! ```compile_fail
31//! # use autumn_plugin_d3::Chart;
32//! // Only bar charts are horizontal.
33//! let _ = Chart::line([]).horizontal();
34//! ```
35//!
36//! ```compile_fail
37//! # use autumn_plugin_d3::Chart;
38//! // Pie charts have no value axis.
39//! let _ = Chart::pie([("a", 1.0)]).y_max(1.0);
40//! ```
41
42use 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/// Every attribute the builder can emit. `parse.js` must know each one.
54#[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
81/// Attribute names.
82mod 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
110/// Built-in kind names. A custom kind must not use them.
111pub(crate) const BUILT_IN: [&str; 5] = ["bar", "line", "area", "scatter", "pie"];
112
113/// Limits. `parse.js` uses the same limits.
114const 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
127/// A chart kind. The crate defines all kinds.
128pub trait Kind: sealed::Sealed {}
129/// Kinds with x and y axes: bar, line, area, scatter, custom.
130pub trait Axes: Kind {}
131/// Kinds with a continuous x axis: line, area, scatter, custom.
132pub trait Xy: Axes {}
133/// Kinds with a curve and point dots: line, area, custom.
134pub trait Curved: Xy {}
135/// Kinds with a legend: line, area, scatter, pie.
136pub trait Legend: Kind {}
137/// Built-in kinds. They have a fixed name.
138pub trait BuiltIn: Kind {
139    /// The `data-d3` value.
140    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 chart kind.
159    Bar = "bar": Axes;
160    /// Line chart kind.
161    Line = "line": Axes, Xy, Curved, Legend;
162    /// Area chart kind.
163    Area = "area": Axes, Xy, Curved, Legend;
164    /// Scatter chart kind.
165    Scatter = "scatter": Axes, Xy, Legend;
166    /// Pie and donut chart kind.
167    Pie = "pie": Legend;
168}
169
170/// Custom chart kind. Register the draw function in JavaScript with
171/// `AutumnD3.register(name, draw)`.
172#[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/// Chart data.
181#[derive(Debug, Clone, PartialEq)]
182enum Payload {
183    /// No inline data (`data-d3-src` gives it).
184    Empty,
185    /// Bar and pie data.
186    Categories(Vec<Datum>),
187    /// Line, area, and scatter data.
188    Series(Vec<Series>),
189    /// Custom data, as JSON.
190    Json(String),
191}
192
193/// A D3 chart. Render it in a Maud template.
194///
195/// ```rust
196/// use autumn_plugin_d3::{Chart, Format};
197///
198/// let chart = Chart::bar([("Apples", 3.0), ("Pears", 5.0)])
199///     .label("Fruit sold")
200///     .y_label("Units")
201///     .format(Format::integer());
202/// let html = autumn_web::html! { (chart) }.into_string();
203/// assert!(html.contains(r#"data-d3="bar""#), "{html}");
204/// assert!(html.contains("<table"), "fallback table: {html}");
205/// ```
206#[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    /// Sets an attribute. A later call replaces an earlier one.
234    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    /// Sets a number attribute. A non-finite value removes it.
244    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    /// The value of an emitted `data-d3-*` option attribute, for example
257    /// `"data-d3-label"`. For `data-d3` use [`kind`](Self::kind); for
258    /// `data-d3-data` use [`data_json`](Self::data_json).
259    #[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    /// The `data-d3` kind name.
268    #[must_use]
269    pub fn kind(&self) -> &str {
270        &self.kind
271    }
272
273    /// Sets the element `id`.
274    pub fn id(mut self, id: impl Into<String>) -> Self {
275        self.id = Some(id.into());
276        self
277    }
278
279    /// Adds CSS classes to the figure, after `d3-chart`.
280    pub fn class(mut self, class: impl Into<String>) -> Self {
281        self.class = Some(class.into());
282        self
283    }
284
285    /// Sets the accessible name of the chart. It is also the table caption.
286    /// Without it, the runtime uses the [`caption`](Self::caption) text.
287    pub fn label(self, label: impl Into<String>) -> Self {
288        self.set(attr::LABEL, label)
289    }
290
291    /// Adds a visible `<figcaption>`.
292    pub fn caption(mut self, caption: impl Into<String>) -> Self {
293        self.caption = Some(caption.into());
294        self
295    }
296
297    /// Sets the value format (axis ticks and tooltips).
298    pub fn format(self, format: Format) -> Self {
299        self.set(attr::FORMAT, format.into_spec())
300    }
301
302    /// Sets the x title (bottom axis) and the first table header.
303    pub fn x_label(self, label: impl Into<String>) -> Self {
304        self.set(attr::X_LABEL, label)
305    }
306
307    /// Sets the value title (value axis) and the value table header.
308    pub fn y_label(self, label: impl Into<String>) -> Self {
309        self.set(attr::Y_LABEL, label)
310    }
311
312    /// Sets the series colors, in order. They replace the palette. At most
313    /// 16 colors are used.
314    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    /// Sets the plot width / height ratio. The value is clamped to
327    /// `0.2..=10`. The default is 16 / 9 (from `d3.css`).
328    pub fn aspect(self, ratio: f64) -> Self {
329        self.set_number(attr::ASPECT, ratio.clamp(ASPECT_RANGE.0, ASPECT_RANGE.1))
330    }
331
332    /// Sets the transition time. Zero turns transitions off. At most 10 s.
333    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    /// Animates also for users who ask for reduced motion.
339    pub fn animate_reduced_motion(self) -> Self {
340        self.set(attr::REDUCED, "animate")
341    }
342
343    /// Loads the data from a same-origin JSON URL. The JSON has the same
344    /// shape as the inline data. Inline data, if any, shows first.
345    pub fn src(self, url: impl Into<String>) -> Self {
346        self.set(attr::SRC, url)
347    }
348
349    /// Loads [`src`](Self::src) again at this interval. The interval is
350    /// clamped to 1 s ..= 24 h. Without `src`, the runtime ignores it.
351    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    /// Includes the fallback data table (default `true`).
357    pub const fn table(mut self, show: bool) -> Self {
358        self.table = show;
359        self
360    }
361
362    /// The JSON for `data-d3-data`, if the chart has inline data.
363    #[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    /// The fallback table. Without inline data, an empty wrapper: the
374    /// runtime fills it for built-in kinds after it loads the data.
375    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    /// Makes a chart with no inline data. It loads the data from `url`.
420    ///
421    /// ```rust
422    /// use autumn_plugin_d3::{Bar, Chart};
423    ///
424    /// let chart = Chart::<Bar>::from_src("/api/sales.json");
425    /// assert_eq!(chart.attribute("data-d3-src"), Some("/api/sales.json"));
426    /// ```
427    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    /// Sets the lower end of the value axis. The data can extend it.
434    pub fn y_min(self, min: f64) -> Self {
435        self.set_number(attr::Y_MIN, min)
436    }
437
438    /// Sets the upper end of the value axis. The data can extend it.
439    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    /// Sets the x scale type. With [`Scale::Time`], x is Unix milliseconds.
446    pub fn x_scale(self, scale: Scale) -> Self {
447        self.set(attr::X_SCALE, scale.as_str())
448    }
449
450    /// Sets the y scale type. [`Scale::Time`] is not a y scale; it means
451    /// [`Scale::Linear`].
452    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    /// Sets the x tick format. Time scales ignore it.
462    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    /// Sets the line interpolation.
469    pub fn curve(self, curve: Curve) -> Self {
470        self.set(attr::CURVE, curve.as_str())
471    }
472
473    /// Draws a dot on each point.
474    pub fn dots(self) -> Self {
475        self.set(attr::DOTS, "true")
476    }
477}
478
479impl<K: Legend> Chart<K> {
480    /// Shows or hides the legend. The default shows it for two or more
481    /// series or slices.
482    pub fn legend(self, show: bool) -> Self {
483        self.set(attr::LEGEND, if show { "true" } else { "false" })
484    }
485}
486
487impl Chart<Bar> {
488    /// Makes a bar chart. Repeated labels draw as separate bars.
489    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    /// Draws horizontal bars.
495    pub fn horizontal(self) -> Self {
496        self.set(attr::HORIZONTAL, "true")
497    }
498}
499
500impl Chart<Pie> {
501    /// Makes a pie chart. Repeated labels draw as separate slices.
502    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    /// Makes a donut. `inner` is the hole radius as a fraction of the outer
508    /// radius, clamped to `0..=0.95`.
509    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    /// Makes a line chart.
516    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    /// Makes an area chart. Each series fills down to zero (or to the
526    /// lower end of the value axis).
527    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    /// Makes a scatter chart.
537    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    /// Sets the dot radius in pixels, clamped to `1..=50`. Default 4.
545    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    /// Makes a custom chart. `name` is the kind name that
552    /// `AutumnD3.register` uses in JavaScript.
553    ///
554    /// # Errors
555    ///
556    /// [`Error::InvalidKind`] when `name` is not 1–64 characters of `a-z`,
557    /// `0-9`, and `-`, starting with a letter, or when it is a built-in
558    /// kind name.
559    ///
560    /// ```rust
561    /// use autumn_plugin_d3::Chart;
562    ///
563    /// let chart = Chart::custom("network").unwrap().json(&[1, 2, 3]).unwrap();
564    /// assert_eq!(chart.data_json().as_deref(), Some("[1,2,3]"));
565    /// assert!(Chart::custom("Bad Name").is_err());
566    /// ```
567    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    /// Sets the inline data from any serializable value.
583    ///
584    /// # Errors
585    ///
586    /// [`Error::Json`] when the value does not serialize (for example, a
587    /// map with non-string keys).
588    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    /// The parsed `data-d3-data` JSON.
636    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="[{&quot;label&quot;:&quot;Apples&quot;,&quot;value&quot;:3.0},{&quot;label&quot;:&quot;Pears&quot;,&quot;value&quot;: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("&lt;script&gt;"), "{html}");
821        assert!(html.contains(r#"data-d3-label="a&quot;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            // `10_000` in JS is `10000` in Rust.
879            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="{&quot;nodes&quot;:[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        /// No emitted number attribute is non-finite, whatever the input.
975        #[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        /// Inline data is always valid JSON, and labels round-trip.
992        #[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}