1use crate::color::{ColorDepth, Rgb};
4use crate::event::Event;
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::keymap::Key;
7use crate::theme::State;
8use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
9
10use super::press::{self, Press};
11
12const DEFAULT_ROWS: u16 = 7;
14
15const LEVELS: u32 = 4;
20
21const MIX: [f32; LEVELS as usize] = [0.30, 0.53, 0.76, 1.0];
24
25const VISIBLE: f64 = 0.03;
27
28type SelectMessage<Msg> = Box<dyn Fn(usize) -> Msg>;
30
31#[derive(Debug, Default)]
33struct HeatmapMemory {
34 cursor: Option<usize>,
36}
37
38pub struct Heatmap<Msg> {
75 values: Vec<f32>,
76 rows: u16,
77 max: Option<f32>,
78 starts_at: u16,
79 series: Option<usize>,
80 selected: Option<usize>,
81 on_select: Option<SelectMessage<Msg>>,
82}
83
84impl<Msg: 'static> Heatmap<Msg> {
85 #[must_use]
87 pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
88 Self {
89 values: values.into_iter().collect(),
90 rows: DEFAULT_ROWS,
91 max: None,
92 starts_at: 0,
93 series: None,
94 selected: None,
95 on_select: None,
96 }
97 }
98
99 #[must_use]
101 pub fn rows(mut self, rows: u16) -> Self {
102 self.rows = rows.max(1);
103 self
104 }
105
106 #[must_use]
108 pub fn max(mut self, max: f32) -> Self {
109 self.max = Some(max);
110 self
111 }
112
113 #[must_use]
116 pub fn starts_at(mut self, row: u16) -> Self {
117 self.starts_at = row % self.rows;
118 self
119 }
120
121 #[must_use]
124 pub fn series(mut self, index: usize) -> Self {
125 self.series = Some(index);
126 self
127 }
128
129 #[must_use]
131 pub fn selected(mut self, index: Option<usize>) -> Self {
132 self.selected = index;
133 self
134 }
135
136 #[must_use]
138 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
139 self.on_select = Some(Box::new(message));
140 self
141 }
142
143 #[must_use]
145 pub fn columns(&self, width: u16) -> u16 {
146 self.total_columns().min(width)
147 }
148
149 fn cell_count(&self) -> usize {
151 usize::from(self.starts_at).saturating_add(self.values.len())
152 }
153
154 fn total_columns(&self) -> u16 {
156 let rows = usize::from(self.rows);
157 let columns = self.cell_count().div_ceil(rows);
158 clamp_u16(i32::try_from(columns).unwrap_or(i32::MAX))
159 }
160
161 fn first_column(&self, width: u16) -> u16 {
163 self.total_columns().saturating_sub(self.columns(width))
164 }
165
166 fn scale(&self) -> f32 {
169 let largest = self.values.iter().copied().fold(0.0, f32::max);
170 let max = self.max.unwrap_or(largest);
171 if max > 0.0 { max } else { 1.0 }
172 }
173
174 fn level(&self, value: f32) -> u32 {
177 if value <= 0.0 {
178 return 0;
179 }
180 let share = (value / self.scale()).clamp(0.0, 1.0);
182 ((share * LEVELS as f32).ceil() as u32).clamp(1, LEVELS)
183 }
184
185 fn cell_rect(&self, area: Rect, index: usize) -> Option<Rect> {
187 let place = usize::from(self.starts_at).checked_add(index)?;
188 let rows = usize::from(self.rows);
189 let column = u16::try_from(place / rows).ok()?;
190 let row = u16::try_from(place % rows).ok()?;
191 let first = self.first_column(area.width);
192 if column < first || row >= area.height {
193 return None;
194 }
195 Some(Rect::new(area.x + i32::from(column - first), area.y + i32::from(row), 1, 1))
196 }
197
198 fn value_at(&self, area: Rect, x: i32, y: i32) -> Option<usize> {
200 if !area.contains(x, y) {
201 return None;
202 }
203 let column = u16::try_from(x - area.x).ok()?.checked_add(self.first_column(area.width))?;
204 let row = u16::try_from(y - area.y).ok()?;
205 let place = usize::from(column).checked_mul(usize::from(self.rows))?.checked_add(usize::from(row))?;
206 let index = place.checked_sub(usize::from(self.starts_at))?;
207 (index < self.values.len()).then_some(index)
208 }
209
210 fn tones(&self, cx: &mut PaintCx<'_>) -> (Rgb, Rgb) {
212 let style = cx.style("heatmap", None, &[]);
213 let empty = style.color("empty").unwrap_or_else(|| cx.color("raised"));
214 let full = style.color("fill").unwrap_or_else(|| match self.series {
215 Some(index) => cx.env().theme().series_color(index),
216 None => cx.color("accent"),
217 });
218 (empty, full)
219 }
220
221 fn lit(&self, cx: &mut PaintCx<'_>, area: Rect) -> (Option<usize>, Option<usize>) {
223 if self.on_select.is_none() {
224 return (None, None);
225 }
226 let pointed = cx.pointer_within().and_then(|(x, y)| self.value_at(area, x, y));
227 let focused = cx.is_focus_visible();
228 let cursor = cx.memory::<HeatmapMemory>().cursor.or(self.selected).filter(|_| focused);
229 (cursor, pointed)
230 }
231
232 fn move_cursor(&self, cx: &mut EventCx<'_, Msg>, step: i32) -> bool {
235 let last = self.values.len().saturating_sub(1);
236 let from = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
237 let target = i32::try_from(from).unwrap_or(0).saturating_add(step);
238 let target = usize::try_from(target.max(0)).unwrap_or(0).min(last);
239 cx.memory::<HeatmapMemory>().cursor = Some(target);
240 true
241 }
242
243 fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
245 cx.memory::<HeatmapMemory>().cursor = Some(index);
246 cx.flash();
247 if let Some(message) = &self.on_select {
248 cx.emit(message(index));
249 }
250 }
251}
252
253fn shown_as(color: Rgb, depth: ColorDepth) -> u32 {
255 match depth {
256 ColorDepth::TrueColor => u32::from(color.r) << 16 | u32::from(color.g) << 8 | u32::from(color.b),
257 ColorDepth::Ansi256 => u32::from(color.to_ansi256()),
258 ColorDepth::Ansi16 => u32::from(color.to_ansi16()),
259 }
260}
261
262fn ramp(empty: Rgb, full: Rgb, depth: ColorDepth) -> Vec<Rgb> {
269 let mut tones = Vec::with_capacity(LEVELS as usize);
270 let mut previous = empty;
271 for mix in MIX {
272 let tone = empty.mix(full, mix);
273 if shown_as(tone, depth) != shown_as(previous, depth) {
274 tones.push(tone);
275 previous = tone;
276 }
277 }
278 if tones.is_empty() {
279 tones.push(full);
280 }
281 tones
282}
283
284fn lift(tone: Rgb, towards: Rgb, empty: Rgb, amount: f32) -> Rgb {
290 let lifted = tone.mix(towards, amount);
291 if lifted.perceptual_distance(tone) < VISIBLE { tone.mix(empty, amount) } else { lifted }
292}
293
294fn tone_of(ramp: &[Rgb], level: u32) -> Option<Rgb> {
297 let steps = u32::try_from(ramp.len()).unwrap_or(1).saturating_sub(1);
298 let last = LEVELS - 1;
299 let index = usize::try_from((level.saturating_sub(1) * steps + last / 2) / last).unwrap_or(0);
301 ramp.get(index).copied()
302}
303
304impl<Msg: 'static> Widget<Msg> for Heatmap<Msg> {
305 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
306 if self.values.is_empty() {
307 return Size::default();
308 }
309 Size::new(self.total_columns(), self.rows).min(available)
310 }
311
312 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
313 if area.is_empty() || self.values.is_empty() {
314 return;
315 }
316 let (empty, full) = self.tones(cx);
317 let steps = ramp(empty, full, cx.env().depth());
318 let (cursor, pointed) = self.lit(cx, area);
319 let style = cx.style("heatmap", None, &[]);
320 let pointer_lift = style.color("cursor").unwrap_or_else(|| cx.color("text"));
321 let keyboard_lift = cx.style("heatmap", None, &[State::Focus]).color("cursor").unwrap_or(pointer_lift);
322 let tone = |level: u32| match level {
323 0 => empty,
324 level => tone_of(&steps, level).unwrap_or(empty),
325 };
326 for (index, value) in self.values.iter().copied().enumerate() {
327 let Some(rect) = self.cell_rect(area, index) else {
328 continue;
329 };
330 let plain = tone(self.level(value));
331 let color = if pointed == Some(index) {
332 lift(plain, pointer_lift, empty, 0.35)
333 } else if pointed.is_none() && cursor == Some(index) {
334 lift(plain, keyboard_lift, empty, 0.5)
335 } else {
336 plain
337 };
338 cx.fill(rect, color);
339 }
340 if self.on_select.is_some() {
341 cx.register_hit(area);
342 }
343 }
344
345 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
346 if self.on_select.is_none() || self.values.is_empty() {
347 return false;
348 }
349 let area = cx.area();
350 let rows = i32::from(self.rows);
351 if let Event::Key(key) = event {
352 if key.is_plain(Key::Left) {
353 return self.move_cursor(cx, -rows);
354 } else if key.is_plain(Key::Right) {
355 return self.move_cursor(cx, rows);
356 } else if key.is_plain(Key::Up) {
357 return self.move_cursor(cx, -1);
358 } else if key.is_plain(Key::Down) {
359 return self.move_cursor(cx, 1);
360 } else if key.is_plain(Key::Home) {
361 return self.move_cursor(cx, i32::MIN);
362 } else if key.is_plain(Key::End) {
363 return self.move_cursor(cx, i32::MAX);
364 }
365 }
366 match press::read(cx, event) {
367 Press::Ignored => false,
368 Press::Used => true,
369 Press::Key => {
370 let last = self.values.len() - 1;
371 let index = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
372 self.choose(cx, index.min(last));
373 true
374 }
375 Press::Click(x, y) => {
376 if let Some(index) = self.value_at(area, x, y) {
377 self.choose(cx, index);
378 }
379 true
380 }
381 }
382 }
383
384 fn focusable(&self) -> bool {
385 self.on_select.is_some()
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::icons::GlyphMode;
393 use crate::runtime::{App, Command, Harness};
394 use crate::widget::{Length, View};
395
396 #[derive(Default)]
397 struct Demo {
398 values: Vec<f32>,
399 rows: u16,
400 height: Option<u16>,
401 starts_at: u16,
402 series: Option<usize>,
403 interactive: bool,
404 chosen: Option<usize>,
405 }
406
407 impl Demo {
408 fn new(values: impl IntoIterator<Item = f32>) -> Self {
409 Self { values: values.into_iter().collect(), rows: 7, ..Self::default() }
410 }
411 }
412
413 impl App for Demo {
414 type Msg = usize;
415 fn update(&mut self, index: usize) -> Command<usize> {
416 self.chosen = Some(index);
417 Command::none()
418 }
419 fn view(&self, ui: &mut View<'_, usize>) {
420 let mut map: Heatmap<usize> =
421 Heatmap::new(self.values.iter().copied()).rows(self.rows).starts_at(self.starts_at);
422 if let Some(index) = self.series {
423 map = map.series(index);
424 }
425 if self.interactive {
426 map = map.selected(self.chosen).on_select(|index| index);
427 }
428 let height = self.height.map_or(Length::Fill(1), Length::Cells);
429 ui.add(map).width(Length::Fill(1)).height(height).id("map");
430 }
431 }
432
433 fn tones(h: &Harness<Demo>) -> (Rgb, [Rgb; 4]) {
435 let theme = h.env().theme();
436 let empty = theme.color("raised").expect("token");
437 let full = theme.color("accent").expect("token");
438 (empty, [0, 1, 2, 3].map(|i| empty.mix(full, MIX[i])))
439 }
440
441 fn blank(h: &Harness<Demo>) -> Option<Rgb> {
443 h.env().theme().color("canvas")
444 }
445
446 #[test]
447 fn a_week_of_days_fills_a_column_and_the_newest_column_is_last() {
448 let h = Harness::new(Demo::new([0.0, 1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 4.0]), 4, 7);
449 let (empty, steps) = tones(&h);
450 assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "a heatmap is made of colour, not characters");
451 assert_eq!(h.bg(0, 0), Some(empty), "a day with nothing takes the empty tone");
452 assert_eq!(h.bg(0, 1), Some(steps[0]), "the quietest day takes the first step");
453 assert_eq!(h.bg(0, 4), Some(steps[3]), "the busiest day takes the top step");
454 assert_eq!(h.bg(1, 0), Some(steps[3]), "the eighth value starts the next column");
455 assert_eq!(h.bg(2, 0), blank(&h), "nothing is drawn past the last column");
456 }
457
458 #[test]
459 fn days_outside_the_range_stay_blank_while_empty_days_take_a_tone() {
460 let mut demo = Demo::new([5.0, 5.0]);
461 demo.starts_at = 3;
462 let h = Harness::new(demo, 2, 7);
463 let (empty, steps) = tones(&h);
464 for row in 0..3 {
465 assert_eq!(h.bg(0, row), blank(&h), "row {row} is before the first value");
466 }
467 assert_eq!(h.bg(0, 3), Some(steps[3]));
468 assert_eq!(h.bg(0, 4), Some(steps[3]));
469 assert_eq!(h.bg(0, 5), blank(&h), "nothing follows the last value");
470 assert_ne!(blank(&h), Some(empty), "a day outside the range is not an empty day");
471 }
472
473 #[test]
474 fn nothing_and_all_zeroes_are_different_pictures() {
475 let nothing = Harness::new(Demo::new([]), 4, 7);
476 assert!((0..7).all(|row| nothing.bg(0, row) == blank(¬hing)), "no values draw nothing at all");
477 let zeroes = Harness::new(Demo::new([0.0; 7]), 4, 7);
478 let (empty, _) = tones(&zeroes);
479 assert!((0..7).all(|row| zeroes.bg(0, row) == Some(empty)), "a quiet week is a column of empty tone");
480 }
481
482 #[test]
483 fn a_single_value_takes_the_top_step_and_a_fixed_scale_holds_it_down() {
484 let h = Harness::new(Demo::new([3.0]), 2, 7);
485 let (_, steps) = tones(&h);
486 assert_eq!(h.bg(0, 0), Some(steps[3]), "the only value is the largest one");
487
488 struct Fixed;
489 impl App for Fixed {
490 type Msg = ();
491 fn update(&mut self, _: ()) -> Command<()> {
492 Command::none()
493 }
494 fn view(&self, ui: &mut View<'_, ()>) {
495 let map: Heatmap<()> = Heatmap::new([3.0]).max(12.0);
496 ui.add(map).width(Length::Fill(1)).height(Length::Fill(1));
497 }
498 }
499 let fixed = Harness::new(Fixed, 2, 7);
500 let theme = fixed.env().theme();
501 let empty = theme.color("raised").expect("token");
502 let step = empty.mix(theme.color("accent").expect("token"), MIX[0]);
503 assert_eq!(fixed.bg(0, 0), Some(step), "a quarter of the goal is the first step");
504 }
505
506 #[test]
507 fn a_narrow_area_keeps_the_newest_weeks_and_says_how_many() {
508 let values: Vec<f32> = (0u16..70).map(|i| f32::from(i % 5)).collect();
509 let map: Heatmap<()> = Heatmap::new(values.iter().copied());
510 assert_eq!(map.columns(80), 10, "ten weeks fit in a wide area");
511 assert_eq!(map.columns(4), 4, "a narrow area shows four weeks");
512 assert_eq!(map.columns(0), 0);
513
514 let h = Harness::new(Demo::new(values), 3, 7);
515 let (_, steps) = tones(&h);
516 assert_eq!(h.bg(2, 0), Some(steps[2]), "the rightmost column is the newest week");
518 assert_eq!(h.bg(0, 0), Some(steps[3]), "the oldest weeks are dropped, not squeezed");
520 }
521
522 #[test]
523 fn a_short_area_keeps_the_rows_that_fit() {
524 let mut demo = Demo::new([4.0; 14]);
525 demo.height = Some(3);
526 let h = Harness::new(demo, 2, 5);
527 let (_, steps) = tones(&h);
528 for column in 0..2 {
529 for row in 0..3 {
530 assert_eq!(h.bg(column, row), Some(steps[3]), "{column},{row}");
531 }
532 }
533 assert_eq!(h.bg(0, 3), blank(&h), "rows past the area are not drawn");
534 }
535
536 #[test]
537 fn tiny_areas_draw_what_they_can_without_panicking() {
538 for (width, height) in [(1, 1), (2, 1), (1, 3), (3, 2)] {
539 let h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0, 5.0]), width, height);
540 assert_eq!(h.screen().lines().count(), usize::from(height), "{width}×{height}");
541 }
542 }
543
544 #[test]
545 fn the_grid_is_the_same_in_every_glyph_mode() {
546 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
547 let mut h = Harness::new(Demo::new([0.0, 2.0, 4.0]), 2, 7);
548 h.set_glyph_mode(mode);
549 let (empty, steps) = tones(&h);
550 assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "{mode:?} draws no characters");
551 assert_eq!(h.bg(0, 0), Some(empty), "{mode:?}");
552 assert_eq!(h.bg(0, 2), Some(steps[3]), "{mode:?}");
553 }
554 }
555
556 #[test]
557 fn every_theme_tells_the_steps_and_the_empty_tone_apart() {
558 for theme in ["monochrome", "nordic", "amber", "iris"] {
559 let mut h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0]), 2, 7);
560 h.set_theme(theme);
561 let drawn: Vec<Rgb> = (0..4).filter_map(|row| h.bg(0, row)).collect();
562 assert_eq!(drawn.len(), 4, "{theme}");
563 let empty = h.env().theme().color("raised").expect("token");
564 for pair in drawn.windows(2) {
565 assert!(
566 pair[1].perceptual_distance(pair[0]) >= VISIBLE,
567 "{theme}: {:?} and {:?} are one tone",
568 pair[0],
569 pair[1]
570 );
571 }
572 assert!(drawn[0].perceptual_distance(empty) >= VISIBLE, "{theme}: a quiet day shows over an empty one");
573 }
574 }
575
576 #[test]
577 fn the_ramp_keeps_only_the_tones_a_terminal_can_tell_apart() {
578 let h = Harness::new(Demo::new([1.0]), 2, 7);
579 let (empty, _) = tones(&h);
580 let full = h.env().theme().color("accent").expect("token");
581
582 let true_color = ramp(empty, full, ColorDepth::TrueColor);
583 assert_eq!(true_color.len(), 4, "true colour shows every step");
584 assert_eq!(tone_of(&true_color, 1), Some(true_color[0]));
585 assert_eq!(tone_of(&true_color, 4), Some(true_color[3]), "the top level takes the full tone");
586
587 for depth in [ColorDepth::Ansi256, ColorDepth::Ansi16] {
588 let steps = ramp(empty, full, depth);
589 assert!(!steps.is_empty(), "{depth:?} still shows that something is there");
590 let shown: Vec<u32> = steps.iter().map(|tone| shown_as(*tone, depth)).collect();
591 let mut distinct = shown.clone();
592 distinct.sort_unstable();
593 distinct.dedup();
594 assert_eq!(distinct.len(), shown.len(), "{depth:?} shows no two steps in one tone: {shown:?}");
595 let levels: Vec<Rgb> = (1..=LEVELS).filter_map(|level| tone_of(&steps, level)).collect();
596 assert_eq!(levels.len(), LEVELS as usize, "{depth:?} gives every level a tone");
597 assert_eq!(levels.last(), steps.last(), "{depth:?} keeps the busiest day the brightest");
598 for pair in levels.windows(2) {
599 assert!(
600 pair[0].relative_luminance() <= pair[1].relative_luminance(),
601 "{depth:?} never turns a busier day quieter: {pair:?}"
602 );
603 }
604 }
605
606 let flat = ramp(empty, empty, ColorDepth::Ansi16);
608 assert_eq!(flat, vec![empty]);
609 assert_eq!(tone_of(&flat, 4), Some(empty));
610 }
611
612 #[test]
613 fn a_heatmap_without_a_message_is_a_picture() {
614 let mut h = Harness::new(Demo::new([1.0, 2.0]), 4, 7);
615 h.press("tab");
616 assert!(!h.is_focused("map"), "a picture takes no focus");
617 h.click(0, 0);
618 assert_eq!(h.app().chosen, None, "a click on a picture chooses nothing");
619 }
620
621 #[test]
622 fn the_pointer_and_the_keyboard_both_reach_a_cell() {
623 let mut demo = Demo::new((0u16..21).map(|i| f32::from(i % 5 + 1)));
624 demo.interactive = true;
625 let mut h = Harness::new(demo, 4, 7);
626 let plain = h.bg(0, 3);
627
628 h.hover(0, 2);
629 let lit = h.bg(0, 2).expect("the hovered cell is drawn");
630 assert_ne!(Some(lit), h.bg(0, 3), "the cell under the pointer lights up");
631 assert_eq!(h.bg(0, 3), plain, "its neighbours keep their tone");
632
633 h.click(0, 2);
634 assert_eq!(h.app().chosen, Some(2), "a click reports the cell");
635
636 h.press("tab");
637 h.press("down");
638 h.press("enter");
639 assert_eq!(h.app().chosen, Some(3), "the keyboard moves one day and Enter reports it");
640 h.press("right");
641 h.press("enter");
642 assert_eq!(h.app().chosen, Some(10), "a column is a week");
643 h.press("left");
644 h.press("up");
645 h.press("enter");
646 assert_eq!(h.app().chosen, Some(2), "back a week and up a day");
647 h.press("home");
648 h.press("enter");
649 assert_eq!(h.app().chosen, Some(0));
650 h.press("end");
651 h.press("enter");
652 assert_eq!(h.app().chosen, Some(20), "End goes to the newest day");
653 }
654
655 #[test]
656 fn the_keyboard_cursor_lights_a_cell_without_choosing_it() {
657 let mut demo = Demo::new([1.0; 7]);
658 demo.interactive = true;
659 let mut h = Harness::new(demo, 2, 7);
660 h.press("tab");
661 h.press("home");
662 let lit = h.bg(0, 0).expect("drawn");
663 let quiet = h.bg(0, 1).expect("drawn");
664 assert_ne!(lit, quiet, "the cursor cell steps away from its tone");
665 assert_eq!(h.app().chosen, None, "moving the cursor chooses nothing");
666 }
667
668 #[test]
669 fn a_lit_cell_steps_away_from_its_tone_in_every_theme() {
670 for theme in ["monochrome", "nordic", "amber", "iris"] {
671 let mut demo = Demo::new([4.0; 7]);
672 demo.interactive = true;
673 let mut h = Harness::new(demo, 2, 7);
674 h.set_theme(theme);
675 h.hover(0, 0);
676 let lit = h.bg(0, 0).expect("drawn");
677 let plain = h.bg(0, 1).expect("drawn");
678 assert!(
679 lit.perceptual_distance(plain) >= VISIBLE,
680 "{theme}: the busiest day shows the pointer ({lit:?} against {plain:?})"
681 );
682 }
683 }
684
685 #[test]
686 fn levels_step_with_the_share_of_the_scale() {
687 let map: Heatmap<()> = Heatmap::new([0.0, 1.0, 25.0, 50.0, 75.0, 100.0]).max(100.0);
688 assert_eq!(map.level(0.0), 0);
689 assert_eq!(map.level(-4.0), 0, "a negative value is nothing");
690 assert_eq!(map.level(1.0), 1, "any day with something reaches the first step");
691 assert_eq!(map.level(25.0), 1);
692 assert_eq!(map.level(26.0), 2);
693 assert_eq!(map.level(75.0), 3);
694 assert_eq!(map.level(76.0), 4);
695 assert_eq!(map.level(100.0), 4);
696 assert_eq!(map.level(400.0), 4, "values above the scale stay at the top step");
697 let zeroes: Heatmap<()> = Heatmap::new([0.0, 0.0]);
698 assert_eq!(zeroes.level(0.0), 0, "a grid of zeroes never lights up");
699 }
700
701 #[test]
702 fn a_series_heatmap_takes_its_tone_from_the_theme() {
703 let mut demo = Demo::new([4.0]);
704 demo.series = Some(2);
705 let h = Harness::new(demo, 2, 7);
706 let theme = h.env().theme();
707 let empty = theme.color("raised").expect("token");
708 assert_eq!(h.bg(0, 0), Some(empty.mix(theme.series_color(2), MIX[3])));
709 }
710}