1use std::fmt::Write as _;
43
44use axum::extract::FromRequestParts;
45use axum::http::request::Parts;
46use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
47use minijinja::value::{Kwargs, Value, ValueKind};
48use minijinja::{Error, ErrorKind, State};
49use serde::{Deserialize, Serialize, Serializer};
50
51use crate::db::{DateTime, Dialect, Model, Query};
52use crate::timezone::Zone;
53use crate::toast::escape;
54use crate::view_filters::{format_money, format_number};
55use crate::{AppState, Result};
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct Period {
70 span: Span,
71 back: u32,
72 per: Option<Bucket>,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum Span {
77 Days(u32),
78 Weeks(u32),
79 Months(u32),
80 MonthToDate,
81 YearToDate,
82 Between(NaiveDate, NaiveDate),
84}
85
86const MAX_RANGE_DAYS: i64 = 1096;
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum Bucket {
93 Day,
95 Week,
99 Month,
101}
102
103impl Default for Period {
104 fn default() -> Self {
105 Period::days(30)
106 }
107}
108
109impl Period {
110 fn of(span: Span) -> Self {
111 Self {
112 span,
113 back: 0,
114 per: None,
115 }
116 }
117
118 pub fn days(n: u32) -> Self {
120 Self::of(Span::Days(n.clamp(1, 366)))
121 }
122
123 pub fn weeks(n: u32) -> Self {
126 Self::of(Span::Weeks(n.clamp(1, 104)))
127 }
128
129 pub fn months(n: u32) -> Self {
131 Self::of(Span::Months(n.clamp(1, 36)))
132 }
133
134 pub fn month_to_date() -> Self {
136 Self::of(Span::MonthToDate)
137 }
138
139 pub fn year_to_date() -> Self {
141 Self::of(Span::YearToDate)
142 }
143
144 pub fn between(from: NaiveDate, to: NaiveDate) -> Option<Self> {
148 let end = to.succ_opt()?;
149 let days = (end - from).num_days();
150 (1..=MAX_RANGE_DAYS)
151 .contains(&days)
152 .then(|| Self::of(Span::Between(from, end)))
153 }
154
155 pub fn per(mut self, bucket: Bucket) -> Self {
158 self.per = Some(bucket);
159 self
160 }
161
162 pub fn parse(key: &str) -> Option<Self> {
165 let key = key.trim().to_ascii_lowercase();
166 match key.as_str() {
167 "mtd" => return Some(Self::month_to_date()),
168 "ytd" => return Some(Self::year_to_date()),
169 _ => {}
170 }
171 if let Some((from, to)) = key.split_once("..") {
172 return Self::between(date(from)?, date(to)?);
173 }
174 let (number, unit) = key.split_at(key.len().checked_sub(1)?);
175 let n: u32 = number.parse().ok()?;
176 match unit {
177 "d" if (1..=366).contains(&n) => Some(Self::days(n)),
178 "w" if (1..=104).contains(&n) => Some(Self::weeks(n)),
179 "m" if (1..=36).contains(&n) => Some(Self::months(n)),
180 _ => None,
181 }
182 }
183
184 fn from_query(query: &str) -> Option<Self> {
187 let mut period = None;
188 let (mut from, mut to) = (None, None);
189 for (key, value) in form_urlencoded::parse(query.as_bytes()) {
190 match &*key {
191 "period" if period.is_none() => period = Some(value.into_owned()),
192 "from" if from.is_none() => from = Some(value.into_owned()),
193 "to" if to.is_none() => to = Some(value.into_owned()),
194 _ => {}
195 }
196 }
197 let period = period?;
198 if period.trim().eq_ignore_ascii_case("custom") {
199 Self::between(date(from.as_deref()?)?, date(to.as_deref()?)?)
200 } else {
201 Self::parse(&period)
202 }
203 }
204
205 pub fn key(&self) -> String {
208 match self.span {
209 Span::Days(n) => format!("{n}d"),
210 Span::Weeks(n) => format!("{n}w"),
211 Span::Months(n) => format!("{n}m"),
212 Span::MonthToDate => "mtd".into(),
213 Span::YearToDate => "ytd".into(),
214 Span::Between(start, end) => format!(
215 "{}..{}",
216 start.format("%Y-%m-%d"),
217 (end - Duration::days(1)).format("%Y-%m-%d")
218 ),
219 }
220 }
221
222 pub fn is_custom(&self) -> bool {
224 matches!(self.span, Span::Between(..))
225 }
226
227 pub fn previous(self) -> Self {
231 Self {
232 back: self.back + 1,
233 ..self
234 }
235 }
236
237 pub fn bucket(&self) -> Bucket {
241 if let Some(bucket) = self.per {
242 return bucket;
243 }
244 match self.span {
245 Span::Days(n) if n <= 92 => Bucket::Day,
246 Span::MonthToDate => Bucket::Day,
247 Span::Weeks(_) => Bucket::Week,
248 Span::Between(start, end) => match (end - start).num_days() {
249 ..=92 => Bucket::Day,
250 93..=182 => Bucket::Week,
251 _ => Bucket::Month,
252 },
253 _ => Bucket::Month,
254 }
255 }
256
257 fn days_in(&self, zone: Zone) -> (NaiveDate, NaiveDate) {
259 let today = zone.local(crate::clock::unix_secs()).date();
260 let back = self.back;
261 match self.span {
262 Span::Days(n) => {
263 let end = today + Duration::days(1) - Duration::days(i64::from(n * back));
264 (end - Duration::days(i64::from(n)), end)
265 }
266 Span::Weeks(n) => {
267 let shift = Duration::days(7 * i64::from(n * back));
268 let start = monday_of(today) - Duration::days(7 * i64::from(n - 1));
269 (start - shift, today + Duration::days(1) - shift)
270 }
271 Span::Months(n) => {
272 let this_month = first_of_month(today);
273 let end = add_months(this_month, 1 - (n * back) as i32);
274 (add_months(end, -(n as i32)), end)
275 }
276 Span::MonthToDate => {
277 let start = add_months(first_of_month(today), -(back as i32));
278 let length = i64::from(today.day());
279 let next = add_months(start, 1);
280 (start, (start + Duration::days(length)).min(next))
281 }
282 Span::YearToDate => {
283 let year = today.year() - back as i32;
284 let start = NaiveDate::from_ymd_opt(year, 1, 1).unwrap_or(today);
285 let length = i64::from(today.ordinal());
286 let next = NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap_or(today);
287 (start, (start + Duration::days(length)).min(next))
288 }
289 Span::Between(start, end) => {
290 let shift = (end - start) * back as i32;
291 (start - shift, end - shift)
292 }
293 }
294 }
295
296 pub fn range(&self, zone: Zone) -> (DateTime, DateTime) {
298 let (start, end) = self.days_in(zone);
299 (moment(zone, start), moment(zone, end))
300 }
301
302 pub fn labels(&self, zone: Zone) -> Vec<String> {
305 let (start, end) = self.days_in(zone);
306 let mut labels = Vec::new();
307 match self.bucket() {
308 Bucket::Day => {
309 let mut day = start;
310 while day < end && labels.len() < 400 {
311 labels.push(day.format("%Y-%m-%d").to_string());
312 day += Duration::days(1);
313 }
314 }
315 Bucket::Week => {
316 let mut week = monday_of(start);
317 while week < end && labels.len() < 400 {
318 labels.push(week.format("%Y-%m-%d").to_string());
319 week += Duration::days(7);
320 }
321 }
322 Bucket::Month => {
323 let mut month = first_of_month(start);
324 while month < end && labels.len() < 400 {
325 labels.push(month.format("%Y-%m").to_string());
326 month = add_months(month, 1);
327 }
328 }
329 }
330 labels
331 }
332}
333
334impl Serialize for Period {
335 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
336 serializer.serialize_str(&self.key())
337 }
338}
339
340impl<S: Send + Sync> FromRequestParts<S> for Period {
341 type Rejection = std::convert::Infallible;
342
343 async fn from_request_parts(
344 parts: &mut Parts,
345 _: &S,
346 ) -> std::result::Result<Self, Self::Rejection> {
347 let query = parts.uri.query().unwrap_or_default();
348 Ok(Period::from_query(query).unwrap_or_default())
349 }
350}
351
352fn date(text: &str) -> Option<NaiveDate> {
354 let text = text.trim();
355 (text.len() == 10)
356 .then(|| NaiveDate::parse_from_str(text, "%Y-%m-%d").ok())
357 .flatten()
358}
359
360fn first_of_month(day: NaiveDate) -> NaiveDate {
361 day.with_day(1).unwrap_or(day)
362}
363
364fn monday_of(day: NaiveDate) -> NaiveDate {
366 day - Duration::days(i64::from(day.weekday().num_days_from_monday()))
367}
368
369fn add_months(day: NaiveDate, months: i32) -> NaiveDate {
370 let total = day.year() * 12 + day.month0() as i32 + months;
371 NaiveDate::from_ymd_opt(total.div_euclid(12), total.rem_euclid(12) as u32 + 1, 1).unwrap_or(day)
372}
373
374fn moment(zone: Zone, day: NaiveDate) -> DateTime {
376 let local = day.and_time(NaiveTime::MIN);
377 let unix = zone
378 .resolve(local)
379 .unwrap_or_else(|| local.and_utc().timestamp());
380 DateTime::from_timestamp(unix, 0).unwrap_or_default()
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
387#[non_exhaustive]
388pub struct Series {
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub name: Option<String>,
392 pub labels: Vec<String>,
394 pub values: Vec<f64>,
396}
397
398impl Series {
399 pub fn new(labels: Vec<String>, values: Vec<f64>) -> Self {
401 Self {
402 name: None,
403 labels,
404 values,
405 }
406 }
407
408 pub fn named(mut self, name: impl Into<String>) -> Self {
410 self.name = Some(name.into());
411 self
412 }
413
414 pub fn total(&self) -> f64 {
416 self.values.iter().sum()
417 }
418
419 pub fn change_from(&self, before: &Series) -> Option<f64> {
422 let base = before.total();
423 (base != 0.0).then(|| (self.total() - base) / base.abs() * 100.0)
424 }
425}
426
427pub struct Trend<M> {
435 query: Query<M>,
436 column: String,
437 period: Period,
438}
439
440impl<M: Model> Trend<M> {
441 pub fn of(query: Query<M>, column: &str) -> Self {
444 Self {
445 query,
446 column: column.to_owned(),
447 period: Period::default(),
448 }
449 }
450
451 pub fn over(mut self, period: Period) -> Self {
453 self.period = period;
454 self
455 }
456
457 pub async fn count(self, state: &AppState) -> Result<Series> {
459 self.run(state, "COUNT(*)".to_owned()).await
460 }
461
462 pub async fn sum(self, state: &AppState, column: &str) -> Result<Series> {
464 let column = checked::<M>(column)?;
465 self.run(state, format!("SUM({column})")).await
466 }
467
468 pub async fn average(self, state: &AppState, column: &str) -> Result<Series> {
470 let column = checked::<M>(column)?;
471 self.run(state, format!("AVG({column})")).await
472 }
473
474 async fn run(self, state: &AppState, aggregate: String) -> Result<Series> {
475 let zone: Zone = state.config.timezone;
476 let column = checked::<M>(&self.column)?;
477 let column = column.as_str();
478 let (start, end) = self.period.range(zone);
479 let minutes = zone.offset_at(end.timestamp()) / 60;
481 let step = self.period.bucket();
482 let bucket = move |dialect: Dialect| match (dialect, step) {
485 (Dialect::Sqlite, Bucket::Week) => {
486 format!("date({column}, '{minutes:+} minutes', 'weekday 0', '-6 days')")
487 }
488 (Dialect::Sqlite, step) => format!(
489 "strftime('{}', {column}, '{minutes:+} minutes')",
490 if step == Bucket::Month {
491 "%Y-%m"
492 } else {
493 "%Y-%m-%d"
494 }
495 ),
496 (Dialect::Postgres, Bucket::Week) => format!(
497 "to_char(date_trunc('week', ({column} AT TIME ZONE 'UTC') + interval '{minutes} minutes'), 'YYYY-MM-DD')"
498 ),
499 (Dialect::Postgres, step) => format!(
500 "to_char(({column} AT TIME ZONE 'UTC') + interval '{minutes} minutes', '{}')",
501 if step == Bucket::Month {
502 "YYYY-MM"
503 } else {
504 "YYYY-MM-DD"
505 }
506 ),
507 };
508 let condition = format!("{column} >= ? AND {column} < ?");
509 let rows = self
510 .query
511 .where_raw(&condition, [start, end])
512 .buckets(&state.db, &bucket, &aggregate)
513 .await?;
514 let found: std::collections::HashMap<String, f64> = rows
515 .into_iter()
516 .map(|(label, value)| (label, value.unwrap_or(0.0)))
517 .collect();
518 let labels = self.period.labels(zone);
519 let values = labels
520 .iter()
521 .map(|label| found.get(label).copied().unwrap_or(0.0))
522 .collect();
523 Ok(Series::new(labels, values))
524 }
525}
526
527fn checked<M: Model>(column: &str) -> Result<String> {
529 if M::COLUMNS.contains(&column) {
530 Ok(format!("\"{column}\""))
531 } else {
532 Err(anyhow::anyhow!("`{}` has no column `{column}`", M::TABLE).into())
533 }
534}
535
536struct Line {
541 name: String,
542 values: Vec<Option<f64>>,
543}
544
545struct Data {
547 labels: Vec<String>,
548 series: Vec<Line>,
549}
550
551fn number_of(value: &Value) -> Option<f64> {
552 if value.is_none() || value.is_undefined() {
553 return None;
554 }
555 if let Ok(i) = i64::try_from(value.clone()) {
556 return Some(i as f64);
557 }
558 if let Some(text) = value.as_str() {
559 return text.trim().parse().ok();
560 }
561 f64::try_from(value.clone()).ok()
562}
563
564fn strings(value: &Value) -> Vec<String> {
565 match value.try_iter() {
566 Ok(items) if value.kind() == ValueKind::Seq => items
567 .map(|v| {
568 v.as_str()
569 .map(str::to_owned)
570 .unwrap_or_else(|| v.to_string())
571 })
572 .collect(),
573 _ => Vec::new(),
574 }
575}
576
577fn numbers(value: &Value) -> Vec<Option<f64>> {
578 match value.try_iter() {
579 Ok(items) if value.kind() == ValueKind::Seq => items.map(|v| number_of(&v)).collect(),
580 _ => Vec::new(),
581 }
582}
583
584fn attr(value: &Value, key: &str) -> Option<Value> {
585 value
586 .get_attr(key)
587 .ok()
588 .filter(|v| !v.is_undefined() && !v.is_none())
589}
590
591fn line_of(value: &Value, fallback: &str) -> Line {
593 if value.kind() == ValueKind::Map {
594 Line {
595 name: attr(value, "name")
596 .and_then(|n| n.as_str().map(str::to_owned))
597 .unwrap_or_else(|| fallback.to_owned()),
598 values: attr(value, "values")
599 .map(|v| numbers(&v))
600 .unwrap_or_default(),
601 }
602 } else {
603 Line {
604 name: fallback.to_owned(),
605 values: numbers(value),
606 }
607 }
608}
609
610fn read_data(
611 data: Option<Value>,
612 kwargs: &Kwargs,
613 title: &str,
614) -> std::result::Result<Data, Error> {
615 let labels: Option<Value> = kwargs.get("labels")?;
616 let series: Option<Value> = kwargs.get("series")?;
617 let values: Option<Value> = kwargs.get("values")?;
618 let name: Option<String> = kwargs.get("name")?;
619 let fallback = name.clone().unwrap_or_else(|| title.to_owned());
620 let mut out = Data {
621 labels: labels.as_ref().map(strings).unwrap_or_default(),
622 series: Vec::new(),
623 };
624 let list_of_series = |value: &Value, out: &mut Data| {
625 if let Ok(items) = value.try_iter() {
626 for (i, item) in items.enumerate() {
627 out.series
628 .push(line_of(&item, &format!("{} {}", fallback, i + 1)));
629 }
630 }
631 };
632 match data {
633 Some(data) if data.kind() == ValueKind::Map => {
634 if out.labels.is_empty()
635 && let Some(labels) = attr(&data, "labels")
636 {
637 out.labels = strings(&labels);
638 }
639 match attr(&data, "series") {
640 Some(series) => list_of_series(&series, &mut out),
641 None => out.series.push(line_of(&data, &fallback)),
642 }
643 }
644 Some(data) if data.kind() == ValueKind::Seq => {
645 let first_is_map = data
646 .try_iter()
647 .ok()
648 .and_then(|mut items| items.next())
649 .is_some_and(|v| v.kind() == ValueKind::Map);
650 if first_is_map {
651 list_of_series(&data, &mut out);
652 } else {
653 out.series.push(line_of(&data, &fallback));
654 }
655 }
656 _ => {}
657 }
658 if let Some(series) = series {
659 list_of_series(&series, &mut out);
660 }
661 if let Some(values) = values {
662 out.series.push(Line {
663 name: fallback.clone(),
664 values: numbers(&values),
665 });
666 }
667 if let Some(name) = name
668 && out.series.len() == 1
669 {
670 out.series[0].name = name;
671 }
672 let longest = out.series.iter().map(|s| s.values.len()).max().unwrap_or(0);
673 if out.labels.len() < longest {
674 for i in out.labels.len()..longest {
675 out.labels.push((i + 1).to_string());
676 }
677 }
678 let n = out.labels.len();
679 for line in &mut out.series {
680 line.values.resize(n, None);
681 }
682 Ok(out)
683}
684
685fn locale(state: &State) -> String {
687 state
688 .lookup("app")
689 .and_then(|app| app.get_attr("locale").ok())
690 .and_then(|l| l.as_str().map(str::to_owned))
691 .unwrap_or_else(|| "en".into())
692}
693
694fn text(state: &State, key: &str, fallback_locale: &str) -> String {
695 state
696 .lookup("t")
697 .and_then(|t| t.call(state, &[Value::from(key)]).ok())
698 .and_then(|v| v.as_str().map(str::to_owned))
699 .unwrap_or_else(|| crate::i18n::builtin_text(fallback_locale, key))
700}
701
702const MONTHS: [&str; 12] = [
703 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
704];
705
706fn display_label(label: &str, format: Option<&str>) -> String {
709 if let Ok(day) = NaiveDate::parse_from_str(label, "%Y-%m-%d") {
710 return match format {
711 Some(format) => day.format(format).to_string(),
712 None => format!("{} {}", MONTHS[day.month0() as usize], day.day()),
713 };
714 }
715 if let Ok(month) = NaiveDate::parse_from_str(&format!("{label}-01"), "%Y-%m-%d") {
716 return match format {
717 Some(format) => month.format(format).to_string(),
718 None => format!("{} {}", MONTHS[month.month0() as usize], month.year()),
719 };
720 }
721 label.to_owned()
722}
723
724struct Formatter {
726 format: String,
727 decimals: Option<u32>,
728 currency: String,
729 locale: String,
730}
731
732impl Formatter {
733 fn full(&self, value: f64) -> String {
734 match self.format.as_str() {
735 "money" => format_money(value, &self.currency, self.decimals, &self.locale),
736 "percent" => format!(
737 "{}%",
738 format_number(value, self.decimals.unwrap_or(1), &self.locale)
739 ),
740 _ => format_number(value, self.decimals.unwrap_or(0), &self.locale),
741 }
742 }
743
744 fn tick(&self, value: f64) -> String {
747 let units: [(f64, &str); 4] = [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
748 let suffix = if self.format == "percent" { "%" } else { "" };
749 if value.abs() >= 10_000.0 {
750 for (size, unit) in units {
751 if value.abs() >= size {
752 let scaled = value / size;
753 let decimals = if scaled.fract().abs() < 1e-9 { 0 } else { 1 };
754 return format!(
755 "{}{unit}{suffix}",
756 format_number(scaled, decimals, &self.locale)
757 );
758 }
759 }
760 }
761 let decimals = if value.fract().abs() < 1e-9 { 0 } else { 1 };
762 format!("{}{suffix}", format_number(value, decimals, &self.locale))
763 }
764}
765
766fn scale(lo: f64, hi: f64) -> (f64, f64, f64) {
769 nice(lo.min(0.0), hi.max(0.0))
770}
771
772fn nice(mut lo: f64, mut hi: f64) -> (f64, f64, f64) {
774 if (hi - lo).abs() < f64::EPSILON {
775 if lo == 0.0 {
776 hi = lo + 1.0;
777 } else {
778 let pad = lo.abs() / 10.0;
779 (lo, hi) = (lo - pad, hi + pad);
780 }
781 }
782 let raw = (hi - lo) / 4.0;
783 let magnitude = 10f64.powf(raw.log10().floor());
784 let normal = raw / magnitude;
785 let nice = if normal <= 1.0 {
786 1.0
787 } else if normal <= 2.0 {
788 2.0
789 } else if normal <= 2.5 {
790 2.5
791 } else if normal <= 5.0 {
792 5.0
793 } else {
794 10.0
795 };
796 let step = nice * magnitude;
797 lo = (lo / step).floor() * step;
798 hi = (hi / step).ceil() * step;
799 (lo, hi, step)
800}
801
802fn slot(i: usize) -> String {
803 if i < 6 {
804 format!("rx-series-{}", i + 1)
805 } else {
806 "rx-series-other".into()
807 }
808}
809
810fn pct(value: f64) -> String {
811 let rounded = (value * 100.0).round() / 100.0;
812 format!("{rounded}")
813}
814
815pub(crate) fn chart(
817 currency: String,
818) -> impl Fn(&State, String, Option<Value>, Kwargs) -> std::result::Result<Value, Error>
819+ Send
820+ Sync
821+ 'static {
822 move |state: &State, kind: String, data: Option<Value>, kwargs: Kwargs| {
823 let title: Option<String> = kwargs.get("title")?;
824 let height: Option<u32> = kwargs.get("height")?;
825 let format: Option<String> = kwargs.get("format")?;
826 let decimals: Option<u32> = kwargs.get("decimals")?;
827 let currency_kw: Option<String> = kwargs.get("currency")?;
828 let stacked: Option<bool> = kwargs.get("stacked")?;
829 let legend: Option<bool> = kwargs.get("legend")?;
830 let table: Option<bool> = kwargs.get("table")?;
831 let x_format: Option<String> = kwargs.get("x_format")?;
832 let x_title: Option<String> = kwargs.get("x_title")?;
833 let y_title: Option<String> = kwargs.get("y_title")?;
834 let id: Option<String> = kwargs.get("id")?;
835 let locale = locale(state);
836 let title = title.unwrap_or_default();
837 let currency = currency_kw
838 .map(|c| c.trim().to_ascii_uppercase())
839 .unwrap_or_else(|| currency.clone());
840 let formatter = |format: Option<String>, decimals: Option<u32>| Formatter {
841 format: format.unwrap_or_else(|| "number".into()),
842 decimals,
843 currency: currency.clone(),
844 locale: locale.clone(),
845 };
846 let mut options = Options {
847 kind: kind.clone(),
848 title: title.clone(),
849 height: height.unwrap_or(240).clamp(80, 800),
850 stacked: stacked.unwrap_or(false),
851 legend: legend.unwrap_or(true),
852 table: table.unwrap_or(true),
853 labels: Vec::new(),
854 id,
855 show_data: text(state, "ui.chart.show_data", &locale),
856 other: text(state, "ui.chart.other", &locale),
857 series_head: text(state, "ui.chart.series", &locale),
858 axes: text(state, "ui.chart.axes", &locale),
859 x_title,
860 y_title,
861 };
862 let html = match kind.as_str() {
863 "scatter" | "bubble" => {
864 let size_format: Option<String> = kwargs.get("size_format")?;
865 let size_title: Option<String> = kwargs.get("size_title")?;
866 let clouds = read_points(data, &kwargs, &title)?;
867 kwargs.assert_all_used()?;
868 let all = || clouds.iter().flat_map(|c| c.points.iter());
869 let formats = Formats {
870 x: formatter(x_format, auto_decimals(all().map(|p| p.x))),
871 y: formatter(
872 format,
873 decimals.or_else(|| auto_decimals(all().map(|p| p.y))),
874 ),
875 size: formatter(size_format, auto_decimals(all().filter_map(|p| p.size))),
876 size_title: size_title.unwrap_or_else(|| text(state, "ui.chart.size", &locale)),
877 };
878 render_points(&clouds, &options, &formats)
879 }
880 "line" | "area" | "bar" | "pie" | "doughnut" => {
881 let data = read_data(data, &kwargs, &title)?;
882 kwargs.assert_all_used()?;
883 options.labels = data
884 .labels
885 .iter()
886 .map(|l| display_label(l, x_format.as_deref()))
887 .collect();
888 let decimals = match format.as_deref() {
891 None | Some("number") => decimals.or_else(|| {
892 auto_decimals(
893 data.series
894 .iter()
895 .flat_map(|s| s.values.iter().flatten().copied()),
896 )
897 }),
898 _ => decimals,
899 };
900 let formatter = formatter(format, decimals);
901 if kind == "pie" || kind == "doughnut" {
902 render_pie(&data, &options, &formatter)
903 } else {
904 render_xy(&data, &options, &formatter)
905 }
906 }
907 other => {
908 return Err(Error::new(
909 ErrorKind::InvalidOperation,
910 format!(
911 "chart: unknown kind `{other}` (line, area, bar, pie, doughnut, scatter or bubble)"
912 ),
913 ));
914 }
915 };
916 Ok(Value::from_safe_string(html))
917 }
918}
919
920struct Options {
921 kind: String,
922 title: String,
923 height: u32,
924 stacked: bool,
925 legend: bool,
926 table: bool,
927 labels: Vec<String>,
928 id: Option<String>,
929 show_data: String,
930 other: String,
931 series_head: String,
933 axes: String,
935 x_title: Option<String>,
937 y_title: Option<String>,
938}
939
940#[derive(Serialize)]
942struct Hover<'a> {
943 kind: &'a str,
944 labels: &'a [String],
945 series: Vec<HoverSeries>,
946}
947
948#[derive(Serialize)]
949struct HoverSeries {
950 name: String,
951 slot: String,
952 values: Vec<Option<String>>,
953}
954
955fn open_figure(out: &mut String, options: &Options, hover: &Hover) {
956 let json = serde_json::to_string(hover).unwrap_or_default();
957 let _ = write!(
958 out,
959 r#"<figure class="rx-chart rx-chart--{kind}"{id} data-rx-chart="{json}">"#,
960 kind = escape(&options.kind),
961 id = options
962 .id
963 .as_deref()
964 .map(|id| format!(r#" id="{}""#, escape(id)))
965 .unwrap_or_default(),
966 json = escape(&json),
967 );
968 if !options.title.is_empty() {
969 let _ = write!(
970 out,
971 r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
972 escape(&options.title)
973 );
974 }
975}
976
977fn legend(out: &mut String, names: &[(usize, &str)], key: &str) {
978 out.push_str(r#"<ul class="rx-chart__legend" role="list">"#);
979 for (i, name) in names {
980 let _ = write!(
981 out,
982 r#"<li><span class="rx-chart__key rx-chart__key--{key} {slot}" aria-hidden="true"></span>{name}</li>"#,
983 slot = slot(*i),
984 name = escape(name),
985 );
986 }
987 out.push_str("</ul>");
988}
989
990fn table(out: &mut String, options: &Options, heads: &[String], rows: &[(String, Vec<String>)]) {
991 if !options.table {
992 return;
993 }
994 let _ = write!(
995 out,
996 r#"<details class="rx-chart__table"><summary>{}</summary><div class="rx-table-wrap"><table class="rx-table"><thead><tr><th scope="col"></th>"#,
997 escape(&options.show_data)
998 );
999 for head in heads {
1000 let _ = write!(
1001 out,
1002 r#"<th scope="col" class="rx-num">{}</th>"#,
1003 escape(head)
1004 );
1005 }
1006 out.push_str("</tr></thead><tbody>");
1007 for (label, cells) in rows {
1008 let _ = write!(out, r#"<tr><th scope="row">{}</th>"#, escape(label));
1009 for cell in cells {
1010 let _ = write!(out, r#"<td class="rx-num">{}</td>"#, escape(cell));
1011 }
1012 out.push_str("</tr>");
1013 }
1014 out.push_str("</tbody></table></div></details>");
1015}
1016
1017fn render_xy(data: &Data, options: &Options, formatter: &Formatter) -> String {
1018 let n = data.labels.len();
1019 let bar = options.kind == "bar";
1020 let stacked = bar && options.stacked;
1021 let (mut lo, mut hi) = (0.0f64, 0.0f64);
1023 for i in 0..n {
1024 let (mut up, mut down) = (0.0, 0.0);
1025 for line in &data.series {
1026 if let Some(v) = line.values[i] {
1027 if stacked {
1028 if v >= 0.0 { up += v } else { down += v }
1029 } else {
1030 hi = hi.max(v);
1031 lo = lo.min(v);
1032 }
1033 }
1034 }
1035 hi = hi.max(up);
1036 lo = lo.min(down);
1037 }
1038 let (lo, hi, step) = scale(lo, hi);
1039 let y = |v: f64| (v - lo) / (hi - lo) * 100.0;
1040 let zero = y(0.0);
1041
1042 let hover = Hover {
1043 kind: &options.kind,
1044 labels: &options.labels,
1045 series: data
1046 .series
1047 .iter()
1048 .enumerate()
1049 .map(|(i, line)| HoverSeries {
1050 name: line.name.clone(),
1051 slot: slot(i),
1052 values: line
1053 .values
1054 .iter()
1055 .map(|v| v.map(|v| formatter.full(v)))
1056 .collect(),
1057 })
1058 .collect(),
1059 };
1060 let mut out = String::new();
1061 open_figure(&mut out, options, &hover);
1062 if options.legend && data.series.len() > 1 {
1063 let names: Vec<(usize, &str)> = data
1064 .series
1065 .iter()
1066 .enumerate()
1067 .map(|(i, s)| (i, s.name.as_str()))
1068 .collect();
1069 legend(&mut out, &names, if bar { "box" } else { "line" });
1070 }
1071 y_title(&mut out, options);
1072 let _ = write!(
1073 out,
1074 r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1075 options.height
1076 );
1077 let mut ticks = Vec::new();
1078 let mut tick = lo;
1079 while tick <= hi + step / 2.0 && ticks.len() < 12 {
1080 ticks.push(tick);
1081 tick += step;
1082 }
1083 for t in &ticks {
1084 let _ = write!(
1085 out,
1086 r#"<span style="bottom: {}%">{}</span>"#,
1087 pct(y(*t)),
1088 escape(&formatter.tick(*t))
1089 );
1090 }
1091 let label = if options.title.is_empty() {
1092 data.series
1093 .iter()
1094 .map(|s| s.name.as_str())
1095 .collect::<Vec<_>>()
1096 .join(", ")
1097 } else {
1098 options.title.clone()
1099 };
1100 let _ = write!(
1101 out,
1102 r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1103 escape(&label)
1104 );
1105 for t in &ticks {
1106 let base = if t.abs() < step / 1e6 {
1107 " rx-chart__rule--base"
1108 } else {
1109 ""
1110 };
1111 let _ = write!(
1112 out,
1113 r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1114 pct(y(*t))
1115 );
1116 }
1117 out.push_str("</div>");
1118
1119 if bar {
1120 out.push_str(r#"<div class="rx-chart__bars" aria-hidden="true">"#);
1121 for i in 0..n {
1122 let _ = write!(out, r#"<div class="rx-chart__band" data-index="{i}">"#);
1123 if stacked {
1124 out.push_str(r#"<div class="rx-chart__column">"#);
1125 let (mut up, mut down) = (0.0, 0.0);
1126 let tops: Vec<usize> = {
1127 let mut last_up = None;
1129 let mut last_down = None;
1130 for (s, line) in data.series.iter().enumerate() {
1131 match line.values[i] {
1132 Some(v) if v > 0.0 => last_up = Some(s),
1133 Some(v) if v < 0.0 => last_down = Some(s),
1134 _ => {}
1135 }
1136 }
1137 last_up.into_iter().chain(last_down).collect()
1138 };
1139 for (s, line) in data.series.iter().enumerate() {
1140 let Some(v) = line.values[i].filter(|v| *v != 0.0) else {
1141 continue;
1142 };
1143 let (from, to) = if v > 0.0 {
1144 let from = up;
1145 up += v;
1146 (from, up)
1147 } else {
1148 let from = down;
1149 down += v;
1150 (down, from)
1151 };
1152 let classes = format!(
1153 "rx-chart__bar {}{}{}",
1154 slot(s),
1155 if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1156 if tops.contains(&s) {
1157 " rx-chart__bar--end"
1158 } else {
1159 ""
1160 },
1161 );
1162 let _ = write!(
1163 out,
1164 r#"<span class="{classes}" style="bottom: {}%; height: {}%"></span>"#,
1165 pct(y(from)),
1166 pct(y(to) - y(from)),
1167 );
1168 }
1169 out.push_str("</div>");
1170 } else {
1171 for (s, line) in data.series.iter().enumerate() {
1172 out.push_str(r#"<div class="rx-chart__column">"#);
1173 if let Some(v) = line.values[i].filter(|v| *v != 0.0) {
1174 let (from, to) = if v > 0.0 { (0.0, v) } else { (v, 0.0) };
1175 let _ = write!(
1176 out,
1177 r#"<span class="rx-chart__bar rx-chart__bar--end {}{}" style="bottom: {}%; height: {}%"></span>"#,
1178 slot(s),
1179 if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1180 pct(y(from)),
1181 pct(y(to) - y(from)),
1182 );
1183 }
1184 out.push_str("</div>");
1185 }
1186 }
1187 out.push_str("</div>");
1188 }
1189 out.push_str("</div>");
1190 } else {
1191 let x = |i: usize| {
1192 if n > 1 {
1193 i as f64 / (n - 1) as f64 * 1000.0
1194 } else {
1195 500.0
1196 }
1197 };
1198 let sy = |v: f64| 1000.0 - y(v) * 10.0;
1199 out.push_str(r#"<svg class="rx-chart__svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">"#);
1200 for (s, line) in data.series.iter().enumerate() {
1201 let mut runs: Vec<Vec<(f64, f64)>> = vec![Vec::new()];
1203 for (i, value) in line.values.iter().enumerate() {
1204 match (value, runs.last_mut()) {
1205 (Some(v), Some(run)) => run.push((x(i), sy(*v))),
1206 _ => runs.push(Vec::new()),
1207 }
1208 }
1209 for run in runs.iter().filter(|r| !r.is_empty()) {
1210 let path: String = run
1211 .iter()
1212 .enumerate()
1213 .map(|(k, (px, py))| {
1214 format!("{}{:.1},{:.1}", if k == 0 { "M" } else { "L" }, px, py)
1215 })
1216 .collect();
1217 if options.kind == "area" {
1218 let base = 1000.0 - zero * 10.0;
1219 let _ = write!(
1220 out,
1221 r#"<path class="rx-chart__area {}" d="{path}L{:.1},{base:.1}L{:.1},{base:.1}Z"/>"#,
1222 slot(s),
1223 run.last().map(|p| p.0).unwrap_or(0.0),
1224 run.first().map(|p| p.0).unwrap_or(0.0),
1225 );
1226 }
1227 let _ = write!(
1228 out,
1229 r#"<path class="rx-chart__line {}" d="{path}" vector-effect="non-scaling-stroke"/>"#,
1230 slot(s)
1231 );
1232 }
1233 }
1234 out.push_str("</svg>");
1235 for (s, line) in data.series.iter().enumerate() {
1237 if let Some((i, v)) = line
1238 .values
1239 .iter()
1240 .enumerate()
1241 .rev()
1242 .find_map(|(i, v)| v.map(|v| (i, v)))
1243 {
1244 let _ = write!(
1245 out,
1246 r#"<span class="rx-chart__dot {}" style="left: {}%; bottom: {}%" aria-hidden="true"></span>"#,
1247 slot(s),
1248 pct(x(i) / 10.0),
1249 pct(y(v)),
1250 );
1251 }
1252 }
1253 }
1254 out.push_str(r#"<span class="rx-chart__cross" hidden></span><div class="rx-chart__tip" hidden></div></div>"#);
1255
1256 out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1258 let every = n.div_ceil(8).max(1);
1259 let mut shown_at: Vec<usize> = (0..n).step_by(every).collect();
1260 if let Some(&last) = shown_at.last()
1261 && last + 1 < n
1262 {
1263 if n - 1 - last < every.div_ceil(2) && shown_at.len() > 1 {
1265 shown_at.pop();
1266 }
1267 shown_at.push(n - 1);
1268 }
1269 for (shown, &i) in shown_at.iter().enumerate() {
1270 let label = &options.labels[i];
1271 let left = if bar {
1272 (i as f64 + 0.5) / n as f64 * 100.0
1273 } else if n > 1 {
1274 i as f64 / (n - 1) as f64 * 100.0
1275 } else {
1276 50.0
1277 };
1278 let narrow = if shown % 2 == 1 && shown + 1 < shown_at.len() {
1280 " rx-chart__x--odd"
1281 } else {
1282 ""
1283 };
1284 let _ = write!(
1285 out,
1286 r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1287 pct(left),
1288 escape(label)
1289 );
1290 }
1291 out.push_str("</div></div>");
1292 x_title(&mut out, options);
1293
1294 let heads: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
1295 let rows: Vec<(String, Vec<String>)> = (0..n)
1296 .map(|i| {
1297 (
1298 options.labels[i].clone(),
1299 data.series
1300 .iter()
1301 .map(|s| {
1302 s.values[i]
1303 .map(|v| formatter.full(v))
1304 .unwrap_or_else(|| "—".into())
1305 })
1306 .collect(),
1307 )
1308 })
1309 .collect();
1310 table(&mut out, options, &heads, &rows);
1311 out.push_str("</figure>");
1312 out
1313}
1314
1315fn render_pie(data: &Data, options: &Options, formatter: &Formatter) -> String {
1316 let values = data
1317 .series
1318 .first()
1319 .map(|s| s.values.clone())
1320 .unwrap_or_default();
1321 let mut slices: Vec<(String, f64)> = data
1323 .labels
1324 .iter()
1325 .zip(values)
1326 .map(|(label, v)| (label.clone(), v.unwrap_or(0.0).max(0.0)))
1327 .collect();
1328 let display: Vec<String> = options.labels.clone();
1329 for (slice, label) in slices.iter_mut().zip(&display) {
1330 slice.0 = label.clone();
1331 }
1332 if slices.len() > 6 {
1333 let rest: f64 = slices[5..].iter().map(|s| s.1).sum();
1334 slices.truncate(5);
1335 slices.push((options.other.clone(), rest));
1336 }
1337 let total: f64 = slices.iter().map(|s| s.1).sum();
1338 let share = |v: f64| if total > 0.0 { v / total * 100.0 } else { 0.0 };
1339 let percent = |v: f64| format!("{}%", format_number(share(v), 1, &formatter.locale));
1340 let labels: Vec<String> = slices.iter().map(|s| s.0.clone()).collect();
1341 let hover = Hover {
1342 kind: &options.kind,
1343 labels: &labels,
1344 series: vec![HoverSeries {
1345 name: options.title.clone(),
1346 slot: String::new(),
1347 values: slices
1348 .iter()
1349 .map(|s| Some(format!("{} ({})", formatter.full(s.1), percent(s.1))))
1350 .collect(),
1351 }],
1352 };
1353 let mut out = String::new();
1354 open_figure(&mut out, options, &hover);
1355 let label = if options.title.is_empty() {
1356 labels.join(", ")
1357 } else {
1358 options.title.clone()
1359 };
1360 let _ = write!(
1361 out,
1362 r#"<div class="rx-chart__pie"><div class="rx-chart__plot rx-chart__plot--pie" tabindex="0" role="img" aria-label="{}"><svg viewBox="-50 -50 100 100" aria-hidden="true" focusable="false">"#,
1363 escape(&label)
1364 );
1365 let inner = if options.kind == "doughnut" {
1366 30.0
1367 } else {
1368 0.0
1369 };
1370 let outer = 48.0;
1371 let mut angle = -std::f64::consts::FRAC_PI_2;
1372 for (i, (_, v)) in slices.iter().enumerate() {
1373 if *v <= 0.0 || total <= 0.0 {
1374 continue;
1375 }
1376 let sweep = v / total * std::f64::consts::TAU;
1377 let class = format!("rx-chart__slice {}", slot(i));
1378 if sweep >= std::f64::consts::TAU - 1e-9 {
1379 let _ = write!(
1380 out,
1381 r#"<circle class="{class}" data-index="{i}" r="{}" fill-rule="evenodd"/>"#,
1382 outer
1383 );
1384 if inner > 0.0 {
1385 let _ = write!(out, r#"<circle class="rx-chart__hole" r="{inner}"/>"#);
1386 }
1387 } else {
1388 let (a0, a1) = (angle, angle + sweep);
1389 let large = if sweep > std::f64::consts::PI { 1 } else { 0 };
1390 let p = |r: f64, a: f64| (r * a.cos(), r * a.sin());
1391 let (x0, y0) = p(outer, a0);
1392 let (x1, y1) = p(outer, a1);
1393 let d = if inner > 0.0 {
1394 let (x2, y2) = p(inner, a1);
1395 let (x3, y3) = p(inner, a0);
1396 format!(
1397 "M{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}L{x2:.3},{y2:.3}A{inner},{inner} 0 {large} 0 {x3:.3},{y3:.3}Z"
1398 )
1399 } else {
1400 format!("M0,0L{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}Z")
1401 };
1402 let _ = write!(out, r#"<path class="{class}" data-index="{i}" d="{d}"/>"#);
1403 }
1404 angle += sweep;
1405 }
1406 out.push_str(r#"</svg><div class="rx-chart__tip" hidden></div></div>"#);
1407 if options.legend {
1408 out.push_str(r#"<ul class="rx-chart__legend rx-chart__legend--pie" role="list">"#);
1409 for (i, (name, v)) in slices.iter().enumerate() {
1410 let _ = write!(
1411 out,
1412 r#"<li><span class="rx-chart__key rx-chart__key--box {}" aria-hidden="true"></span><span class="rx-chart__name">{}</span><span class="rx-chart__value">{} <span class="rx-chart__share">{}</span></span></li>"#,
1413 slot(i),
1414 escape(name),
1415 escape(&formatter.full(*v)),
1416 escape(&percent(*v)),
1417 );
1418 }
1419 out.push_str("</ul>");
1420 }
1421 out.push_str("</div>");
1422 let heads = vec![
1423 if options.title.is_empty() {
1424 "".into()
1425 } else {
1426 options.title.clone()
1427 },
1428 "%".into(),
1429 ];
1430 let rows: Vec<(String, Vec<String>)> = slices
1431 .iter()
1432 .map(|(name, v)| (name.clone(), vec![formatter.full(*v), percent(*v)]))
1433 .collect();
1434 table(&mut out, options, &heads, &rows);
1435 out.push_str("</figure>");
1436 out
1437}
1438
1439fn y_title(out: &mut String, options: &Options) {
1441 if let Some(title) = options.y_title.as_deref().filter(|t| !t.is_empty()) {
1442 let _ = write!(
1443 out,
1444 r#"<p class="rx-chart__axis-title rx-chart__axis-title--y" aria-hidden="true">{}</p>"#,
1445 escape(title)
1446 );
1447 }
1448}
1449
1450fn x_title(out: &mut String, options: &Options) {
1452 if let Some(title) = options.x_title.as_deref().filter(|t| !t.is_empty()) {
1453 let _ = write!(
1454 out,
1455 r#"<p class="rx-chart__axis-title rx-chart__axis-title--x" aria-hidden="true">{}</p>"#,
1456 escape(title)
1457 );
1458 }
1459}
1460
1461struct Point {
1466 x: f64,
1467 y: f64,
1468 size: Option<f64>,
1469 label: Option<String>,
1470}
1471
1472struct Cloud {
1474 name: String,
1475 points: Vec<Point>,
1476}
1477
1478fn point_of(value: &Value) -> Option<Point> {
1481 if value.kind() == ValueKind::Map {
1482 return Some(Point {
1483 x: attr(value, "x").as_ref().and_then(number_of)?,
1484 y: attr(value, "y").as_ref().and_then(number_of)?,
1485 size: attr(value, "size").as_ref().and_then(number_of),
1486 label: attr(value, "label").map(|l| {
1487 l.as_str()
1488 .map(str::to_owned)
1489 .unwrap_or_else(|| l.to_string())
1490 }),
1491 });
1492 }
1493 let items: Vec<Value> = value.try_iter().ok()?.collect();
1494 Some(Point {
1495 x: number_of(items.first()?)?,
1496 y: number_of(items.get(1)?)?,
1497 size: items.get(2).and_then(number_of),
1498 label: None,
1499 })
1500}
1501
1502fn points_of(value: &Value) -> Vec<Point> {
1503 match value.try_iter() {
1504 Ok(items) if value.kind() == ValueKind::Seq => {
1505 items.filter_map(|item| point_of(&item)).collect()
1506 }
1507 _ => Vec::new(),
1508 }
1509}
1510
1511fn cloud_of(value: &Value, fallback: &str) -> Cloud {
1513 if value.kind() == ValueKind::Map {
1514 Cloud {
1515 name: attr(value, "name")
1516 .and_then(|n| n.as_str().map(str::to_owned))
1517 .unwrap_or_else(|| fallback.to_owned()),
1518 points: attr(value, "points")
1519 .map(|p| points_of(&p))
1520 .unwrap_or_default(),
1521 }
1522 } else {
1523 Cloud {
1524 name: fallback.to_owned(),
1525 points: points_of(value),
1526 }
1527 }
1528}
1529
1530fn read_points(
1534 data: Option<Value>,
1535 kwargs: &Kwargs,
1536 title: &str,
1537) -> std::result::Result<Vec<Cloud>, Error> {
1538 let series: Option<Value> = kwargs.get("series")?;
1539 let points: Option<Value> = kwargs.get("points")?;
1540 let name: Option<String> = kwargs.get("name")?;
1541 let fallback = name.clone().unwrap_or_else(|| title.to_owned());
1542 let mut clouds = Vec::new();
1543 let list_of_series = |value: &Value, clouds: &mut Vec<Cloud>| {
1544 if let Ok(items) = value.try_iter() {
1545 for (i, item) in items.enumerate() {
1546 clouds.push(cloud_of(&item, &format!("{} {}", fallback, i + 1)));
1547 }
1548 }
1549 };
1550 match data {
1551 Some(data) if data.kind() == ValueKind::Map => match attr(&data, "series") {
1552 Some(series) => list_of_series(&series, &mut clouds),
1553 None => clouds.push(cloud_of(&data, &fallback)),
1554 },
1555 Some(data) if data.kind() == ValueKind::Seq => {
1556 let first = data.try_iter().ok().and_then(|mut items| items.next());
1559 if first.is_some_and(|v| v.kind() == ValueKind::Map && attr(&v, "points").is_some()) {
1560 list_of_series(&data, &mut clouds);
1561 } else {
1562 clouds.push(cloud_of(&data, &fallback));
1563 }
1564 }
1565 _ => {}
1566 }
1567 if let Some(series) = series {
1568 list_of_series(&series, &mut clouds);
1569 }
1570 if let Some(points) = points {
1571 clouds.push(Cloud {
1572 name: fallback.clone(),
1573 points: points_of(&points),
1574 });
1575 }
1576 if let Some(name) = name
1577 && clouds.len() == 1
1578 {
1579 clouds[0].name = name;
1580 }
1581 Ok(clouds)
1582}
1583
1584fn auto_decimals(values: impl Iterator<Item = f64>) -> Option<u32> {
1586 let mut decimals = 0;
1587 for value in values {
1588 let fract = (value.abs() * 100.0).round() as i64 % 100;
1589 if fract % 10 != 0 {
1590 return Some(2);
1591 }
1592 if fract != 0 {
1593 decimals = 1;
1594 }
1595 }
1596 Some(decimals)
1597}
1598
1599struct Formats {
1601 x: Formatter,
1602 y: Formatter,
1603 size: Formatter,
1604 size_title: String,
1605}
1606
1607#[derive(Serialize)]
1609struct HoverPoints<'a> {
1610 kind: &'a str,
1611 points: Vec<HoverPoint>,
1612}
1613
1614#[derive(Serialize)]
1615struct HoverPoint {
1616 title: String,
1618 name: String,
1620 slot: String,
1621 rows: Vec<(String, String)>,
1623 left: f64,
1625 bottom: f64,
1626}
1627
1628fn point_scale(values: impl Iterator<Item = f64>, pad: bool) -> (f64, f64, f64) {
1631 let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
1632 for v in values {
1633 lo = lo.min(v);
1634 hi = hi.max(v);
1635 }
1636 if !lo.is_finite() {
1637 return scale(0.0, 0.0);
1638 }
1639 if pad {
1640 let room = ((hi - lo) * 0.08).max(hi.abs().max(lo.abs()) * 0.02);
1642 lo = if lo >= 0.0 {
1643 (lo - room).max(0.0)
1644 } else {
1645 lo - room
1646 };
1647 hi = if hi <= 0.0 {
1648 (hi + room).min(0.0)
1649 } else {
1650 hi + room
1651 };
1652 }
1653 if lo >= 0.0 && lo <= hi * 0.25 {
1654 lo = 0.0;
1655 }
1656 if hi <= 0.0 && hi >= lo * 0.25 {
1657 hi = 0.0;
1658 }
1659 nice(lo, hi)
1660}
1661
1662fn ticks(lo: f64, hi: f64, step: f64) -> Vec<f64> {
1663 let mut ticks = Vec::new();
1664 let mut tick = lo;
1665 while tick <= hi + step / 2.0 && ticks.len() < 12 {
1666 ticks.push(tick);
1667 tick += step;
1668 }
1669 ticks
1670}
1671
1672fn render_points(clouds: &[Cloud], options: &Options, formats: &Formats) -> String {
1673 let bubble = options.kind == "bubble";
1674 let all = || clouds.iter().flat_map(|c| c.points.iter());
1675 let (x_lo, x_hi, x_step) = point_scale(all().map(|p| p.x), bubble);
1676 let (y_lo, y_hi, y_step) = point_scale(all().map(|p| p.y), bubble);
1677 let px = |v: f64| (v - x_lo) / (x_hi - x_lo) * 100.0;
1678 let py = |v: f64| (v - y_lo) / (y_hi - y_lo) * 100.0;
1679 let biggest = all()
1681 .filter_map(|p| p.size)
1682 .fold(0.0f64, |a, b| a.max(b.abs()));
1683 let diameter = |size: Option<f64>| {
1684 let share = match (size, biggest > 0.0) {
1685 (Some(size), true) => (size.abs() / biggest).sqrt(),
1686 _ => 0.0,
1687 };
1688 (6.0 + share * 34.0).round()
1689 };
1690 let x_name = options.x_title.clone().unwrap_or_else(|| "x".into());
1691 let y_name = options.y_title.clone().unwrap_or_else(|| "y".into());
1692 let several = clouds.len() > 1;
1693
1694 let mut order: Vec<(usize, &Point)> = clouds
1696 .iter()
1697 .enumerate()
1698 .flat_map(|(s, c)| c.points.iter().map(move |p| (s, p)))
1699 .collect();
1700 order.sort_by(|a, b| a.1.x.total_cmp(&b.1.x).then(a.1.y.total_cmp(&b.1.y)));
1701
1702 let rows_of = |point: &Point| {
1703 let mut rows = vec![
1704 (x_name.clone(), formats.x.full(point.x)),
1705 (y_name.clone(), formats.y.full(point.y)),
1706 ];
1707 if bubble && let Some(size) = point.size {
1708 rows.push((formats.size_title.clone(), formats.size.full(size)));
1709 }
1710 rows
1711 };
1712 let hover = HoverPoints {
1713 kind: &options.kind,
1714 points: order
1715 .iter()
1716 .map(|(s, point)| HoverPoint {
1717 title: point.label.clone().unwrap_or_default(),
1718 name: if several {
1719 clouds[*s].name.clone()
1720 } else {
1721 String::new()
1722 },
1723 slot: slot(*s),
1724 rows: rows_of(point),
1725 left: (px(point.x) * 100.0).round() / 100.0,
1726 bottom: (py(point.y) * 100.0).round() / 100.0,
1727 })
1728 .collect(),
1729 };
1730 let json = serde_json::to_string(&hover).unwrap_or_default();
1731 let mut out = String::new();
1732 let _ = write!(
1733 out,
1734 r#"<figure class="rx-chart rx-chart--{kind} rx-chart--points"{id} data-rx-chart="{json}">"#,
1735 kind = escape(&options.kind),
1736 id = options
1737 .id
1738 .as_deref()
1739 .map(|id| format!(r#" id="{}""#, escape(id)))
1740 .unwrap_or_default(),
1741 json = escape(&json),
1742 );
1743 if !options.title.is_empty() {
1744 let _ = write!(
1745 out,
1746 r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
1747 escape(&options.title)
1748 );
1749 }
1750 if options.legend && several {
1751 let names: Vec<(usize, &str)> = clouds
1752 .iter()
1753 .enumerate()
1754 .map(|(i, c)| (i, c.name.as_str()))
1755 .collect();
1756 legend(&mut out, &names, "dot");
1757 }
1758 y_title(&mut out, options);
1759 let _ = write!(
1760 out,
1761 r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1762 options.height
1763 );
1764 let y_ticks = ticks(y_lo, y_hi, y_step);
1765 for t in &y_ticks {
1766 let _ = write!(
1767 out,
1768 r#"<span style="bottom: {}%">{}</span>"#,
1769 pct(py(*t)),
1770 escape(&formats.y.tick(*t))
1771 );
1772 }
1773 let label = if options.title.is_empty() {
1774 clouds
1775 .iter()
1776 .map(|c| c.name.as_str())
1777 .filter(|n| !n.is_empty())
1778 .collect::<Vec<_>>()
1779 .join(", ")
1780 } else {
1781 options.title.clone()
1782 };
1783 let label = match (&options.x_title, &options.y_title) {
1784 (Some(x), Some(y)) => {
1785 let axes = options.axes.replace(":y", y).replace(":x", x);
1786 if label.is_empty() {
1787 axes
1788 } else {
1789 format!("{label}: {axes}")
1790 }
1791 }
1792 _ => label,
1793 };
1794 let _ = write!(
1795 out,
1796 r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1797 escape(&label)
1798 );
1799 for t in &y_ticks {
1800 let base = if t.abs() < y_step / 1e6 {
1801 " rx-chart__rule--base"
1802 } else {
1803 ""
1804 };
1805 let _ = write!(
1806 out,
1807 r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1808 pct(py(*t))
1809 );
1810 }
1811 let x_ticks = ticks(x_lo, x_hi, x_step);
1812 for t in &x_ticks {
1813 let base = if t.abs() < x_step / 1e6 {
1814 " rx-chart__rule--base"
1815 } else {
1816 ""
1817 };
1818 let _ = write!(
1819 out,
1820 r#"<span class="rx-chart__rule rx-chart__rule--x{base}" style="left: {}%"></span>"#,
1821 pct(px(*t))
1822 );
1823 }
1824 out.push_str(r#"</div><div class="rx-chart__points" aria-hidden="true">"#);
1825 let mut drawn: Vec<(usize, &(usize, &Point))> = order.iter().enumerate().collect();
1827 if bubble {
1828 drawn.sort_by(|a, b| {
1829 let size = |p: &Point| p.size.map(f64::abs).unwrap_or(0.0);
1830 size(b.1.1).total_cmp(&size(a.1.1))
1831 });
1832 }
1833 for (index, (s, point)) in drawn {
1834 let size = if bubble {
1835 format!("; --rx-point: {}px", diameter(point.size))
1836 } else {
1837 String::new()
1838 };
1839 let _ = write!(
1840 out,
1841 r#"<span class="rx-chart__point {}" data-index="{index}" style="left: {}%; bottom: {}%{size}"></span>"#,
1842 slot(*s),
1843 pct(px(point.x)),
1844 pct(py(point.y)),
1845 );
1846 }
1847 out.push_str(r#"</div><div class="rx-chart__tip" hidden></div></div>"#);
1848 out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1849 for (shown, t) in x_ticks.iter().enumerate() {
1850 let narrow = if shown % 2 == 1 && shown + 1 < x_ticks.len() {
1851 " rx-chart__x--odd"
1852 } else {
1853 ""
1854 };
1855 let _ = write!(
1856 out,
1857 r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1858 pct(px(*t)),
1859 escape(&formats.x.tick(*t))
1860 );
1861 }
1862 out.push_str("</div></div>");
1863 x_title(&mut out, options);
1864
1865 let mut heads = Vec::new();
1866 if several {
1867 heads.push(options.series_head.clone());
1868 }
1869 heads.push(x_name.clone());
1870 heads.push(y_name.clone());
1871 if bubble {
1872 heads.push(formats.size_title.clone());
1873 }
1874 let rows: Vec<(String, Vec<String>)> = order
1875 .iter()
1876 .enumerate()
1877 .map(|(i, (s, point))| {
1878 let mut cells = Vec::new();
1879 if several {
1880 cells.push(clouds[*s].name.clone());
1881 }
1882 cells.push(formats.x.full(point.x));
1883 cells.push(formats.y.full(point.y));
1884 if bubble {
1885 cells.push(
1886 point
1887 .size
1888 .map(|v| formats.size.full(v))
1889 .unwrap_or_else(|| "—".into()),
1890 );
1891 }
1892 (
1893 point.label.clone().unwrap_or_else(|| (i + 1).to_string()),
1894 cells,
1895 )
1896 })
1897 .collect();
1898 table(&mut out, options, &heads, &rows);
1899 out.push_str("</figure>");
1900 out
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906
1907 #[test]
1908 fn periods_parse_and_step_back() {
1909 assert_eq!(Period::parse("30d"), Some(Period::days(30)));
1910 assert_eq!(Period::parse("12M").map(|p| p.key()), Some("12m".into()));
1911 assert_eq!(Period::parse("ytd"), Some(Period::year_to_date()));
1912 for bad in [
1913 "",
1914 "0d",
1915 "400d",
1916 "40m",
1917 "105w",
1918 "d",
1919 "-3d",
1920 "2026-09-30..2026-09-01",
1921 "2020-01-01..2026-01-01",
1922 "2026-9-1..2026-09-30",
1923 ] {
1924 assert_eq!(Period::parse(bad), None, "{bad}");
1925 }
1926 assert_eq!(Period::days(30).bucket(), Bucket::Day);
1927 assert_eq!(Period::days(365).bucket(), Bucket::Month);
1928 let zone = Zone::UTC;
1929 let (start, end) = Period::days(7).days_in(zone);
1930 assert_eq!((end - start).num_days(), 7);
1931 let (before_start, before_end) = Period::days(7).previous().days_in(zone);
1932 assert_eq!(
1933 (before_end, (before_end - before_start).num_days()),
1934 (start, 7)
1935 );
1936 assert_eq!(Period::days(7).labels(zone).len(), 7);
1937 assert_eq!(Period::months(12).labels(zone).len(), 12);
1938 let (m0, m1) = Period::months(3).previous().days_in(zone);
1939 assert_eq!((m0.day(), m1.day()), (1, 1));
1940 assert_eq!(
1941 add_months(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), -1),
1942 NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()
1943 );
1944 }
1945
1946 fn day(text: &str) -> NaiveDate {
1947 NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
1948 }
1949
1950 #[test]
1951 fn custom_ranges_parse_and_step_back() {
1952 let range = Period::between(day("2026-09-01"), day("2026-09-30")).unwrap();
1953 assert!(range.is_custom() && !Period::days(30).is_custom());
1954 assert_eq!(range.key(), "2026-09-01..2026-09-30");
1955 assert_eq!(Period::parse(&range.key()), Some(range));
1956 assert_eq!(
1957 range.days_in(Zone::UTC),
1958 (day("2026-09-01"), day("2026-10-01"))
1959 );
1960 assert_eq!(
1962 range.previous().days_in(Zone::UTC),
1963 (day("2026-08-02"), day("2026-09-01"))
1964 );
1965 assert!(Period::between(day("2026-09-01"), day("2026-09-01")).is_some());
1967 assert!(Period::between(day("2026-09-02"), day("2026-09-01")).is_none());
1968 assert!(Period::between(day("2024-01-01"), day("2026-12-31")).is_some());
1969 assert!(Period::between(day("2024-01-01"), day("2027-01-01")).is_none());
1970 let query = "q=x&period=custom&from=2026-09-01&to=2026-09-30";
1972 assert_eq!(Period::from_query(query), Some(range));
1973 assert_eq!(
1974 Period::from_query("period=12w").map(|p| p.key()),
1975 Some("12w".into())
1976 );
1977 for bad in [
1978 "period=custom",
1979 "period=custom&from=2026-09-01",
1980 "period=custom&from=2026-09-31&to=2026-10-01",
1981 "period=custom&from=2026-10-01&to=2026-09-01",
1982 "from=2026-09-01&to=2026-09-30",
1983 ] {
1984 assert_eq!(Period::from_query(bad), None, "{bad}");
1985 }
1986 let span = |days: i64| {
1988 let from = day("2026-01-01");
1989 Period::between(from, from + Duration::days(days - 1))
1990 .unwrap()
1991 .bucket()
1992 };
1993 assert_eq!(
1994 (span(92), span(93), span(182), span(183)),
1995 (Bucket::Day, Bucket::Week, Bucket::Week, Bucket::Month)
1996 );
1997 }
1998
1999 #[test]
2000 fn weeks_start_on_monday() {
2001 assert_eq!(monday_of(day("2026-10-04")), day("2026-09-28")); assert_eq!(monday_of(day("2026-09-28")), day("2026-09-28"));
2003 let weeks = Period::weeks(4);
2004 assert_eq!(weeks.bucket(), Bucket::Week);
2005 let (start, end) = weeks.days_in(Zone::UTC);
2006 assert_eq!(start.weekday(), chrono::Weekday::Mon);
2007 assert!((end - start).num_days() > 21 && (end - start).num_days() <= 28);
2008 let labels = weeks.labels(Zone::UTC);
2009 assert_eq!(labels.len(), 4);
2010 assert_eq!(labels[0], start.format("%Y-%m-%d").to_string());
2011 let (before_start, before_end) = weeks.previous().days_in(Zone::UTC);
2013 assert_eq!(
2014 (before_start, end - before_end),
2015 (start - Duration::days(28), Duration::days(28))
2016 );
2017 let range = Period::between(day("2026-09-02"), day("2026-09-30"))
2019 .unwrap()
2020 .per(Bucket::Week);
2021 assert_eq!(
2022 range.labels(Zone::UTC),
2023 [
2024 "2026-08-31",
2025 "2026-09-07",
2026 "2026-09-14",
2027 "2026-09-21",
2028 "2026-09-28"
2029 ]
2030 );
2031 assert_eq!(range.previous().bucket(), Bucket::Week);
2032 assert_eq!(Period::days(365).per(Bucket::Day).bucket(), Bucket::Day);
2033 }
2034
2035 #[test]
2036 fn series_add_up_and_compare() {
2037 let now = Series::new(vec!["a".into(), "b".into()], vec![30.0, 30.0]);
2038 let before = Series::new(vec!["a".into(), "b".into()], vec![20.0, 20.0]);
2039 assert_eq!(now.change_from(&before), Some(50.0));
2040 assert_eq!(now.change_from(&Series::default()), None);
2041 assert_eq!(
2042 serde_json::to_value(now.named("Sales")).unwrap()["name"],
2043 "Sales"
2044 );
2045 }
2046
2047 #[test]
2048 fn scales_are_clean() {
2049 assert_eq!(scale(0.0, 87.0), (0.0, 100.0, 25.0));
2050 assert_eq!(scale(0.0, 1234.0), (0.0, 1500.0, 500.0));
2051 assert_eq!(scale(-30.0, 70.0), (-50.0, 75.0, 25.0));
2052 assert_eq!(scale(0.0, 0.0), (0.0, 1.0, 0.25));
2053 assert_eq!(
2055 point_scale([52.0, 87.0].into_iter(), false),
2056 (50.0, 90.0, 10.0)
2057 );
2058 assert_eq!(
2059 point_scale([3.0, 87.0].into_iter(), false),
2060 (0.0, 100.0, 25.0)
2061 );
2062 assert_eq!(point_scale([-5.0, -80.0].into_iter(), false).1, 0.0);
2063 assert_eq!(point_scale(std::iter::empty(), true), (0.0, 1.0, 0.25));
2064 assert_eq!(
2065 auto_decimals([1.0, 2.5].into_iter()),
2066 Some(1),
2067 "as many decimals as needed"
2068 );
2069 assert_eq!(auto_decimals([1.25].into_iter()), Some(2));
2070 assert_eq!(auto_decimals([3.0].into_iter()), Some(0));
2071 }
2072
2073 #[test]
2074 fn labels_and_ticks_read_well() {
2075 assert_eq!(display_label("2026-10-02", None), "Oct 2");
2076 assert_eq!(display_label("2026-08", None), "Aug 2026");
2077 assert_eq!(display_label("2026-10-02", Some("%d/%m")), "02/10");
2078 assert_eq!(display_label("Coffee", None), "Coffee");
2079 let f = |locale: &str| Formatter {
2080 format: "number".into(),
2081 decimals: None,
2082 currency: "IDR".into(),
2083 locale: locale.into(),
2084 };
2085 assert_eq!(f("en").tick(12_500.0), "12.5K");
2086 assert_eq!(f("es").tick(2_500_000.0), "2,5M");
2087 assert_eq!(f("en").tick(750.0), "750");
2088 assert_eq!(f("en").tick(2_000_000_000.0), "2B");
2089 }
2090
2091 fn on<T>(date: &str, f: impl FnOnce() -> T) -> T {
2095 let at = day(date)
2096 .and_hms_opt(12, 0, 0)
2097 .unwrap()
2098 .and_utc()
2099 .timestamp();
2100 let now = chrono::Utc::now().timestamp();
2101 crate::clock::with_offset_sync(at - now, f)
2102 }
2103
2104 #[test]
2105 fn month_and_year_to_date_compare_with_as_many_days_before() {
2106 let zone = Zone::UTC;
2107 let mtd = Period::month_to_date();
2108 assert_eq!(mtd.key(), "mtd");
2109 assert_eq!(mtd.bucket(), Bucket::Day);
2110 on("2026-03-31", || {
2113 assert_eq!(mtd.days_in(zone), (day("2026-03-01"), day("2026-04-01")));
2114 assert_eq!(
2115 mtd.previous().days_in(zone),
2116 (day("2026-02-01"), day("2026-03-01"))
2117 );
2118 });
2119 on("2024-02-29", || {
2122 let ytd = Period::year_to_date();
2123 assert_eq!(ytd.days_in(zone), (day("2024-01-01"), day("2024-03-01")));
2124 assert_eq!(
2125 ytd.previous().days_in(zone),
2126 (day("2023-01-01"), day("2023-03-02"))
2127 );
2128 });
2129 on("2024-12-31", || {
2131 assert_eq!(
2132 Period::year_to_date().previous().days_in(zone),
2133 (day("2023-01-01"), day("2024-01-01"))
2134 );
2135 });
2136 }
2137
2138 fn render(source: &str) -> String {
2139 let mut env = minijinja::Environment::new();
2140 env.add_function("chart", chart("USD".into()));
2141 env.render_str(source, minijinja::context! {})
2142 .unwrap_or_else(|err| panic!("{err:#}"))
2143 }
2144
2145 #[test]
2146 fn awkward_data_still_draws() {
2147 let gap =
2149 render(r#"{{ chart("line", labels=["a", "b", "c", "d"], values=[1, none, 3, 4]) }}"#);
2150 assert!(gap.contains("<figure class=\"rx-chart"), "{gap}");
2151 for source in [
2152 r#"{{ chart("bar", [-5, -2, -9]) }}"#,
2153 r#"{{ chart("line", [3, 3, 3]) }}"#,
2154 r#"{{ chart("line", [0, 0]) }}"#,
2155 ] {
2156 assert!(
2157 render(source).contains("<figure class=\"rx-chart"),
2158 "{source}"
2159 );
2160 }
2161 let many = render(
2163 r#"{{ chart("line", labels=["a"], series=[{"name": "1", "values": [1]}, {"name": "2", "values": [1]}, {"name": "3", "values": [1]}, {"name": "4", "values": [1]}, {"name": "5", "values": [1]}, {"name": "6", "values": [1]}, {"name": "7", "values": [1]}]) }}"#,
2164 );
2165 assert!(many.contains("rx-series-other"), "{many}");
2166 }
2167
2168 #[test]
2169 fn pies_skip_empty_slices_and_draw_a_whole_one_as_a_circle() {
2170 let pie = render(r#"{{ chart("pie", labels=["none", "all"], values=[0, 5]) }}"#);
2171 assert!(
2172 pie.contains(r#"<circle class="rx-chart__slice rx-series-2""#),
2173 "{pie}"
2174 );
2175 assert!(!pie.contains("rx-chart__hole"));
2176 assert!(pie.contains(r#"aria-label="none, all""#), "{pie}");
2177 let ring = render(r#"{{ chart("doughnut", labels=["all"], values=[5], legend=false) }}"#);
2178 assert!(ring.contains("rx-chart__hole"), "{ring}");
2179 assert!(!ring.contains("rx-chart__legend"));
2180 let empty = render(r#"{{ chart("pie", labels=["a"], values=[0]) }}"#);
2181 assert!(!empty.contains("rx-chart__slice"), "{empty}");
2182 }
2183
2184 #[test]
2187 fn data_comes_in_every_shape() {
2188 let list = render(
2189 r#"{{ chart("line", [{"name": "North", "values": [1, 2]}, {"values": [3, 4]}], labels=["x", "y"], name="Sales") }}"#,
2190 );
2191 assert!(list.contains(">North<"), "{list}");
2192 assert!(
2193 list.contains(">Sales 2<"),
2194 "a series without a name: {list}"
2195 );
2196 let one = render(r#"{{ chart("bar", {"name": "Visits", "values": [1, 2]}) }}"#);
2197 assert!(one.contains(">Visits<"), "{one}");
2198 let nested = render(
2199 r#"{{ chart("line", {"labels": ["Mon", "Tue"], "series": [{"name": "Cups", "values": [5, 6]}]}) }}"#,
2200 );
2201 assert!(
2202 nested.contains(">Mon<") && nested.contains(">Cups<"),
2203 "{nested}"
2204 );
2205 let odd = render(r#"{{ chart("line", labels=[1, true], values=["3", "x", 2.5]) }}"#);
2208 assert!(odd.contains(">True<"), "{odd}");
2209 assert!(
2210 odd.contains(">3.0</td>") && odd.contains(">—</td>"),
2211 "{odd}"
2212 );
2213 assert!(odd.contains(r#"<th scope="row">3</th>"#), "{odd}");
2215 }
2216
2217 #[test]
2218 fn axes_for_negative_and_flat_values() {
2219 assert_eq!(scale(-9.0, -2.0), (-10.0, 0.0, 2.5));
2220 let (lo, hi, step) = nice(3.0, 3.0);
2221 assert!(lo < 3.0 && hi > 3.0 && step > 0.0, "{lo} {hi} {step}");
2222 let (lo, hi, _) = nice(-4.0, -4.0);
2223 assert!(lo < -4.0 && hi > -4.0, "{lo} {hi}");
2224 let flat = render(r#"{{ chart("bar", [-4, -4]) }}"#);
2225 assert!(flat.contains(">-4<") || flat.contains(">−4<"), "{flat}");
2226 }
2227
2228 #[test]
2229 fn lines_break_at_gaps_and_keep_their_last_label() {
2230 let gap = render(r#"{{ chart("line", [1, none, 3, 4]) }}"#);
2231 assert_eq!(gap.matches(r#"class="rx-chart__line "#).count(), 2, "{gap}");
2232 let many = render(r#"{{ chart("line", values=range(20)|list) }}"#);
2235 let x = many.split(r#"<div class="rx-chart__x""#).nth(1).unwrap();
2236 let x = x.split("</div>").next().unwrap();
2237 assert!(x.contains(">20</span>") && !x.contains(">19</span>"), "{x}");
2238 assert!(x.contains(">1</span>") && x.contains(">4</span>"), "{x}");
2239 let one = render(r#"{{ chart("line", [5]) }}"#);
2241 assert!(one.contains(r#"style="left: 50%">1</span>"#), "{one}");
2242 }
2243
2244 #[test]
2248 fn fractions_keep_their_decimals() {
2249 let bars = render(r#"{{ chart("bar", [1.5, 2.25, 3]) }}"#);
2250 let table = bars.split("<tbody>").nth(1).unwrap();
2251 assert!(
2252 table.contains(">1.50<") && table.contains(">2.25<") && table.contains(">3.00<"),
2253 "{table}"
2254 );
2255 let pie = render(r#"{{ chart("pie", labels=["a", "b"], values=[4.5, 5.5]) }}"#);
2256 assert!(pie.contains(">4.5<"), "{pie}");
2257 let whole = render(r#"{{ chart("line", [1, 2]) }}"#);
2258 assert!(
2259 whole.split("<tbody>").nth(1).unwrap().contains(">2<"),
2260 "{whole}"
2261 );
2262 let chosen = render(r#"{{ chart("bar", [1.5], decimals=0) }}"#);
2263 assert!(
2264 chosen.split("<tbody>").nth(1).unwrap().contains(">2<"),
2265 "{chosen}"
2266 );
2267 }
2268
2269 #[test]
2272 fn points_come_in_every_shape() {
2273 let nested = render(
2274 r#"{{ chart("scatter", {"series": [{"name": "North", "points": [[1, 2]]}, {"name": "South", "points": [[3, 4]]}]}) }}"#,
2275 );
2276 assert!(
2277 nested.contains(">North<") && nested.contains(">South<"),
2278 "{nested}"
2279 );
2280 let listed = render(r#"{{ chart("scatter", [{"name": "East", "points": [[1, 2]]}]) }}"#);
2281 assert!(listed.contains(r#"data-index="0""#), "{listed}");
2283 let pairs = render(r#"{{ chart("scatter", [[1, 2], [3, 4]], name="Pairs") }}"#);
2284 assert!(pairs.contains(r#"data-index="1""#), "two points: {pairs}");
2285 let one =
2286 render(r#"{{ chart("bubble", {"name": "Sizes", "points": [[1, 2, 3], [2, 3, 9]]}) }}"#);
2287 assert!(one.contains(r#"data-index="1""#), "{one}");
2288 let kwarg = render(
2289 r#"{{ chart("scatter", series=[{"name": "West", "points": [[5, 5]]}, {"name": "Far", "points": [[6, 6]]}]) }}"#,
2290 );
2291 assert!(
2292 kwarg.contains(">West<") && kwarg.contains(">Far<"),
2293 "{kwarg}"
2294 );
2295 let named = render(r#"{{ chart("line", [1, 2], name="Only") }}"#);
2296 assert!(named.contains(">Only<"), "{named}");
2297 }
2298
2299 #[test]
2300 fn scatter_marks_the_zero_line() {
2301 let html = render(r#"{{ chart("scatter", points=[[-5, -10], [8, 20]]) }}"#);
2302 assert!(html.contains("rx-chart__rule--base"), "{html}");
2303 }
2304
2305 #[test]
2306 fn scatter_with_negative_values_only() {
2307 let html = render(r#"{{ chart("scatter", points=[[-5, -10], [-1, -3]]) }}"#);
2308 assert!(
2310 html.contains(r#"<span style="bottom: 0%">-10</span>"#),
2311 "{html}"
2312 );
2313 assert!(html.contains(r#"style="left: 100%">0</span>"#), "{html}");
2314 }
2315}