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, money_divisor};
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 divisor: f64,
733}
734
735impl Formatter {
736 fn shown(&self, value: f64) -> f64 {
738 if self.format == "money" {
739 value / self.divisor
740 } else {
741 value
742 }
743 }
744
745 fn full(&self, value: f64) -> String {
746 let value = self.shown(value);
747 match self.format.as_str() {
748 "money" => format_money(value, &self.currency, self.decimals, &self.locale),
749 "percent" => format!(
750 "{}%",
751 format_number(value, self.decimals.unwrap_or(1), &self.locale)
752 ),
753 _ => format_number(value, self.decimals.unwrap_or(0), &self.locale),
754 }
755 }
756
757 fn tick(&self, value: f64) -> String {
760 let value = self.shown(value);
761 let units: [(f64, &str); 4] = [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
762 let suffix = if self.format == "percent" { "%" } else { "" };
763 if value.abs() >= 10_000.0 {
764 for (size, unit) in units {
765 if value.abs() >= size {
766 let scaled = value / size;
767 let decimals = if scaled.fract().abs() < 1e-9 { 0 } else { 1 };
768 return format!(
769 "{}{unit}{suffix}",
770 format_number(scaled, decimals, &self.locale)
771 );
772 }
773 }
774 }
775 let decimals = if value.fract().abs() < 1e-9 { 0 } else { 1 };
776 format!("{}{suffix}", format_number(value, decimals, &self.locale))
777 }
778}
779
780fn scale(lo: f64, hi: f64) -> (f64, f64, f64) {
783 nice(lo.min(0.0), hi.max(0.0))
784}
785
786fn nice(mut lo: f64, mut hi: f64) -> (f64, f64, f64) {
788 if (hi - lo).abs() < f64::EPSILON {
789 if lo == 0.0 {
790 hi = lo + 1.0;
791 } else {
792 let pad = lo.abs() / 10.0;
793 (lo, hi) = (lo - pad, hi + pad);
794 }
795 }
796 let raw = (hi - lo) / 4.0;
797 let magnitude = 10f64.powf(raw.log10().floor());
798 let normal = raw / magnitude;
799 let nice = if normal <= 1.0 {
800 1.0
801 } else if normal <= 2.0 {
802 2.0
803 } else if normal <= 2.5 {
804 2.5
805 } else if normal <= 5.0 {
806 5.0
807 } else {
808 10.0
809 };
810 let step = nice * magnitude;
811 lo = (lo / step).floor() * step;
812 hi = (hi / step).ceil() * step;
813 (lo, hi, step)
814}
815
816fn slot(i: usize) -> String {
817 if i < 6 {
818 format!("rx-series-{}", i + 1)
819 } else {
820 "rx-series-other".into()
821 }
822}
823
824fn pct(value: f64) -> String {
825 let rounded = (value * 100.0).round() / 100.0;
826 format!("{rounded}")
827}
828
829pub(crate) fn chart(
831 currency: String,
832) -> impl Fn(&State, String, Option<Value>, Kwargs) -> std::result::Result<Value, Error>
833+ Send
834+ Sync
835+ 'static {
836 move |state: &State, kind: String, data: Option<Value>, kwargs: Kwargs| {
837 let title: Option<String> = kwargs.get("title")?;
838 let height: Option<u32> = kwargs.get("height")?;
839 let format: Option<String> = kwargs.get("format")?;
840 let decimals: Option<u32> = kwargs.get("decimals")?;
841 let currency_kw: Option<String> = kwargs.get("currency")?;
842 let divide_by: Option<f64> = kwargs.get("divide_by")?;
843 let stacked: Option<bool> = kwargs.get("stacked")?;
844 let legend: Option<bool> = kwargs.get("legend")?;
845 let table: Option<bool> = kwargs.get("table")?;
846 let x_format: Option<String> = kwargs.get("x_format")?;
847 let x_title: Option<String> = kwargs.get("x_title")?;
848 let y_title: Option<String> = kwargs.get("y_title")?;
849 let id: Option<String> = kwargs.get("id")?;
850 let locale = locale(state);
851 let title = title.unwrap_or_default();
852 let currency = currency_kw
853 .map(|c| c.trim().to_ascii_uppercase())
854 .unwrap_or_else(|| currency.clone());
855 let divisor = money_divisor(¤cy, divide_by);
856 let formatter = |format: Option<String>, given: Option<u32>, auto: Option<u32>| {
859 let format = format.unwrap_or_else(|| "number".into());
860 Formatter {
861 decimals: if format == "money" {
862 given
863 } else {
864 given.or(auto)
865 },
866 format,
867 currency: currency.clone(),
868 locale: locale.clone(),
869 divisor,
870 }
871 };
872 let mut options = Options {
873 kind: kind.clone(),
874 title: title.clone(),
875 height: height.unwrap_or(240).clamp(80, 800),
876 stacked: stacked.unwrap_or(false),
877 legend: legend.unwrap_or(true),
878 table: table.unwrap_or(true),
879 labels: Vec::new(),
880 id,
881 show_data: text(state, "ui.chart.show_data", &locale),
882 other: text(state, "ui.chart.other", &locale),
883 series_head: text(state, "ui.chart.series", &locale),
884 axes: text(state, "ui.chart.axes", &locale),
885 x_title,
886 y_title,
887 };
888 let html = match kind.as_str() {
889 "scatter" | "bubble" => {
890 let size_format: Option<String> = kwargs.get("size_format")?;
891 let size_title: Option<String> = kwargs.get("size_title")?;
892 let clouds = read_points(data, &kwargs, &title)?;
893 kwargs.assert_all_used()?;
894 let all = || clouds.iter().flat_map(|c| c.points.iter());
895 let formats = Formats {
896 x: formatter(x_format, None, auto_decimals(all().map(|p| p.x))),
897 y: formatter(format, decimals, auto_decimals(all().map(|p| p.y))),
898 size: formatter(
899 size_format,
900 None,
901 auto_decimals(all().filter_map(|p| p.size)),
902 ),
903 size_title: size_title.unwrap_or_else(|| text(state, "ui.chart.size", &locale)),
904 };
905 render_points(&clouds, &options, &formats)
906 }
907 "line" | "area" | "bar" | "pie" | "doughnut" => {
908 let data = read_data(data, &kwargs, &title)?;
909 kwargs.assert_all_used()?;
910 options.labels = data
911 .labels
912 .iter()
913 .map(|l| display_label(l, x_format.as_deref()))
914 .collect();
915 let auto = match format.as_deref() {
918 None | Some("number") => auto_decimals(
919 data.series
920 .iter()
921 .flat_map(|s| s.values.iter().flatten().copied()),
922 ),
923 _ => None,
924 };
925 let formatter = formatter(format, decimals, auto);
926 if kind == "pie" || kind == "doughnut" {
927 render_pie(&data, &options, &formatter)
928 } else {
929 render_xy(&data, &options, &formatter)
930 }
931 }
932 other => {
933 return Err(Error::new(
934 ErrorKind::InvalidOperation,
935 format!(
936 "chart: unknown kind `{other}` (line, area, bar, pie, doughnut, scatter or bubble)"
937 ),
938 ));
939 }
940 };
941 Ok(Value::from_safe_string(html))
942 }
943}
944
945struct Options {
946 kind: String,
947 title: String,
948 height: u32,
949 stacked: bool,
950 legend: bool,
951 table: bool,
952 labels: Vec<String>,
953 id: Option<String>,
954 show_data: String,
955 other: String,
956 series_head: String,
958 axes: String,
960 x_title: Option<String>,
962 y_title: Option<String>,
963}
964
965#[derive(Serialize)]
967struct Hover<'a> {
968 kind: &'a str,
969 labels: &'a [String],
970 series: Vec<HoverSeries>,
971}
972
973#[derive(Serialize)]
974struct HoverSeries {
975 name: String,
976 slot: String,
977 values: Vec<Option<String>>,
978}
979
980fn open_figure(out: &mut String, options: &Options, hover: &Hover) {
981 let json = serde_json::to_string(hover).unwrap_or_default();
982 let _ = write!(
983 out,
984 r#"<figure class="rx-chart rx-chart--{kind}"{id} data-rx-chart="{json}">"#,
985 kind = escape(&options.kind),
986 id = options
987 .id
988 .as_deref()
989 .map(|id| format!(r#" id="{}""#, escape(id)))
990 .unwrap_or_default(),
991 json = escape(&json),
992 );
993 if !options.title.is_empty() {
994 let _ = write!(
995 out,
996 r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
997 escape(&options.title)
998 );
999 }
1000}
1001
1002fn legend(out: &mut String, names: &[(usize, &str)], key: &str) {
1003 out.push_str(r#"<ul class="rx-chart__legend" role="list">"#);
1004 for (i, name) in names {
1005 let _ = write!(
1006 out,
1007 r#"<li><span class="rx-chart__key rx-chart__key--{key} {slot}" aria-hidden="true"></span>{name}</li>"#,
1008 slot = slot(*i),
1009 name = escape(name),
1010 );
1011 }
1012 out.push_str("</ul>");
1013}
1014
1015fn table(out: &mut String, options: &Options, heads: &[String], rows: &[(String, Vec<String>)]) {
1016 if !options.table {
1017 return;
1018 }
1019 let _ = write!(
1020 out,
1021 r#"<details class="rx-chart__table"><summary>{}</summary><div class="rx-table-wrap"><table class="rx-table"><thead><tr><th scope="col"></th>"#,
1022 escape(&options.show_data)
1023 );
1024 for head in heads {
1025 let _ = write!(
1026 out,
1027 r#"<th scope="col" class="rx-num">{}</th>"#,
1028 escape(head)
1029 );
1030 }
1031 out.push_str("</tr></thead><tbody>");
1032 for (label, cells) in rows {
1033 let _ = write!(out, r#"<tr><th scope="row">{}</th>"#, escape(label));
1034 for cell in cells {
1035 let _ = write!(out, r#"<td class="rx-num">{}</td>"#, escape(cell));
1036 }
1037 out.push_str("</tr>");
1038 }
1039 out.push_str("</tbody></table></div></details>");
1040}
1041
1042fn render_xy(data: &Data, options: &Options, formatter: &Formatter) -> String {
1043 let n = data.labels.len();
1044 let bar = options.kind == "bar";
1045 let stacked = bar && options.stacked;
1046 let (mut lo, mut hi) = (0.0f64, 0.0f64);
1048 for i in 0..n {
1049 let (mut up, mut down) = (0.0, 0.0);
1050 for line in &data.series {
1051 if let Some(v) = line.values[i] {
1052 if stacked {
1053 if v >= 0.0 { up += v } else { down += v }
1054 } else {
1055 hi = hi.max(v);
1056 lo = lo.min(v);
1057 }
1058 }
1059 }
1060 hi = hi.max(up);
1061 lo = lo.min(down);
1062 }
1063 let (lo, hi, step) = scale(lo, hi);
1064 let y = |v: f64| (v - lo) / (hi - lo) * 100.0;
1065 let zero = y(0.0);
1066
1067 let hover = Hover {
1068 kind: &options.kind,
1069 labels: &options.labels,
1070 series: data
1071 .series
1072 .iter()
1073 .enumerate()
1074 .map(|(i, line)| HoverSeries {
1075 name: line.name.clone(),
1076 slot: slot(i),
1077 values: line
1078 .values
1079 .iter()
1080 .map(|v| v.map(|v| formatter.full(v)))
1081 .collect(),
1082 })
1083 .collect(),
1084 };
1085 let mut out = String::new();
1086 open_figure(&mut out, options, &hover);
1087 if options.legend && data.series.len() > 1 {
1088 let names: Vec<(usize, &str)> = data
1089 .series
1090 .iter()
1091 .enumerate()
1092 .map(|(i, s)| (i, s.name.as_str()))
1093 .collect();
1094 legend(&mut out, &names, if bar { "box" } else { "line" });
1095 }
1096 y_title(&mut out, options);
1097 let _ = write!(
1098 out,
1099 r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1100 options.height
1101 );
1102 let mut ticks = Vec::new();
1103 let mut tick = lo;
1104 while tick <= hi + step / 2.0 && ticks.len() < 12 {
1105 ticks.push(tick);
1106 tick += step;
1107 }
1108 for t in &ticks {
1109 let _ = write!(
1110 out,
1111 r#"<span style="bottom: {}%">{}</span>"#,
1112 pct(y(*t)),
1113 escape(&formatter.tick(*t))
1114 );
1115 }
1116 let label = if options.title.is_empty() {
1117 data.series
1118 .iter()
1119 .map(|s| s.name.as_str())
1120 .collect::<Vec<_>>()
1121 .join(", ")
1122 } else {
1123 options.title.clone()
1124 };
1125 let _ = write!(
1126 out,
1127 r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1128 escape(&label)
1129 );
1130 for t in &ticks {
1131 let base = if t.abs() < step / 1e6 {
1132 " rx-chart__rule--base"
1133 } else {
1134 ""
1135 };
1136 let _ = write!(
1137 out,
1138 r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1139 pct(y(*t))
1140 );
1141 }
1142 out.push_str("</div>");
1143
1144 if bar {
1145 out.push_str(r#"<div class="rx-chart__bars" aria-hidden="true">"#);
1146 for i in 0..n {
1147 let _ = write!(out, r#"<div class="rx-chart__band" data-index="{i}">"#);
1148 if stacked {
1149 out.push_str(r#"<div class="rx-chart__column">"#);
1150 let (mut up, mut down) = (0.0, 0.0);
1151 let tops: Vec<usize> = {
1152 let mut last_up = None;
1154 let mut last_down = None;
1155 for (s, line) in data.series.iter().enumerate() {
1156 match line.values[i] {
1157 Some(v) if v > 0.0 => last_up = Some(s),
1158 Some(v) if v < 0.0 => last_down = Some(s),
1159 _ => {}
1160 }
1161 }
1162 last_up.into_iter().chain(last_down).collect()
1163 };
1164 for (s, line) in data.series.iter().enumerate() {
1165 let Some(v) = line.values[i].filter(|v| *v != 0.0) else {
1166 continue;
1167 };
1168 let (from, to) = if v > 0.0 {
1169 let from = up;
1170 up += v;
1171 (from, up)
1172 } else {
1173 let from = down;
1174 down += v;
1175 (down, from)
1176 };
1177 let classes = format!(
1178 "rx-chart__bar {}{}{}",
1179 slot(s),
1180 if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1181 if tops.contains(&s) {
1182 " rx-chart__bar--end"
1183 } else {
1184 ""
1185 },
1186 );
1187 let _ = write!(
1188 out,
1189 r#"<span class="{classes}" style="bottom: {}%; height: {}%"></span>"#,
1190 pct(y(from)),
1191 pct(y(to) - y(from)),
1192 );
1193 }
1194 out.push_str("</div>");
1195 } else {
1196 for (s, line) in data.series.iter().enumerate() {
1197 out.push_str(r#"<div class="rx-chart__column">"#);
1198 if let Some(v) = line.values[i].filter(|v| *v != 0.0) {
1199 let (from, to) = if v > 0.0 { (0.0, v) } else { (v, 0.0) };
1200 let _ = write!(
1201 out,
1202 r#"<span class="rx-chart__bar rx-chart__bar--end {}{}" style="bottom: {}%; height: {}%"></span>"#,
1203 slot(s),
1204 if v < 0.0 { " rx-chart__bar--neg" } else { "" },
1205 pct(y(from)),
1206 pct(y(to) - y(from)),
1207 );
1208 }
1209 out.push_str("</div>");
1210 }
1211 }
1212 out.push_str("</div>");
1213 }
1214 out.push_str("</div>");
1215 } else {
1216 let x = |i: usize| {
1217 if n > 1 {
1218 i as f64 / (n - 1) as f64 * 1000.0
1219 } else {
1220 500.0
1221 }
1222 };
1223 let sy = |v: f64| 1000.0 - y(v) * 10.0;
1224 out.push_str(r#"<svg class="rx-chart__svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">"#);
1225 for (s, line) in data.series.iter().enumerate() {
1226 let mut runs: Vec<Vec<(f64, f64)>> = vec![Vec::new()];
1228 for (i, value) in line.values.iter().enumerate() {
1229 match (value, runs.last_mut()) {
1230 (Some(v), Some(run)) => run.push((x(i), sy(*v))),
1231 _ => runs.push(Vec::new()),
1232 }
1233 }
1234 for run in runs.iter().filter(|r| !r.is_empty()) {
1235 let path: String = run
1236 .iter()
1237 .enumerate()
1238 .map(|(k, (px, py))| {
1239 format!("{}{:.1},{:.1}", if k == 0 { "M" } else { "L" }, px, py)
1240 })
1241 .collect();
1242 if options.kind == "area" {
1243 let base = 1000.0 - zero * 10.0;
1244 let _ = write!(
1245 out,
1246 r#"<path class="rx-chart__area {}" d="{path}L{:.1},{base:.1}L{:.1},{base:.1}Z"/>"#,
1247 slot(s),
1248 run.last().map(|p| p.0).unwrap_or(0.0),
1249 run.first().map(|p| p.0).unwrap_or(0.0),
1250 );
1251 }
1252 let _ = write!(
1253 out,
1254 r#"<path class="rx-chart__line {}" d="{path}" vector-effect="non-scaling-stroke"/>"#,
1255 slot(s)
1256 );
1257 }
1258 }
1259 out.push_str("</svg>");
1260 for (s, line) in data.series.iter().enumerate() {
1262 if let Some((i, v)) = line
1263 .values
1264 .iter()
1265 .enumerate()
1266 .rev()
1267 .find_map(|(i, v)| v.map(|v| (i, v)))
1268 {
1269 let _ = write!(
1270 out,
1271 r#"<span class="rx-chart__dot {}" style="left: {}%; bottom: {}%" aria-hidden="true"></span>"#,
1272 slot(s),
1273 pct(x(i) / 10.0),
1274 pct(y(v)),
1275 );
1276 }
1277 }
1278 }
1279 out.push_str(r#"<span class="rx-chart__cross" hidden></span><div class="rx-chart__tip" hidden></div></div>"#);
1280
1281 out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1283 let every = n.div_ceil(8).max(1);
1284 let mut shown_at: Vec<usize> = (0..n).step_by(every).collect();
1285 if let Some(&last) = shown_at.last()
1286 && last + 1 < n
1287 {
1288 if n - 1 - last < every.div_ceil(2) && shown_at.len() > 1 {
1290 shown_at.pop();
1291 }
1292 shown_at.push(n - 1);
1293 }
1294 for (shown, &i) in shown_at.iter().enumerate() {
1295 let label = &options.labels[i];
1296 let left = if bar {
1297 (i as f64 + 0.5) / n as f64 * 100.0
1298 } else if n > 1 {
1299 i as f64 / (n - 1) as f64 * 100.0
1300 } else {
1301 50.0
1302 };
1303 let narrow = if shown % 2 == 1 && shown + 1 < shown_at.len() {
1305 " rx-chart__x--odd"
1306 } else {
1307 ""
1308 };
1309 let _ = write!(
1310 out,
1311 r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1312 pct(left),
1313 escape(label)
1314 );
1315 }
1316 out.push_str("</div></div>");
1317 x_title(&mut out, options);
1318
1319 let heads: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
1320 let rows: Vec<(String, Vec<String>)> = (0..n)
1321 .map(|i| {
1322 (
1323 options.labels[i].clone(),
1324 data.series
1325 .iter()
1326 .map(|s| {
1327 s.values[i]
1328 .map(|v| formatter.full(v))
1329 .unwrap_or_else(|| "—".into())
1330 })
1331 .collect(),
1332 )
1333 })
1334 .collect();
1335 table(&mut out, options, &heads, &rows);
1336 out.push_str("</figure>");
1337 out
1338}
1339
1340fn render_pie(data: &Data, options: &Options, formatter: &Formatter) -> String {
1341 let values = data
1342 .series
1343 .first()
1344 .map(|s| s.values.clone())
1345 .unwrap_or_default();
1346 let mut slices: Vec<(String, f64)> = data
1348 .labels
1349 .iter()
1350 .zip(values)
1351 .map(|(label, v)| (label.clone(), v.unwrap_or(0.0).max(0.0)))
1352 .collect();
1353 let display: Vec<String> = options.labels.clone();
1354 for (slice, label) in slices.iter_mut().zip(&display) {
1355 slice.0 = label.clone();
1356 }
1357 if slices.len() > 6 {
1358 let rest: f64 = slices[5..].iter().map(|s| s.1).sum();
1359 slices.truncate(5);
1360 slices.push((options.other.clone(), rest));
1361 }
1362 let total: f64 = slices.iter().map(|s| s.1).sum();
1363 let share = |v: f64| if total > 0.0 { v / total * 100.0 } else { 0.0 };
1364 let percent = |v: f64| format!("{}%", format_number(share(v), 1, &formatter.locale));
1365 let labels: Vec<String> = slices.iter().map(|s| s.0.clone()).collect();
1366 let hover = Hover {
1367 kind: &options.kind,
1368 labels: &labels,
1369 series: vec![HoverSeries {
1370 name: options.title.clone(),
1371 slot: String::new(),
1372 values: slices
1373 .iter()
1374 .map(|s| Some(format!("{} ({})", formatter.full(s.1), percent(s.1))))
1375 .collect(),
1376 }],
1377 };
1378 let mut out = String::new();
1379 open_figure(&mut out, options, &hover);
1380 let label = if options.title.is_empty() {
1381 labels.join(", ")
1382 } else {
1383 options.title.clone()
1384 };
1385 let _ = write!(
1386 out,
1387 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">"#,
1388 escape(&label)
1389 );
1390 let inner = if options.kind == "doughnut" {
1391 30.0
1392 } else {
1393 0.0
1394 };
1395 let outer = 48.0;
1396 let mut angle = -std::f64::consts::FRAC_PI_2;
1397 for (i, (_, v)) in slices.iter().enumerate() {
1398 if *v <= 0.0 || total <= 0.0 {
1399 continue;
1400 }
1401 let sweep = v / total * std::f64::consts::TAU;
1402 let class = format!("rx-chart__slice {}", slot(i));
1403 if sweep >= std::f64::consts::TAU - 1e-9 {
1404 let _ = write!(
1405 out,
1406 r#"<circle class="{class}" data-index="{i}" r="{}" fill-rule="evenodd"/>"#,
1407 outer
1408 );
1409 if inner > 0.0 {
1410 let _ = write!(out, r#"<circle class="rx-chart__hole" r="{inner}"/>"#);
1411 }
1412 } else {
1413 let (a0, a1) = (angle, angle + sweep);
1414 let large = if sweep > std::f64::consts::PI { 1 } else { 0 };
1415 let p = |r: f64, a: f64| (r * a.cos(), r * a.sin());
1416 let (x0, y0) = p(outer, a0);
1417 let (x1, y1) = p(outer, a1);
1418 let d = if inner > 0.0 {
1419 let (x2, y2) = p(inner, a1);
1420 let (x3, y3) = p(inner, a0);
1421 format!(
1422 "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"
1423 )
1424 } else {
1425 format!("M0,0L{x0:.3},{y0:.3}A{outer},{outer} 0 {large} 1 {x1:.3},{y1:.3}Z")
1426 };
1427 let _ = write!(out, r#"<path class="{class}" data-index="{i}" d="{d}"/>"#);
1428 }
1429 angle += sweep;
1430 }
1431 out.push_str(r#"</svg><div class="rx-chart__tip" hidden></div></div>"#);
1432 if options.legend {
1433 out.push_str(r#"<ul class="rx-chart__legend rx-chart__legend--pie" role="list">"#);
1434 for (i, (name, v)) in slices.iter().enumerate() {
1435 let _ = write!(
1436 out,
1437 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>"#,
1438 slot(i),
1439 escape(name),
1440 escape(&formatter.full(*v)),
1441 escape(&percent(*v)),
1442 );
1443 }
1444 out.push_str("</ul>");
1445 }
1446 out.push_str("</div>");
1447 let heads = vec![
1448 if options.title.is_empty() {
1449 "".into()
1450 } else {
1451 options.title.clone()
1452 },
1453 "%".into(),
1454 ];
1455 let rows: Vec<(String, Vec<String>)> = slices
1456 .iter()
1457 .map(|(name, v)| (name.clone(), vec![formatter.full(*v), percent(*v)]))
1458 .collect();
1459 table(&mut out, options, &heads, &rows);
1460 out.push_str("</figure>");
1461 out
1462}
1463
1464fn y_title(out: &mut String, options: &Options) {
1466 if let Some(title) = options.y_title.as_deref().filter(|t| !t.is_empty()) {
1467 let _ = write!(
1468 out,
1469 r#"<p class="rx-chart__axis-title rx-chart__axis-title--y" aria-hidden="true">{}</p>"#,
1470 escape(title)
1471 );
1472 }
1473}
1474
1475fn x_title(out: &mut String, options: &Options) {
1477 if let Some(title) = options.x_title.as_deref().filter(|t| !t.is_empty()) {
1478 let _ = write!(
1479 out,
1480 r#"<p class="rx-chart__axis-title rx-chart__axis-title--x" aria-hidden="true">{}</p>"#,
1481 escape(title)
1482 );
1483 }
1484}
1485
1486struct Point {
1491 x: f64,
1492 y: f64,
1493 size: Option<f64>,
1494 label: Option<String>,
1495}
1496
1497struct Cloud {
1499 name: String,
1500 points: Vec<Point>,
1501}
1502
1503fn point_of(value: &Value) -> Option<Point> {
1506 if value.kind() == ValueKind::Map {
1507 return Some(Point {
1508 x: attr(value, "x").as_ref().and_then(number_of)?,
1509 y: attr(value, "y").as_ref().and_then(number_of)?,
1510 size: attr(value, "size").as_ref().and_then(number_of),
1511 label: attr(value, "label").map(|l| {
1512 l.as_str()
1513 .map(str::to_owned)
1514 .unwrap_or_else(|| l.to_string())
1515 }),
1516 });
1517 }
1518 let items: Vec<Value> = value.try_iter().ok()?.collect();
1519 Some(Point {
1520 x: number_of(items.first()?)?,
1521 y: number_of(items.get(1)?)?,
1522 size: items.get(2).and_then(number_of),
1523 label: None,
1524 })
1525}
1526
1527fn points_of(value: &Value) -> Vec<Point> {
1528 match value.try_iter() {
1529 Ok(items) if value.kind() == ValueKind::Seq => {
1530 items.filter_map(|item| point_of(&item)).collect()
1531 }
1532 _ => Vec::new(),
1533 }
1534}
1535
1536fn cloud_of(value: &Value, fallback: &str) -> Cloud {
1538 if value.kind() == ValueKind::Map {
1539 Cloud {
1540 name: attr(value, "name")
1541 .and_then(|n| n.as_str().map(str::to_owned))
1542 .unwrap_or_else(|| fallback.to_owned()),
1543 points: attr(value, "points")
1544 .map(|p| points_of(&p))
1545 .unwrap_or_default(),
1546 }
1547 } else {
1548 Cloud {
1549 name: fallback.to_owned(),
1550 points: points_of(value),
1551 }
1552 }
1553}
1554
1555fn read_points(
1559 data: Option<Value>,
1560 kwargs: &Kwargs,
1561 title: &str,
1562) -> std::result::Result<Vec<Cloud>, Error> {
1563 let series: Option<Value> = kwargs.get("series")?;
1564 let points: Option<Value> = kwargs.get("points")?;
1565 let name: Option<String> = kwargs.get("name")?;
1566 let fallback = name.clone().unwrap_or_else(|| title.to_owned());
1567 let mut clouds = Vec::new();
1568 let list_of_series = |value: &Value, clouds: &mut Vec<Cloud>| {
1569 if let Ok(items) = value.try_iter() {
1570 for (i, item) in items.enumerate() {
1571 clouds.push(cloud_of(&item, &format!("{} {}", fallback, i + 1)));
1572 }
1573 }
1574 };
1575 match data {
1576 Some(data) if data.kind() == ValueKind::Map => match attr(&data, "series") {
1577 Some(series) => list_of_series(&series, &mut clouds),
1578 None => clouds.push(cloud_of(&data, &fallback)),
1579 },
1580 Some(data) if data.kind() == ValueKind::Seq => {
1581 let first = data.try_iter().ok().and_then(|mut items| items.next());
1584 if first.is_some_and(|v| v.kind() == ValueKind::Map && attr(&v, "points").is_some()) {
1585 list_of_series(&data, &mut clouds);
1586 } else {
1587 clouds.push(cloud_of(&data, &fallback));
1588 }
1589 }
1590 _ => {}
1591 }
1592 if let Some(series) = series {
1593 list_of_series(&series, &mut clouds);
1594 }
1595 if let Some(points) = points {
1596 clouds.push(Cloud {
1597 name: fallback.clone(),
1598 points: points_of(&points),
1599 });
1600 }
1601 if let Some(name) = name
1602 && clouds.len() == 1
1603 {
1604 clouds[0].name = name;
1605 }
1606 Ok(clouds)
1607}
1608
1609fn auto_decimals(values: impl Iterator<Item = f64>) -> Option<u32> {
1611 let mut decimals = 0;
1612 for value in values {
1613 let fract = (value.abs() * 100.0).round() as i64 % 100;
1614 if fract % 10 != 0 {
1615 return Some(2);
1616 }
1617 if fract != 0 {
1618 decimals = 1;
1619 }
1620 }
1621 Some(decimals)
1622}
1623
1624struct Formats {
1626 x: Formatter,
1627 y: Formatter,
1628 size: Formatter,
1629 size_title: String,
1630}
1631
1632#[derive(Serialize)]
1634struct HoverPoints<'a> {
1635 kind: &'a str,
1636 points: Vec<HoverPoint>,
1637}
1638
1639#[derive(Serialize)]
1640struct HoverPoint {
1641 title: String,
1643 name: String,
1645 slot: String,
1646 rows: Vec<(String, String)>,
1648 left: f64,
1650 bottom: f64,
1651}
1652
1653fn point_scale(values: impl Iterator<Item = f64>, pad: bool) -> (f64, f64, f64) {
1656 let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
1657 for v in values {
1658 lo = lo.min(v);
1659 hi = hi.max(v);
1660 }
1661 if !lo.is_finite() {
1662 return scale(0.0, 0.0);
1663 }
1664 if pad {
1665 let room = ((hi - lo) * 0.08).max(hi.abs().max(lo.abs()) * 0.02);
1667 lo = if lo >= 0.0 {
1668 (lo - room).max(0.0)
1669 } else {
1670 lo - room
1671 };
1672 hi = if hi <= 0.0 {
1673 (hi + room).min(0.0)
1674 } else {
1675 hi + room
1676 };
1677 }
1678 if lo >= 0.0 && lo <= hi * 0.25 {
1679 lo = 0.0;
1680 }
1681 if hi <= 0.0 && hi >= lo * 0.25 {
1682 hi = 0.0;
1683 }
1684 nice(lo, hi)
1685}
1686
1687fn ticks(lo: f64, hi: f64, step: f64) -> Vec<f64> {
1688 let mut ticks = Vec::new();
1689 let mut tick = lo;
1690 while tick <= hi + step / 2.0 && ticks.len() < 12 {
1691 ticks.push(tick);
1692 tick += step;
1693 }
1694 ticks
1695}
1696
1697fn render_points(clouds: &[Cloud], options: &Options, formats: &Formats) -> String {
1698 let bubble = options.kind == "bubble";
1699 let all = || clouds.iter().flat_map(|c| c.points.iter());
1700 let (x_lo, x_hi, x_step) = point_scale(all().map(|p| p.x), bubble);
1701 let (y_lo, y_hi, y_step) = point_scale(all().map(|p| p.y), bubble);
1702 let px = |v: f64| (v - x_lo) / (x_hi - x_lo) * 100.0;
1703 let py = |v: f64| (v - y_lo) / (y_hi - y_lo) * 100.0;
1704 let biggest = all()
1706 .filter_map(|p| p.size)
1707 .fold(0.0f64, |a, b| a.max(b.abs()));
1708 let diameter = |size: Option<f64>| {
1709 let share = match (size, biggest > 0.0) {
1710 (Some(size), true) => (size.abs() / biggest).sqrt(),
1711 _ => 0.0,
1712 };
1713 (6.0 + share * 34.0).round()
1714 };
1715 let x_name = options.x_title.clone().unwrap_or_else(|| "x".into());
1716 let y_name = options.y_title.clone().unwrap_or_else(|| "y".into());
1717 let several = clouds.len() > 1;
1718
1719 let mut order: Vec<(usize, &Point)> = clouds
1721 .iter()
1722 .enumerate()
1723 .flat_map(|(s, c)| c.points.iter().map(move |p| (s, p)))
1724 .collect();
1725 order.sort_by(|a, b| a.1.x.total_cmp(&b.1.x).then(a.1.y.total_cmp(&b.1.y)));
1726
1727 let rows_of = |point: &Point| {
1728 let mut rows = vec![
1729 (x_name.clone(), formats.x.full(point.x)),
1730 (y_name.clone(), formats.y.full(point.y)),
1731 ];
1732 if bubble && let Some(size) = point.size {
1733 rows.push((formats.size_title.clone(), formats.size.full(size)));
1734 }
1735 rows
1736 };
1737 let hover = HoverPoints {
1738 kind: &options.kind,
1739 points: order
1740 .iter()
1741 .map(|(s, point)| HoverPoint {
1742 title: point.label.clone().unwrap_or_default(),
1743 name: if several {
1744 clouds[*s].name.clone()
1745 } else {
1746 String::new()
1747 },
1748 slot: slot(*s),
1749 rows: rows_of(point),
1750 left: (px(point.x) * 100.0).round() / 100.0,
1751 bottom: (py(point.y) * 100.0).round() / 100.0,
1752 })
1753 .collect(),
1754 };
1755 let json = serde_json::to_string(&hover).unwrap_or_default();
1756 let mut out = String::new();
1757 let _ = write!(
1758 out,
1759 r#"<figure class="rx-chart rx-chart--{kind} rx-chart--points"{id} data-rx-chart="{json}">"#,
1760 kind = escape(&options.kind),
1761 id = options
1762 .id
1763 .as_deref()
1764 .map(|id| format!(r#" id="{}""#, escape(id)))
1765 .unwrap_or_default(),
1766 json = escape(&json),
1767 );
1768 if !options.title.is_empty() {
1769 let _ = write!(
1770 out,
1771 r#"<figcaption class="rx-visually-hidden">{}</figcaption>"#,
1772 escape(&options.title)
1773 );
1774 }
1775 if options.legend && several {
1776 let names: Vec<(usize, &str)> = clouds
1777 .iter()
1778 .enumerate()
1779 .map(|(i, c)| (i, c.name.as_str()))
1780 .collect();
1781 legend(&mut out, &names, "dot");
1782 }
1783 y_title(&mut out, options);
1784 let _ = write!(
1785 out,
1786 r#"<div class="rx-chart__frame" style="--rx-chart-h: {}px"><div class="rx-chart__y" aria-hidden="true">"#,
1787 options.height
1788 );
1789 let y_ticks = ticks(y_lo, y_hi, y_step);
1790 for t in &y_ticks {
1791 let _ = write!(
1792 out,
1793 r#"<span style="bottom: {}%">{}</span>"#,
1794 pct(py(*t)),
1795 escape(&formats.y.tick(*t))
1796 );
1797 }
1798 let label = if options.title.is_empty() {
1799 clouds
1800 .iter()
1801 .map(|c| c.name.as_str())
1802 .filter(|n| !n.is_empty())
1803 .collect::<Vec<_>>()
1804 .join(", ")
1805 } else {
1806 options.title.clone()
1807 };
1808 let label = match (&options.x_title, &options.y_title) {
1809 (Some(x), Some(y)) => {
1810 let axes = options.axes.replace(":y", y).replace(":x", x);
1811 if label.is_empty() {
1812 axes
1813 } else {
1814 format!("{label}: {axes}")
1815 }
1816 }
1817 _ => label,
1818 };
1819 let _ = write!(
1820 out,
1821 r#"</div><div class="rx-chart__plot" tabindex="0" role="img" aria-label="{}"><div class="rx-chart__grid" aria-hidden="true">"#,
1822 escape(&label)
1823 );
1824 for t in &y_ticks {
1825 let base = if t.abs() < y_step / 1e6 {
1826 " rx-chart__rule--base"
1827 } else {
1828 ""
1829 };
1830 let _ = write!(
1831 out,
1832 r#"<span class="rx-chart__rule{base}" style="bottom: {}%"></span>"#,
1833 pct(py(*t))
1834 );
1835 }
1836 let x_ticks = ticks(x_lo, x_hi, x_step);
1837 for t in &x_ticks {
1838 let base = if t.abs() < x_step / 1e6 {
1839 " rx-chart__rule--base"
1840 } else {
1841 ""
1842 };
1843 let _ = write!(
1844 out,
1845 r#"<span class="rx-chart__rule rx-chart__rule--x{base}" style="left: {}%"></span>"#,
1846 pct(px(*t))
1847 );
1848 }
1849 out.push_str(r#"</div><div class="rx-chart__points" aria-hidden="true">"#);
1850 let mut drawn: Vec<(usize, &(usize, &Point))> = order.iter().enumerate().collect();
1852 if bubble {
1853 drawn.sort_by(|a, b| {
1854 let size = |p: &Point| p.size.map(f64::abs).unwrap_or(0.0);
1855 size(b.1.1).total_cmp(&size(a.1.1))
1856 });
1857 }
1858 for (index, (s, point)) in drawn {
1859 let size = if bubble {
1860 format!("; --rx-point: {}px", diameter(point.size))
1861 } else {
1862 String::new()
1863 };
1864 let _ = write!(
1865 out,
1866 r#"<span class="rx-chart__point {}" data-index="{index}" style="left: {}%; bottom: {}%{size}"></span>"#,
1867 slot(*s),
1868 pct(px(point.x)),
1869 pct(py(point.y)),
1870 );
1871 }
1872 out.push_str(r#"</div><div class="rx-chart__tip" hidden></div></div>"#);
1873 out.push_str(r#"<div class="rx-chart__x" aria-hidden="true">"#);
1874 for (shown, t) in x_ticks.iter().enumerate() {
1875 let narrow = if shown % 2 == 1 && shown + 1 < x_ticks.len() {
1876 " rx-chart__x--odd"
1877 } else {
1878 ""
1879 };
1880 let _ = write!(
1881 out,
1882 r#"<span class="{narrow}" style="left: {}%">{}</span>"#,
1883 pct(px(*t)),
1884 escape(&formats.x.tick(*t))
1885 );
1886 }
1887 out.push_str("</div></div>");
1888 x_title(&mut out, options);
1889
1890 let mut heads = Vec::new();
1891 if several {
1892 heads.push(options.series_head.clone());
1893 }
1894 heads.push(x_name.clone());
1895 heads.push(y_name.clone());
1896 if bubble {
1897 heads.push(formats.size_title.clone());
1898 }
1899 let rows: Vec<(String, Vec<String>)> = order
1900 .iter()
1901 .enumerate()
1902 .map(|(i, (s, point))| {
1903 let mut cells = Vec::new();
1904 if several {
1905 cells.push(clouds[*s].name.clone());
1906 }
1907 cells.push(formats.x.full(point.x));
1908 cells.push(formats.y.full(point.y));
1909 if bubble {
1910 cells.push(
1911 point
1912 .size
1913 .map(|v| formats.size.full(v))
1914 .unwrap_or_else(|| "—".into()),
1915 );
1916 }
1917 (
1918 point.label.clone().unwrap_or_else(|| (i + 1).to_string()),
1919 cells,
1920 )
1921 })
1922 .collect();
1923 table(&mut out, options, &heads, &rows);
1924 out.push_str("</figure>");
1925 out
1926}
1927
1928#[cfg(test)]
1929mod tests {
1930 use super::*;
1931
1932 #[test]
1933 fn periods_parse_and_step_back() {
1934 assert_eq!(Period::parse("30d"), Some(Period::days(30)));
1935 assert_eq!(Period::parse("12M").map(|p| p.key()), Some("12m".into()));
1936 assert_eq!(Period::parse("ytd"), Some(Period::year_to_date()));
1937 for bad in [
1938 "",
1939 "0d",
1940 "400d",
1941 "40m",
1942 "105w",
1943 "d",
1944 "-3d",
1945 "2026-09-30..2026-09-01",
1946 "2020-01-01..2026-01-01",
1947 "2026-9-1..2026-09-30",
1948 ] {
1949 assert_eq!(Period::parse(bad), None, "{bad}");
1950 }
1951 assert_eq!(Period::days(30).bucket(), Bucket::Day);
1952 assert_eq!(Period::days(365).bucket(), Bucket::Month);
1953 let zone = Zone::UTC;
1954 let (start, end) = Period::days(7).days_in(zone);
1955 assert_eq!((end - start).num_days(), 7);
1956 let (before_start, before_end) = Period::days(7).previous().days_in(zone);
1957 assert_eq!(
1958 (before_end, (before_end - before_start).num_days()),
1959 (start, 7)
1960 );
1961 assert_eq!(Period::days(7).labels(zone).len(), 7);
1962 assert_eq!(Period::months(12).labels(zone).len(), 12);
1963 let (m0, m1) = Period::months(3).previous().days_in(zone);
1964 assert_eq!((m0.day(), m1.day()), (1, 1));
1965 assert_eq!(
1966 add_months(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), -1),
1967 NaiveDate::from_ymd_opt(2025, 12, 1).unwrap()
1968 );
1969 }
1970
1971 fn day(text: &str) -> NaiveDate {
1972 NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
1973 }
1974
1975 #[test]
1976 fn custom_ranges_parse_and_step_back() {
1977 let range = Period::between(day("2026-09-01"), day("2026-09-30")).unwrap();
1978 assert!(range.is_custom() && !Period::days(30).is_custom());
1979 assert_eq!(range.key(), "2026-09-01..2026-09-30");
1980 assert_eq!(Period::parse(&range.key()), Some(range));
1981 assert_eq!(
1982 range.days_in(Zone::UTC),
1983 (day("2026-09-01"), day("2026-10-01"))
1984 );
1985 assert_eq!(
1987 range.previous().days_in(Zone::UTC),
1988 (day("2026-08-02"), day("2026-09-01"))
1989 );
1990 assert!(Period::between(day("2026-09-01"), day("2026-09-01")).is_some());
1992 assert!(Period::between(day("2026-09-02"), day("2026-09-01")).is_none());
1993 assert!(Period::between(day("2024-01-01"), day("2026-12-31")).is_some());
1994 assert!(Period::between(day("2024-01-01"), day("2027-01-01")).is_none());
1995 let query = "q=x&period=custom&from=2026-09-01&to=2026-09-30";
1997 assert_eq!(Period::from_query(query), Some(range));
1998 assert_eq!(
1999 Period::from_query("period=12w").map(|p| p.key()),
2000 Some("12w".into())
2001 );
2002 for bad in [
2003 "period=custom",
2004 "period=custom&from=2026-09-01",
2005 "period=custom&from=2026-09-31&to=2026-10-01",
2006 "period=custom&from=2026-10-01&to=2026-09-01",
2007 "from=2026-09-01&to=2026-09-30",
2008 ] {
2009 assert_eq!(Period::from_query(bad), None, "{bad}");
2010 }
2011 let span = |days: i64| {
2013 let from = day("2026-01-01");
2014 Period::between(from, from + Duration::days(days - 1))
2015 .unwrap()
2016 .bucket()
2017 };
2018 assert_eq!(
2019 (span(92), span(93), span(182), span(183)),
2020 (Bucket::Day, Bucket::Week, Bucket::Week, Bucket::Month)
2021 );
2022 }
2023
2024 #[test]
2025 fn weeks_start_on_monday() {
2026 assert_eq!(monday_of(day("2026-10-04")), day("2026-09-28")); assert_eq!(monday_of(day("2026-09-28")), day("2026-09-28"));
2028 let weeks = Period::weeks(4);
2029 assert_eq!(weeks.bucket(), Bucket::Week);
2030 let (start, end) = weeks.days_in(Zone::UTC);
2031 assert_eq!(start.weekday(), chrono::Weekday::Mon);
2032 assert!((end - start).num_days() > 21 && (end - start).num_days() <= 28);
2033 let labels = weeks.labels(Zone::UTC);
2034 assert_eq!(labels.len(), 4);
2035 assert_eq!(labels[0], start.format("%Y-%m-%d").to_string());
2036 let (before_start, before_end) = weeks.previous().days_in(Zone::UTC);
2038 assert_eq!(
2039 (before_start, end - before_end),
2040 (start - Duration::days(28), Duration::days(28))
2041 );
2042 let range = Period::between(day("2026-09-02"), day("2026-09-30"))
2044 .unwrap()
2045 .per(Bucket::Week);
2046 assert_eq!(
2047 range.labels(Zone::UTC),
2048 [
2049 "2026-08-31",
2050 "2026-09-07",
2051 "2026-09-14",
2052 "2026-09-21",
2053 "2026-09-28"
2054 ]
2055 );
2056 assert_eq!(range.previous().bucket(), Bucket::Week);
2057 assert_eq!(Period::days(365).per(Bucket::Day).bucket(), Bucket::Day);
2058 }
2059
2060 #[test]
2061 fn series_add_up_and_compare() {
2062 let now = Series::new(vec!["a".into(), "b".into()], vec![30.0, 30.0]);
2063 let before = Series::new(vec!["a".into(), "b".into()], vec![20.0, 20.0]);
2064 assert_eq!(now.change_from(&before), Some(50.0));
2065 assert_eq!(now.change_from(&Series::default()), None);
2066 assert_eq!(
2067 serde_json::to_value(now.named("Sales")).unwrap()["name"],
2068 "Sales"
2069 );
2070 }
2071
2072 #[test]
2073 fn scales_are_clean() {
2074 assert_eq!(scale(0.0, 87.0), (0.0, 100.0, 25.0));
2075 assert_eq!(scale(0.0, 1234.0), (0.0, 1500.0, 500.0));
2076 assert_eq!(scale(-30.0, 70.0), (-50.0, 75.0, 25.0));
2077 assert_eq!(scale(0.0, 0.0), (0.0, 1.0, 0.25));
2078 assert_eq!(
2080 point_scale([52.0, 87.0].into_iter(), false),
2081 (50.0, 90.0, 10.0)
2082 );
2083 assert_eq!(
2084 point_scale([3.0, 87.0].into_iter(), false),
2085 (0.0, 100.0, 25.0)
2086 );
2087 assert_eq!(point_scale([-5.0, -80.0].into_iter(), false).1, 0.0);
2088 assert_eq!(point_scale(std::iter::empty(), true), (0.0, 1.0, 0.25));
2089 assert_eq!(
2090 auto_decimals([1.0, 2.5].into_iter()),
2091 Some(1),
2092 "as many decimals as needed"
2093 );
2094 assert_eq!(auto_decimals([1.25].into_iter()), Some(2));
2095 assert_eq!(auto_decimals([3.0].into_iter()), Some(0));
2096 }
2097
2098 #[test]
2099 fn labels_and_ticks_read_well() {
2100 assert_eq!(display_label("2026-10-02", None), "Oct 2");
2101 assert_eq!(display_label("2026-08", None), "Aug 2026");
2102 assert_eq!(display_label("2026-10-02", Some("%d/%m")), "02/10");
2103 assert_eq!(display_label("Coffee", None), "Coffee");
2104 let f = |locale: &str| Formatter {
2105 format: "number".into(),
2106 decimals: None,
2107 currency: "USD".into(),
2108 locale: locale.into(),
2109 divisor: 1.0,
2110 };
2111 assert_eq!(f("en").tick(12_500.0), "12.5K");
2112 assert_eq!(f("es").tick(2_500_000.0), "2,5M");
2113 assert_eq!(f("en").tick(750.0), "750");
2114 assert_eq!(f("en").tick(2_000_000_000.0), "2B");
2115 }
2116
2117 fn on<T>(date: &str, f: impl FnOnce() -> T) -> T {
2121 let at = day(date)
2122 .and_hms_opt(12, 0, 0)
2123 .unwrap()
2124 .and_utc()
2125 .timestamp();
2126 let now = chrono::Utc::now().timestamp();
2127 crate::clock::with_offset_sync(at - now, f)
2128 }
2129
2130 #[test]
2131 fn month_and_year_to_date_compare_with_as_many_days_before() {
2132 let zone = Zone::UTC;
2133 let mtd = Period::month_to_date();
2134 assert_eq!(mtd.key(), "mtd");
2135 assert_eq!(mtd.bucket(), Bucket::Day);
2136 on("2026-03-31", || {
2139 assert_eq!(mtd.days_in(zone), (day("2026-03-01"), day("2026-04-01")));
2140 assert_eq!(
2141 mtd.previous().days_in(zone),
2142 (day("2026-02-01"), day("2026-03-01"))
2143 );
2144 });
2145 on("2024-02-29", || {
2148 let ytd = Period::year_to_date();
2149 assert_eq!(ytd.days_in(zone), (day("2024-01-01"), day("2024-03-01")));
2150 assert_eq!(
2151 ytd.previous().days_in(zone),
2152 (day("2023-01-01"), day("2023-03-02"))
2153 );
2154 });
2155 on("2024-12-31", || {
2157 assert_eq!(
2158 Period::year_to_date().previous().days_in(zone),
2159 (day("2023-01-01"), day("2024-01-01"))
2160 );
2161 });
2162 }
2163
2164 fn render(source: &str) -> String {
2165 let mut env = minijinja::Environment::new();
2166 env.add_function("chart", chart("USD".into()));
2167 env.render_str(source, minijinja::context! {})
2168 .unwrap_or_else(|err| panic!("{err:#}"))
2169 }
2170
2171 #[test]
2172 fn awkward_data_still_draws() {
2173 let gap =
2175 render(r#"{{ chart("line", labels=["a", "b", "c", "d"], values=[1, none, 3, 4]) }}"#);
2176 assert!(gap.contains("<figure class=\"rx-chart"), "{gap}");
2177 for source in [
2178 r#"{{ chart("bar", [-5, -2, -9]) }}"#,
2179 r#"{{ chart("line", [3, 3, 3]) }}"#,
2180 r#"{{ chart("line", [0, 0]) }}"#,
2181 ] {
2182 assert!(
2183 render(source).contains("<figure class=\"rx-chart"),
2184 "{source}"
2185 );
2186 }
2187 let many = render(
2189 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]}]) }}"#,
2190 );
2191 assert!(many.contains("rx-series-other"), "{many}");
2192 }
2193
2194 #[test]
2195 fn pies_skip_empty_slices_and_draw_a_whole_one_as_a_circle() {
2196 let pie = render(r#"{{ chart("pie", labels=["none", "all"], values=[0, 5]) }}"#);
2197 assert!(
2198 pie.contains(r#"<circle class="rx-chart__slice rx-series-2""#),
2199 "{pie}"
2200 );
2201 assert!(!pie.contains("rx-chart__hole"));
2202 assert!(pie.contains(r#"aria-label="none, all""#), "{pie}");
2203 let ring = render(r#"{{ chart("doughnut", labels=["all"], values=[5], legend=false) }}"#);
2204 assert!(ring.contains("rx-chart__hole"), "{ring}");
2205 assert!(!ring.contains("rx-chart__legend"));
2206 let empty = render(r#"{{ chart("pie", labels=["a"], values=[0]) }}"#);
2207 assert!(!empty.contains("rx-chart__slice"), "{empty}");
2208 }
2209
2210 #[test]
2213 fn data_comes_in_every_shape() {
2214 let list = render(
2215 r#"{{ chart("line", [{"name": "North", "values": [1, 2]}, {"values": [3, 4]}], labels=["x", "y"], name="Sales") }}"#,
2216 );
2217 assert!(list.contains(">North<"), "{list}");
2218 assert!(
2219 list.contains(">Sales 2<"),
2220 "a series without a name: {list}"
2221 );
2222 let one = render(r#"{{ chart("bar", {"name": "Visits", "values": [1, 2]}) }}"#);
2223 assert!(one.contains(">Visits<"), "{one}");
2224 let nested = render(
2225 r#"{{ chart("line", {"labels": ["Mon", "Tue"], "series": [{"name": "Cups", "values": [5, 6]}]}) }}"#,
2226 );
2227 assert!(
2228 nested.contains(">Mon<") && nested.contains(">Cups<"),
2229 "{nested}"
2230 );
2231 let odd = render(r#"{{ chart("line", labels=[1, true], values=["3", "x", 2.5]) }}"#);
2234 assert!(odd.contains(">True<"), "{odd}");
2235 assert!(
2236 odd.contains(">3.0</td>") && odd.contains(">—</td>"),
2237 "{odd}"
2238 );
2239 assert!(odd.contains(r#"<th scope="row">3</th>"#), "{odd}");
2241 }
2242
2243 #[test]
2244 fn axes_for_negative_and_flat_values() {
2245 assert_eq!(scale(-9.0, -2.0), (-10.0, 0.0, 2.5));
2246 let (lo, hi, step) = nice(3.0, 3.0);
2247 assert!(lo < 3.0 && hi > 3.0 && step > 0.0, "{lo} {hi} {step}");
2248 let (lo, hi, _) = nice(-4.0, -4.0);
2249 assert!(lo < -4.0 && hi > -4.0, "{lo} {hi}");
2250 let flat = render(r#"{{ chart("bar", [-4, -4]) }}"#);
2251 assert!(flat.contains(">-4<") || flat.contains(">−4<"), "{flat}");
2252 }
2253
2254 #[test]
2255 fn lines_break_at_gaps_and_keep_their_last_label() {
2256 let gap = render(r#"{{ chart("line", [1, none, 3, 4]) }}"#);
2257 assert_eq!(gap.matches(r#"class="rx-chart__line "#).count(), 2, "{gap}");
2258 let many = render(r#"{{ chart("line", values=range(20)|list) }}"#);
2261 let x = many.split(r#"<div class="rx-chart__x""#).nth(1).unwrap();
2262 let x = x.split("</div>").next().unwrap();
2263 assert!(x.contains(">20</span>") && !x.contains(">19</span>"), "{x}");
2264 assert!(x.contains(">1</span>") && x.contains(">4</span>"), "{x}");
2265 let one = render(r#"{{ chart("line", [5]) }}"#);
2267 assert!(one.contains(r#"style="left: 50%">1</span>"#), "{one}");
2268 }
2269
2270 #[test]
2274 fn fractions_keep_their_decimals() {
2275 let bars = render(r#"{{ chart("bar", [1.5, 2.25, 3]) }}"#);
2276 let table = bars.split("<tbody>").nth(1).unwrap();
2277 assert!(
2278 table.contains(">1.50<") && table.contains(">2.25<") && table.contains(">3.00<"),
2279 "{table}"
2280 );
2281 let pie = render(r#"{{ chart("pie", labels=["a", "b"], values=[4.5, 5.5]) }}"#);
2282 assert!(pie.contains(">4.5<"), "{pie}");
2283 let whole = render(r#"{{ chart("line", [1, 2]) }}"#);
2284 assert!(
2285 whole.split("<tbody>").nth(1).unwrap().contains(">2<"),
2286 "{whole}"
2287 );
2288 let chosen = render(r#"{{ chart("bar", [1.5], decimals=0) }}"#);
2289 assert!(
2290 chosen.split("<tbody>").nth(1).unwrap().contains(">2<"),
2291 "{chosen}"
2292 );
2293 }
2294
2295 #[test]
2298 fn points_come_in_every_shape() {
2299 let nested = render(
2300 r#"{{ chart("scatter", {"series": [{"name": "North", "points": [[1, 2]]}, {"name": "South", "points": [[3, 4]]}]}) }}"#,
2301 );
2302 assert!(
2303 nested.contains(">North<") && nested.contains(">South<"),
2304 "{nested}"
2305 );
2306 let listed = render(r#"{{ chart("scatter", [{"name": "East", "points": [[1, 2]]}]) }}"#);
2307 assert!(listed.contains(r#"data-index="0""#), "{listed}");
2309 let pairs = render(r#"{{ chart("scatter", [[1, 2], [3, 4]], name="Pairs") }}"#);
2310 assert!(pairs.contains(r#"data-index="1""#), "two points: {pairs}");
2311 let one =
2312 render(r#"{{ chart("bubble", {"name": "Sizes", "points": [[1, 2, 3], [2, 3, 9]]}) }}"#);
2313 assert!(one.contains(r#"data-index="1""#), "{one}");
2314 let kwarg = render(
2315 r#"{{ chart("scatter", series=[{"name": "West", "points": [[5, 5]]}, {"name": "Far", "points": [[6, 6]]}]) }}"#,
2316 );
2317 assert!(
2318 kwarg.contains(">West<") && kwarg.contains(">Far<"),
2319 "{kwarg}"
2320 );
2321 let named = render(r#"{{ chart("line", [1, 2], name="Only") }}"#);
2322 assert!(named.contains(">Only<"), "{named}");
2323 }
2324
2325 #[test]
2326 fn scatter_marks_the_zero_line() {
2327 let html = render(r#"{{ chart("scatter", points=[[-5, -10], [8, 20]]) }}"#);
2328 assert!(html.contains("rx-chart__rule--base"), "{html}");
2329 }
2330
2331 #[test]
2332 fn scatter_with_negative_values_only() {
2333 let html = render(r#"{{ chart("scatter", points=[[-5, -10], [-1, -3]]) }}"#);
2334 assert!(
2336 html.contains(r#"<span style="bottom: 0%">-10</span>"#),
2337 "{html}"
2338 );
2339 assert!(html.contains(r#"style="left: 100%">0</span>"#), "{html}");
2340 }
2341}