1use std::fmt;
5
6use hypersig::html::{El, Node, div, each, el, li, span, table, tbody, td, th, thead, tr, ul};
7use hypersig::style::value::{
8 AlignItems, BorderStyle, Color, Display, FlexDirection, FlexWrap, FontVariantNumeric,
9 FontWeight, JustifyContent, Opacity, Overflow, PointerEvents, Position, TextAlign, WhiteSpace,
10 ZIndex, pct, px, raw, rem,
11};
12use hypersig::style::{Condition, Property, PseudoClass, Style, Token};
13use hypersig::{Expr, IntoExpr, Item, Number, NumberFormat, Sig, Textual, if_else};
14
15use crate::look;
16use crate::token;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Kind {
21 Bar,
22 StackedBar,
23 HorizontalBar,
24 Line,
25 Area,
26}
27
28struct Series<S, T> {
31 name: String,
32 color: Token<Color>,
33 value: RowValue<S, T>,
34 text: RowText<S, T>,
35}
36
37type RowValue<S, T> = Box<dyn Fn(Expr<S, T>) -> Expr<S, f64>>;
39
40type RowText<S, T> = Box<dyn Fn(Item<S, T>, Option<&NumberFormat>) -> Expr<S, String>>;
42
43const TICKS: i32 = 6;
47
48pub struct Chart<S, T> {
100 kind: Kind,
101 label: String,
102 rows: Sig<S, Vec<T>>,
103 category: Sig<T, String>,
104 series: Vec<Series<S, T>>,
105 format: Option<NumberFormat>,
106 max_labels: u32,
107}
108
109impl<S: 'static, T: 'static> Chart<S, T> {
110 #[must_use]
114 pub fn bar(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
115 Self::new(Kind::Bar, label.into(), rows, category)
116 }
117
118 #[must_use]
125 pub fn stacked_bar(
126 label: impl Into<String>,
127 rows: Sig<S, Vec<T>>,
128 category: Sig<T, String>,
129 ) -> Self {
130 Self::new(Kind::StackedBar, label.into(), rows, category)
131 }
132
133 #[must_use]
139 pub fn horizontal_bar(
140 label: impl Into<String>,
141 rows: Sig<S, Vec<T>>,
142 category: Sig<T, String>,
143 ) -> Self {
144 Self::new(Kind::HorizontalBar, label.into(), rows, category)
145 }
146
147 #[must_use]
151 pub fn line(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
152 Self::new(Kind::Line, label.into(), rows, category)
153 }
154
155 #[must_use]
159 pub fn area(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
160 Self::new(Kind::Area, label.into(), rows, category)
161 }
162
163 fn new(kind: Kind, label: String, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
164 Self {
165 kind,
166 label,
167 rows,
168 category,
169 series: Vec::new(),
170 format: None,
171 max_labels: 12,
172 }
173 }
174
175 #[must_use]
177 pub fn series<U: Number + Textual + 'static>(
178 mut self,
179 name: impl Into<String>,
180 field: Sig<T, U>,
181 color: Token<Color>,
182 ) -> Self {
183 self.series.push(Series {
184 name: name.into(),
185 color,
186 value: Box::new(move |row| row.at(field).to_f64()),
187 text: Box::new(move |row, format| match format {
188 Some(format) => row.at(field).format(format),
189 None => row.at(field).to_text(),
190 }),
191 });
192 self
193 }
194
195 #[must_use]
199 pub fn format(mut self, format: NumberFormat) -> Self {
200 self.format = Some(format);
201 self
202 }
203
204 #[must_use]
215 pub fn max_labels(mut self, count: u32) -> Self {
216 assert!(count >= 1, "a chart labels at least one row");
217 self.max_labels = count;
218 self
219 }
220
221 #[must_use]
223 pub fn render(self) -> El<S> {
224 let count = self.rows.len().to_f64();
225 let band = Expr::lit(&100.0) / count;
226 let plot = el("svg")
227 .attr("viewBox", "0 0 100 100")
228 .attr("preserveAspectRatio", "none")
229 .attr("width", "100%")
230 .attr("height", "100%")
231 .css(
232 Style::new()
233 .display(Display::Block)
234 .overflow(Overflow::Visible),
235 )
236 .children((0..TICKS).map(|tick| {
237 let at = self.tick(tick, |value, lo, hi| self.place(value, lo, hi).to_fixed(3));
238 let (along, across) = if self.horizontal() {
239 (["y1", "y2"], ["x1", "x2"])
240 } else {
241 (["x1", "x2"], ["y1", "y2"])
242 };
243 el("line")
244 .attr(along[0], "0")
245 .attr(along[1], "100")
246 .attr("vector-effect", "non-scaling-stroke")
247 .css(
248 Style::new()
249 .stroke(token::border().var())
250 .stroke_width(px(1)),
251 )
252 .show(self.tick_shown(tick))
253 .bind_attr(across[0], at.clone())
254 .bind_attr(across[1], at)
255 }))
256 .children(self.marks(&band));
257 let visual = div()
258 .attr("aria-hidden", "true")
259 .css(
260 Style::new()
261 .display(Display::Grid)
262 .grid_template_columns(raw("auto 1fr"))
263 .grid_template_rows(raw("15rem auto"))
264 .column_gap(rem(0.5))
265 .row_gap(rem(0.5))
266 .font_variant_numeric(FontVariantNumeric::TabularNums)
267 .color(token::muted_foreground().var())
268 .font_size(rem(0.75)),
269 )
270 .child(if self.horizontal() {
271 self.category_labels(band.clone())
272 } else {
273 self.value_labels()
274 })
275 .child(
276 div()
277 .css(Style::new().position(Position::Relative))
278 .child(plot)
279 .child(self.hover(&band)),
280 )
281 .child(div())
282 .child(if self.horizontal() {
283 self.value_labels()
284 } else {
285 self.category_labels(band)
286 });
287 el("figure")
288 .attr("aria-label", self.label.clone())
289 .css(
290 Style::new()
291 .display(Display::Flex)
292 .flex_direction(FlexDirection::Column)
293 .row_gap(rem(1))
294 .margin(px(0)),
295 )
296 .child(visual)
297 .child(self.legend())
298 .child(self.data_table())
299 }
300
301 fn scaled<U>(
304 &self,
305 body: impl FnOnce(Expr<S, f64>, Expr<S, f64>, Expr<S, f64>) -> Expr<S, U>,
306 ) -> Expr<S, U> {
307 let (top, bottom) = self.extremes();
308 bottom.let_in(|bottom| {
309 let top = if_else(top.clone().gt(bottom.clone()), top, bottom.clone() + 1.0);
311 top.let_in(|top| {
312 ((top.clone() - bottom.clone()) / 4.0).let_in(|rough| {
313 let guess = Expr::lit(&10.0).pow(rough.clone().log10().floor());
314 guess.let_in(|guess| {
315 let ratio = rough.clone() / guess.clone();
318 let power = if_else(
319 ratio.clone().ge(10.0),
320 guess.clone() * 10.0,
321 if_else(ratio.lt(1.0), guess.clone() / 10.0, guess),
322 );
323 power.let_in(|power| {
324 let n = rough / power.clone();
325 let factor = if_else(
326 n.clone().le(1.0),
327 Expr::lit(&1.0),
328 if_else(
329 n.clone().le(2.0),
330 Expr::lit(&2.0),
331 if_else(
332 n.clone().le(2.5),
333 Expr::lit(&2.5),
334 if_else(n.le(5.0), Expr::lit(&5.0), Expr::lit(&10.0)),
335 ),
336 ),
337 );
338 (power * factor).let_in(|step| {
339 let lo = clean((bottom / step.clone()).floor() * step.clone());
340 let hi = clean((top / step.clone()).ceil() * step.clone());
341 lo.let_in(|lo| hi.let_in(|hi| body(lo, hi, step)))
342 })
343 })
344 })
345 })
346 })
347 })
348 }
349
350 fn horizontal(&self) -> bool {
352 self.kind == Kind::HorizontalBar
353 }
354
355 fn place(&self, value: Expr<S, f64>, lo: Expr<S, f64>, hi: Expr<S, f64>) -> Expr<S, f64> {
359 if self.horizontal() {
360 (value - lo.clone()) / (hi - lo) * 100.0
361 } else {
362 y_of(value, lo, hi)
363 }
364 }
365
366 fn extremes(&self) -> (Expr<S, f64>, Expr<S, f64>) {
368 let rows = self.rows;
369 if self.kind == Kind::StackedBar {
370 let sum = |row: Item<S, T>, positive: bool| stack(&self.series, row, positive);
371 let top = rows.max_of(|row| sum(row, true)).unwrap_or(0.0);
372 let bottom = rows.min_of(|row| sum(row, false)).unwrap_or(0.0);
373 return (top.max(0.0), bottom.min(0.0));
374 }
375 let mut top = Expr::lit(&0.0);
376 let mut bottom = Expr::lit(&0.0);
377 for series in &self.series {
378 let value = &series.value;
379 top = top.max(rows.max_of(|row| value(row.into_expr())).unwrap_or(0.0));
380 bottom = bottom.min(rows.min_of(|row| value(row.into_expr())).unwrap_or(0.0));
381 }
382 (top, bottom)
383 }
384
385 fn tick<U>(
388 &self,
389 tick: i32,
390 body: impl FnOnce(Expr<S, f64>, Expr<S, f64>, Expr<S, f64>) -> Expr<S, U>,
391 ) -> Expr<S, U> {
392 self.scaled(|lo, hi, step| {
393 let value = clean(lo.clone() + step * f64::from(tick));
394 body(value, lo, hi)
395 })
396 }
397
398 fn tick_shown(&self, tick: i32) -> Expr<S, bool> {
400 self.scaled(|lo, hi, step| (lo + step.clone() * f64::from(tick)).le(hi + step / 2.0))
401 }
402
403 fn marks(&self, band: &Expr<S, f64>) -> Vec<Node<S>> {
405 let count: f64 = self.series.iter().map(|_| 1.0).sum();
406 let mut marks = Vec::with_capacity(self.series.len());
407 for (index, series) in self.series.iter().enumerate() {
408 let earlier = &self.series[..index];
409 let color = &series.color;
410 let mark = each(self.rows, |row| {
411 let value = (series.value)(row.into_expr());
412 let x = row.index().to_f64() * band.clone();
413 match self.kind {
414 Kind::Bar | Kind::HorizontalBar => {
415 let width = band.clone() * (0.8 / count);
416 #[expect(
417 clippy::cast_precision_loss,
418 reason = "a chart has far fewer than 2^52 series"
419 )]
420 let left = x + band.clone() * 0.1 + width.clone() * (index as f64);
421 self.bar_mark(Expr::lit(&0.0), value, left, width, color)
422 }
423 Kind::StackedBar => {
424 let start = if_else(
425 value.clone().ge(0.0),
426 stack(earlier, row, true),
427 stack(earlier, row, false),
428 );
429 let end = start.clone() + value;
430 let left = x + band.clone() * 0.1;
431 self.bar_mark(start, end, left, band.clone() * 0.8, color)
432 }
433 Kind::Line | Kind::Area => {
434 let next =
435 (series.value)(self.rows.nth(row.index() + 1).unwrap_or(row.value()));
436 let x1 = x + band.clone() * 0.5;
437 let x2 = x1.clone() + band.clone();
438 self.segment(value, next, x1, x2, color)
439 .show(self.rows.len().gt(row.index() + 1))
440 }
441 }
442 });
443 marks.push(mark);
444 }
445 marks
446 }
447
448 fn bar_mark(
451 &self,
452 start: Expr<S, f64>,
453 end: Expr<S, f64>,
454 offset: Expr<S, f64>,
455 thickness: Expr<S, f64>,
456 color: &Token<Color>,
457 ) -> El<S> {
458 let near = self.scaled(|lo, hi, _| {
459 self.place(start.clone(), lo.clone(), hi.clone())
460 .min(self.place(end.clone(), lo, hi))
461 });
462 let length = self.scaled(|lo, hi, _| {
463 let a = self.place(start, lo.clone(), hi.clone());
464 let b = self.place(end, lo, hi);
465 a.clone().max(b.clone()) - a.min(b)
466 });
467 let ([value_at, value_size], [band_at, band_size]) = if self.horizontal() {
468 (["x", "width"], ["y", "height"])
469 } else {
470 (["y", "height"], ["x", "width"])
471 };
472 el("rect")
473 .css(Style::new().fill(color.var()))
474 .bind_attr(band_at, offset.to_fixed(3))
475 .bind_attr(band_size, thickness.to_fixed(3))
476 .bind_attr(value_at, near.to_fixed(3))
477 .bind_attr(value_size, length.to_fixed(3))
478 }
479
480 fn segment(
483 &self,
484 value: Expr<S, f64>,
485 next: Expr<S, f64>,
486 x1: Expr<S, f64>,
487 x2: Expr<S, f64>,
488 color: &Token<Color>,
489 ) -> El<S> {
490 let y1 = self.scaled(|lo, hi, _| y_of(value, lo, hi).to_fixed(3));
491 let y2 = self.scaled(|lo, hi, _| y_of(next, lo, hi).to_fixed(3));
492 let line = el("line")
493 .attr("vector-effect", "non-scaling-stroke")
494 .attr("stroke-linecap", "round")
495 .css(Style::new().stroke(color.var()).stroke_width(px(2)))
496 .bind_attr("x1", x1.clone().to_fixed(3))
497 .bind_attr("x2", x2.clone().to_fixed(3))
498 .bind_attr("y1", y1.clone())
499 .bind_attr("y2", y2.clone());
500 if self.kind != Kind::Area {
501 return el("g").child(line);
502 }
503 let base = self.scaled(|lo, hi, _| y_of(Expr::lit(&0.0), lo, hi).to_fixed(3));
504 let point = |x: Expr<S, f64>, y: Expr<S, String>| x.to_fixed(3).concat(",").concat(y);
505 let points = point(x1.clone(), base.clone())
506 .concat(" ")
507 .concat(point(x1, y1))
508 .concat(" ")
509 .concat(point(x2.clone(), y2))
510 .concat(" ")
511 .concat(point(x2, base));
512 el("g")
513 .child(
514 el("polygon")
515 .css(Style::new().fill(color.var()).fill_opacity(0.25))
516 .bind_attr("points", points),
517 )
518 .child(line)
519 }
520
521 fn value_labels(&self) -> El<S> {
524 let format = self.format;
525 let horizontal = self.horizontal();
526 let (frame, label, side) = if horizontal {
527 (
528 Style::new().position(Position::Relative).height(rem(1)),
529 Style::new().translate(raw("-50% 0")),
530 Property::Left,
531 )
532 } else {
533 (
534 Style::new()
535 .position(Position::Relative)
536 .min_width(rem(2.5))
537 .text_align(TextAlign::End),
538 Style::new().right(px(0)).translate(raw("0 -50%")),
539 Property::Top,
540 )
541 };
542 div().css(frame).children((0..TICKS).map(|tick| {
543 let at = self.tick(tick, |value, lo, hi| {
544 self.place(value, lo, hi).to_fixed(3).concat("%")
545 });
546 let text = self.tick(tick, |value, _, _| match &format {
547 Some(format) => value.format(format),
548 None => value.to_text(),
549 });
550 span()
551 .css(
552 Style::new()
553 .position(Position::Absolute)
554 .white_space(WhiteSpace::Nowrap)
555 .merge(label.clone()),
556 )
557 .show(self.tick_shown(tick))
558 .bind_style(side, at)
559 .bind_text(text)
560 }))
561 }
562
563 fn hover(&self, band: &Expr<S, f64>) -> El<S> {
566 let category = self.category;
567 let series = &self.series;
568 div()
569 .css(Style::new().position(Position::Absolute).inset(px(0)))
570 .child(each(self.rows, |row| {
571 let start = (row.index().to_f64() * band.clone())
572 .to_fixed(3)
573 .concat("%");
574 let (place, span_style, across) = if self.horizontal() {
575 (
576 Property::Top,
577 Property::Height,
578 Style::new().left(px(0)).right(px(0)),
579 )
580 } else {
581 (
582 Property::Left,
583 Property::Width,
584 Style::new().top(px(0)).bottom(px(0)),
585 )
586 };
587 let mut values = div().css(
588 Style::new()
589 .display(Display::Grid)
590 .grid_template_columns(raw("auto 1fr auto"))
591 .column_gap(rem(0.5))
592 .align_items(AlignItems::Center),
593 );
594 for series in series {
595 values = values
596 .child(swatch(&series.color))
597 .child(span().text(series.name.clone()))
598 .child(
599 span()
600 .css(
601 Style::new()
602 .font_weight(FontWeight::Number(500.0))
603 .color(token::popover_foreground().var()),
604 )
605 .bind_text((series.text)(row, self.format.as_ref())),
606 );
607 }
608 div()
609 .css(
610 Style::new()
611 .position(Position::Absolute)
612 .merge(across)
613 .group()
614 .on(
615 PseudoClass::Hover,
616 Style::new().box_shadow(raw(
619 "inset 0 0 0 100vmax color-mix(in srgb, currentColor 8%, transparent)",
620 )),
621 ),
622 )
623 .bind_style(place, start)
624 .bind_style(span_style, band.clone().to_fixed(3).concat("%"))
625 .child(
626 div()
627 .css(tooltip_style())
628 .child(
629 div()
630 .css(
631 Style::new()
632 .font_weight(FontWeight::Number(500.0))
633 .color(token::popover_foreground().var()),
634 )
635 .bind_text(row.at(category)),
636 )
637 .child(values),
638 )
639 }))
640 }
641
642 fn category_labels(&self, band: Expr<S, f64>) -> El<S> {
646 let category = self.category;
647 let every = (self.rows.len().to_f64() / f64::from(self.max_labels)).ceil();
648 let (frame, label, side) = if self.horizontal() {
649 (
650 Style::new()
651 .position(Position::Relative)
652 .min_width(rem(4))
653 .text_align(TextAlign::End),
654 Style::new().right(px(0)).translate(raw("0 -50%")),
655 Property::Top,
656 )
657 } else {
658 (
659 Style::new().position(Position::Relative).height(rem(1)),
660 Style::new().translate(raw("-50% 0")),
661 Property::Left,
662 )
663 };
664 div().css(frame).child(each(self.rows, |row| {
665 let middle = ((row.index().to_f64() + 0.5) * band)
666 .to_fixed(3)
667 .concat("%");
668 span()
669 .css(
670 Style::new()
671 .position(Position::Absolute)
672 .white_space(WhiteSpace::Nowrap)
673 .merge(label),
674 )
675 .show((row.index().to_f64() % every.clone()).equals(0.0))
676 .bind_style(side, middle)
677 .bind_text(row.at(category))
678 }))
679 }
680
681 fn legend(&self) -> El<S> {
683 ul().attr("aria-hidden", "true")
684 .css(
685 Style::new()
686 .display(Display::Flex)
687 .flex_wrap(FlexWrap::Wrap)
688 .justify_content(JustifyContent::Center)
689 .column_gap(rem(1))
690 .margin(px(0))
691 .padding(px(0))
692 .list_style_type(raw("none"))
693 .merge(look::small_text()),
694 )
695 .children(self.series.iter().map(|series| {
696 li().css(
697 Style::new()
698 .display(Display::Flex)
699 .align_items(AlignItems::Center)
700 .column_gap(rem(0.5)),
701 )
702 .child(swatch(&series.color))
703 .child(span().text(series.name.clone()))
704 }))
705 }
706
707 fn data_table(&self) -> El<S> {
709 let mut head = tr().child(th().attr("scope", "col"));
710 for series in &self.series {
711 head = head.child(th().attr("scope", "col").text(series.name.clone()));
712 }
713 let category = self.category;
714 let series = &self.series;
715 table()
716 .css(visually_hidden())
717 .child(el("caption").text(self.label.clone()))
718 .child(thead().child(head))
719 .child(tbody().child(each(self.rows, |row| {
720 let mut line = tr().child(th().attr("scope", "row").bind_text(row.at(category)));
721 for series in series {
722 line = line.child(td().bind_text((series.text)(row, self.format.as_ref())));
723 }
724 line
725 })))
726 }
727}
728
729impl<S, T> fmt::Debug for Chart<S, T> {
730 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
731 f.debug_struct("Chart")
732 .field("kind", &self.kind)
733 .field("label", &self.label)
734 .field("rows", &self.rows)
735 .field("category", &self.category)
736 .field("format", &self.format)
737 .field("max_labels", &self.max_labels)
738 .field(
739 "series",
740 &self
741 .series
742 .iter()
743 .map(|series| &series.name)
744 .collect::<Vec<_>>(),
745 )
746 .finish()
747 }
748}
749
750impl<S: 'static, T: 'static> From<Chart<S, T>> for El<S> {
751 fn from(chart: Chart<S, T>) -> Self {
752 chart.render()
753 }
754}
755
756impl<S: 'static, T: 'static> From<Chart<S, T>> for Node<S> {
757 fn from(chart: Chart<S, T>) -> Self {
758 chart.render().into()
759 }
760}
761
762fn stack<S, T>(series: &[Series<S, T>], row: Item<S, T>, positive: bool) -> Expr<S, f64> {
765 series.iter().fold(Expr::lit(&0.0), |sum, series| {
766 let value = (series.value)(row.into_expr());
767 sum + if positive {
768 value.max(0.0)
769 } else {
770 value.min(0.0)
771 }
772 })
773}
774
775fn y_of<S>(value: Expr<S, f64>, lo: Expr<S, f64>, hi: Expr<S, f64>) -> Expr<S, f64> {
778 (hi.clone() - value) / (hi - lo) * 100.0
779}
780
781fn clean<S>(value: Expr<S, f64>) -> Expr<S, f64> {
784 (value * 1e9).round() / 1e9
785}
786
787fn swatch<S>(color: &Token<Color>) -> El<S> {
789 span().css(
790 Style::new()
791 .display(Display::InlineBlock)
792 .width(rem(0.625))
793 .height(rem(0.625))
794 .border_radius(px(2))
795 .background_color(color.var()),
796 )
797}
798
799fn tooltip_style() -> Style {
801 Style::new()
802 .display(Display::None)
803 .position(Position::Absolute)
804 .top(rem(0.5))
805 .left(pct(50))
806 .translate(raw("-50% 0"))
807 .z_index(ZIndex::Integer(10))
808 .min_width(rem(8))
809 .padding_block(rem(0.375))
810 .padding_inline(rem(0.625))
811 .border_width(px(1))
812 .border_style(BorderStyle::Solid)
813 .border_color(token::border().var())
814 .border_radius(rem(0.5))
815 .background_color(token::popover().var())
816 .box_shadow(raw("0 4px 12px rgb(0 0 0 / 0.1)"))
817 .white_space(WhiteSpace::Nowrap)
818 .pointer_events(PointerEvents::None)
819 .on(
820 Condition::Group(PseudoClass::Hover),
821 Style::new().display(Display::Flex),
822 )
823 .flex_direction(FlexDirection::Column)
824 .row_gap(rem(0.25))
825 .opacity(Opacity::Number(1.0))
826 .color(Color::CURRENT_COLOR)
827}
828
829pub(crate) fn visually_hidden() -> Style {
831 Style::new()
832 .position(Position::Absolute)
833 .width(px(1))
834 .height(px(1))
835 .overflow(Overflow::Hidden)
836 .clip_path(raw("inset(50%)"))
837 .white_space(WhiteSpace::Nowrap)
838}
839
840#[cfg(test)]
841mod tests {
842 use hypersig::html::span;
843 use hypersig::{Page, Signals};
844 use serde::{Deserialize, Serialize};
845
846 use super::Chart;
847 use crate::token;
848
849 #[derive(Signals, Serialize, Deserialize)]
850 struct Point {
851 name: String,
852 value: f64,
853 }
854
855 #[derive(Signals, Serialize, Deserialize)]
856 struct Plot {
857 points: Vec<Point>,
858 }
859
860 fn fit(values: &[f64]) -> String {
863 let chart = Chart::bar("Points", Plot::points(), Point::name()).series(
864 "Value",
865 Point::value(),
866 token::chart_1(),
867 );
868 let text = chart.scaled(|lo, hi, step| {
869 lo.to_text()
870 .concat(" ")
871 .concat(hi.to_text())
872 .concat(" ")
873 .concat(step.to_text())
874 });
875 let points = values
876 .iter()
877 .map(|&value| Point {
878 name: String::new(),
879 value,
880 })
881 .collect();
882 let html = Page::new("Points", Plot { points })
883 .child(span().bind_text(text))
884 .render();
885 let end = html.find("</span>").expect("the span is rendered");
886 let start = html[..end].rfind('>').expect("the span has a start tag") + 1;
887 html[start..end].to_owned()
888 }
889
890 #[test]
891 fn rounds_out_to_a_step_of_one_two_two_and_a_half_or_five() {
892 assert_eq!(fit(&[186.0, 305.0, 237.0]), "0 400 100");
893 assert_eq!(fit(&[820.0]), "0 1000 250");
894 assert_eq!(fit(&[7.0]), "0 8 2");
895 assert_eq!(fit(&[400.0]), "0 400 100");
896 assert_eq!(fit(&[1000.0]), "0 1000 250");
897 }
898
899 #[test]
900 fn keeps_small_steps_free_of_float_error() {
901 assert_eq!(fit(&[0.3]), "0 0.3 0.1");
902 }
903
904 #[test]
905 fn reaches_below_zero_for_a_negative_value() {
906 assert_eq!(fit(&[120.0, -45.0]), "-50 150 50");
907 assert_eq!(fit(&[-7.0, -2.0]), "-8 0 2");
908 }
909
910 #[test]
911 fn runs_from_zero_to_one_without_a_value_other_than_zero() {
912 assert_eq!(fit(&[]), "0 1 0.25");
913 assert_eq!(fit(&[0.0, 0.0]), "0 1 0.25");
914 }
915}