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 ramp(empty: Rgb, full: Rgb, depth: ColorDepth, ground: Rgb) -> Vec<Rgb> {
261 let mut tones = Vec::with_capacity(LEVELS as usize);
262 let mut previous = empty;
263 for mix in MIX {
264 let tone = empty.mix(full, mix);
265 if depth.tells_apart(tone, previous, ground) {
266 tones.push(tone);
267 previous = tone;
268 }
269 }
270 if tones.is_empty() {
271 tones.push(full);
272 }
273 tones
274}
275
276fn lift(tone: Rgb, towards: Rgb, empty: Rgb, amount: f32) -> Rgb {
282 let lifted = tone.mix(towards, amount);
283 if lifted.perceptual_distance(tone) < VISIBLE { tone.mix(empty, amount) } else { lifted }
284}
285
286fn tone_of(ramp: &[Rgb], level: u32) -> Option<Rgb> {
289 let steps = u32::try_from(ramp.len()).unwrap_or(1).saturating_sub(1);
290 let last = LEVELS - 1;
291 let index = usize::try_from((level.saturating_sub(1) * steps + last / 2) / last).unwrap_or(0);
293 ramp.get(index).copied()
294}
295
296impl<Msg: 'static> Widget<Msg> for Heatmap<Msg> {
297 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
298 if self.values.is_empty() {
299 return Size::default();
300 }
301 Size::new(self.total_columns(), self.rows).min(available)
302 }
303
304 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
305 if area.is_empty() || self.values.is_empty() {
306 return;
307 }
308 let (empty, full) = self.tones(cx);
309 let steps = ramp(empty, full, cx.env().depth(), cx.color("canvas"));
310 let (cursor, pointed) = self.lit(cx, area);
311 let style = cx.style("heatmap", None, &[]);
312 let pointer_lift = style.color("cursor").unwrap_or_else(|| cx.color("text"));
313 let keyboard_lift = cx.style("heatmap", None, &[State::Focus]).color("cursor").unwrap_or(pointer_lift);
314 let tone = |level: u32| match level {
315 0 => empty,
316 level => tone_of(&steps, level).unwrap_or(empty),
317 };
318 for (index, value) in self.values.iter().copied().enumerate() {
319 let Some(rect) = self.cell_rect(area, index) else {
320 continue;
321 };
322 let plain = tone(self.level(value));
323 let color = if pointed == Some(index) {
324 lift(plain, pointer_lift, empty, 0.35)
325 } else if pointed.is_none() && cursor == Some(index) {
326 lift(plain, keyboard_lift, empty, 0.5)
327 } else {
328 plain
329 };
330 cx.fill(rect, color);
331 }
332 if self.on_select.is_some() {
333 cx.register_hit(area);
334 }
335 }
336
337 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
338 if self.on_select.is_none() || self.values.is_empty() {
339 return false;
340 }
341 let area = cx.area();
342 let rows = i32::from(self.rows);
343 if let Event::Key(key) = event {
344 if key.is_plain(Key::Left) {
345 return self.move_cursor(cx, -rows);
346 } else if key.is_plain(Key::Right) {
347 return self.move_cursor(cx, rows);
348 } else if key.is_plain(Key::Up) {
349 return self.move_cursor(cx, -1);
350 } else if key.is_plain(Key::Down) {
351 return self.move_cursor(cx, 1);
352 } else if key.is_plain(Key::Home) {
353 return self.move_cursor(cx, i32::MIN);
354 } else if key.is_plain(Key::End) {
355 return self.move_cursor(cx, i32::MAX);
356 }
357 }
358 match press::read(cx, event) {
359 Press::Ignored => false,
360 Press::Used => true,
361 Press::Key => {
362 let last = self.values.len() - 1;
363 let index = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
364 self.choose(cx, index.min(last));
365 true
366 }
367 Press::Click(x, y) => {
368 if let Some(index) = self.value_at(area, x, y) {
369 self.choose(cx, index);
370 }
371 true
372 }
373 }
374 }
375
376 fn focusable(&self) -> bool {
377 self.on_select.is_some()
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::icons::GlyphMode;
385 use crate::runtime::{App, Command, Harness};
386 use crate::widget::{Length, View};
387
388 #[derive(Default)]
389 struct Demo {
390 values: Vec<f32>,
391 rows: u16,
392 height: Option<u16>,
393 starts_at: u16,
394 series: Option<usize>,
395 interactive: bool,
396 chosen: Option<usize>,
397 }
398
399 impl Demo {
400 fn new(values: impl IntoIterator<Item = f32>) -> Self {
401 Self { values: values.into_iter().collect(), rows: 7, ..Self::default() }
402 }
403 }
404
405 impl App for Demo {
406 type Msg = usize;
407 fn update(&mut self, index: usize) -> Command<usize> {
408 self.chosen = Some(index);
409 Command::none()
410 }
411 fn view(&self, ui: &mut View<'_, usize>) {
412 let mut map: Heatmap<usize> =
413 Heatmap::new(self.values.iter().copied()).rows(self.rows).starts_at(self.starts_at);
414 if let Some(index) = self.series {
415 map = map.series(index);
416 }
417 if self.interactive {
418 map = map.selected(self.chosen).on_select(|index| index);
419 }
420 let height = self.height.map_or(Length::Fill(1), Length::Cells);
421 ui.add(map).width(Length::Fill(1)).height(height).id("map");
422 }
423 }
424
425 fn tones(h: &Harness<Demo>) -> (Rgb, [Rgb; 4]) {
427 let theme = h.env().theme();
428 let empty = theme.color("raised").expect("token");
429 let full = theme.color("accent").expect("token");
430 (empty, [0, 1, 2, 3].map(|i| empty.mix(full, MIX[i])))
431 }
432
433 fn blank(h: &Harness<Demo>) -> Option<Rgb> {
435 h.env().theme().color("canvas")
436 }
437
438 #[test]
439 fn a_week_of_days_fills_a_column_and_the_newest_column_is_last() {
440 let h = Harness::new(Demo::new([0.0, 1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 4.0]), 4, 7);
441 let (empty, steps) = tones(&h);
442 assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "a heatmap is made of colour, not characters");
443 assert_eq!(h.bg(0, 0), Some(empty), "a day with nothing takes the empty tone");
444 assert_eq!(h.bg(0, 1), Some(steps[0]), "the quietest day takes the first step");
445 assert_eq!(h.bg(0, 4), Some(steps[3]), "the busiest day takes the top step");
446 assert_eq!(h.bg(1, 0), Some(steps[3]), "the eighth value starts the next column");
447 assert_eq!(h.bg(2, 0), blank(&h), "nothing is drawn past the last column");
448 }
449
450 #[test]
451 fn days_outside_the_range_stay_blank_while_empty_days_take_a_tone() {
452 let mut demo = Demo::new([5.0, 5.0]);
453 demo.starts_at = 3;
454 let h = Harness::new(demo, 2, 7);
455 let (empty, steps) = tones(&h);
456 for row in 0..3 {
457 assert_eq!(h.bg(0, row), blank(&h), "row {row} is before the first value");
458 }
459 assert_eq!(h.bg(0, 3), Some(steps[3]));
460 assert_eq!(h.bg(0, 4), Some(steps[3]));
461 assert_eq!(h.bg(0, 5), blank(&h), "nothing follows the last value");
462 assert_ne!(blank(&h), Some(empty), "a day outside the range is not an empty day");
463 }
464
465 #[test]
466 fn nothing_and_all_zeroes_are_different_pictures() {
467 let nothing = Harness::new(Demo::new([]), 4, 7);
468 assert!((0..7).all(|row| nothing.bg(0, row) == blank(¬hing)), "no values draw nothing at all");
469 let zeroes = Harness::new(Demo::new([0.0; 7]), 4, 7);
470 let (empty, _) = tones(&zeroes);
471 assert!((0..7).all(|row| zeroes.bg(0, row) == Some(empty)), "a quiet week is a column of empty tone");
472 }
473
474 #[test]
475 fn a_single_value_takes_the_top_step_and_a_fixed_scale_holds_it_down() {
476 let h = Harness::new(Demo::new([3.0]), 2, 7);
477 let (_, steps) = tones(&h);
478 assert_eq!(h.bg(0, 0), Some(steps[3]), "the only value is the largest one");
479
480 struct Fixed;
481 impl App for Fixed {
482 type Msg = ();
483 fn update(&mut self, _: ()) -> Command<()> {
484 Command::none()
485 }
486 fn view(&self, ui: &mut View<'_, ()>) {
487 let map: Heatmap<()> = Heatmap::new([3.0]).max(12.0);
488 ui.add(map).width(Length::Fill(1)).height(Length::Fill(1));
489 }
490 }
491 let fixed = Harness::new(Fixed, 2, 7);
492 let theme = fixed.env().theme();
493 let empty = theme.color("raised").expect("token");
494 let step = empty.mix(theme.color("accent").expect("token"), MIX[0]);
495 assert_eq!(fixed.bg(0, 0), Some(step), "a quarter of the goal is the first step");
496 }
497
498 #[test]
499 fn a_narrow_area_keeps_the_newest_weeks_and_says_how_many() {
500 let values: Vec<f32> = (0u16..70).map(|i| f32::from(i % 5)).collect();
501 let map: Heatmap<()> = Heatmap::new(values.iter().copied());
502 assert_eq!(map.columns(80), 10, "ten weeks fit in a wide area");
503 assert_eq!(map.columns(4), 4, "a narrow area shows four weeks");
504 assert_eq!(map.columns(0), 0);
505
506 let h = Harness::new(Demo::new(values), 3, 7);
507 let (_, steps) = tones(&h);
508 assert_eq!(h.bg(2, 0), Some(steps[2]), "the rightmost column is the newest week");
510 assert_eq!(h.bg(0, 0), Some(steps[3]), "the oldest weeks are dropped, not squeezed");
512 }
513
514 #[test]
515 fn a_short_area_keeps_the_rows_that_fit() {
516 let mut demo = Demo::new([4.0; 14]);
517 demo.height = Some(3);
518 let h = Harness::new(demo, 2, 5);
519 let (_, steps) = tones(&h);
520 for column in 0..2 {
521 for row in 0..3 {
522 assert_eq!(h.bg(column, row), Some(steps[3]), "{column},{row}");
523 }
524 }
525 assert_eq!(h.bg(0, 3), blank(&h), "rows past the area are not drawn");
526 }
527
528 #[test]
529 fn tiny_areas_draw_what_they_can_without_panicking() {
530 for (width, height) in [(1, 1), (2, 1), (1, 3), (3, 2)] {
531 let h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0, 5.0]), width, height);
532 assert_eq!(h.screen().lines().count(), usize::from(height), "{width}×{height}");
533 }
534 }
535
536 #[test]
537 fn the_grid_is_the_same_in_every_glyph_mode() {
538 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
539 let mut h = Harness::new(Demo::new([0.0, 2.0, 4.0]), 2, 7);
540 h.set_glyph_mode(mode);
541 let (empty, steps) = tones(&h);
542 assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "{mode:?} draws no characters");
543 assert_eq!(h.bg(0, 0), Some(empty), "{mode:?}");
544 assert_eq!(h.bg(0, 2), Some(steps[3]), "{mode:?}");
545 }
546 }
547
548 #[test]
549 fn every_theme_tells_the_steps_and_the_empty_tone_apart() {
550 for theme in ["monochrome", "nordic", "amber", "iris"] {
551 let mut h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0]), 2, 7);
552 h.set_theme(theme);
553 let drawn: Vec<Rgb> = (0..4).filter_map(|row| h.bg(0, row)).collect();
554 assert_eq!(drawn.len(), 4, "{theme}");
555 let empty = h.env().theme().color("raised").expect("token");
556 for pair in drawn.windows(2) {
557 assert!(
558 pair[1].perceptual_distance(pair[0]) >= VISIBLE,
559 "{theme}: {:?} and {:?} are one tone",
560 pair[0],
561 pair[1]
562 );
563 }
564 assert!(drawn[0].perceptual_distance(empty) >= VISIBLE, "{theme}: a quiet day shows over an empty one");
565 }
566 }
567
568 #[test]
569 fn the_ramp_keeps_only_the_tones_a_terminal_can_tell_apart() {
570 let h = Harness::new(Demo::new([1.0]), 2, 7);
571 let (empty, _) = tones(&h);
572 let full = h.env().theme().color("accent").expect("token");
573 let ground = h.env().theme().color("canvas").expect("token");
574
575 let true_color = ramp(empty, full, ColorDepth::TrueColor, ground);
576 assert_eq!(true_color.len(), 4, "true colour shows every step");
577 assert_eq!(tone_of(&true_color, 1), Some(true_color[0]));
578 assert_eq!(tone_of(&true_color, 4), Some(true_color[3]), "the top level takes the full tone");
579
580 for depth in [ColorDepth::Ansi256, ColorDepth::Ansi16] {
581 let steps = ramp(empty, full, depth, ground);
582 assert!(!steps.is_empty(), "{depth:?} still shows that something is there");
583 let shown: Vec<u32> = steps.iter().map(|tone| depth.shown(*tone, ground)).collect();
584 let mut distinct = shown.clone();
585 distinct.sort_unstable();
586 distinct.dedup();
587 assert_eq!(distinct.len(), shown.len(), "{depth:?} shows no two steps in one tone: {shown:?}");
588 let levels: Vec<Rgb> = (1..=LEVELS).filter_map(|level| tone_of(&steps, level)).collect();
589 assert_eq!(levels.len(), LEVELS as usize, "{depth:?} gives every level a tone");
590 assert_eq!(levels.last(), steps.last(), "{depth:?} keeps the busiest day the brightest");
591 for pair in levels.windows(2) {
592 assert!(
593 pair[0].relative_luminance() <= pair[1].relative_luminance(),
594 "{depth:?} never turns a busier day quieter: {pair:?}"
595 );
596 }
597 }
598
599 let flat = ramp(empty, empty, ColorDepth::Ansi16, ground);
601 assert_eq!(flat, vec![empty]);
602 assert_eq!(tone_of(&flat, 4), Some(empty));
603 }
604
605 #[test]
606 fn a_heatmap_without_a_message_is_a_picture() {
607 let mut h = Harness::new(Demo::new([1.0, 2.0]), 4, 7);
608 h.press("tab");
609 assert!(!h.is_focused("map"), "a picture takes no focus");
610 h.click(0, 0);
611 assert_eq!(h.app().chosen, None, "a click on a picture chooses nothing");
612 }
613
614 #[test]
615 fn the_pointer_and_the_keyboard_both_reach_a_cell() {
616 let mut demo = Demo::new((0u16..21).map(|i| f32::from(i % 5 + 1)));
617 demo.interactive = true;
618 let mut h = Harness::new(demo, 4, 7);
619 let plain = h.bg(0, 3);
620
621 h.hover(0, 2);
622 let lit = h.bg(0, 2).expect("the hovered cell is drawn");
623 assert_ne!(Some(lit), h.bg(0, 3), "the cell under the pointer lights up");
624 assert_eq!(h.bg(0, 3), plain, "its neighbours keep their tone");
625
626 h.click(0, 2);
627 assert_eq!(h.app().chosen, Some(2), "a click reports the cell");
628
629 h.press("tab");
630 h.press("down");
631 h.press("enter");
632 assert_eq!(h.app().chosen, Some(3), "the keyboard moves one day and Enter reports it");
633 h.press("right");
634 h.press("enter");
635 assert_eq!(h.app().chosen, Some(10), "a column is a week");
636 h.press("left");
637 h.press("up");
638 h.press("enter");
639 assert_eq!(h.app().chosen, Some(2), "back a week and up a day");
640 h.press("home");
641 h.press("enter");
642 assert_eq!(h.app().chosen, Some(0));
643 h.press("end");
644 h.press("enter");
645 assert_eq!(h.app().chosen, Some(20), "End goes to the newest day");
646 }
647
648 #[test]
649 fn the_keyboard_cursor_lights_a_cell_without_choosing_it() {
650 let mut demo = Demo::new([1.0; 7]);
651 demo.interactive = true;
652 let mut h = Harness::new(demo, 2, 7);
653 h.press("tab");
654 h.press("home");
655 let lit = h.bg(0, 0).expect("drawn");
656 let quiet = h.bg(0, 1).expect("drawn");
657 assert_ne!(lit, quiet, "the cursor cell steps away from its tone");
658 assert_eq!(h.app().chosen, None, "moving the cursor chooses nothing");
659 }
660
661 #[test]
662 fn a_lit_cell_steps_away_from_its_tone_in_every_theme() {
663 for theme in ["monochrome", "nordic", "amber", "iris"] {
664 let mut demo = Demo::new([4.0; 7]);
665 demo.interactive = true;
666 let mut h = Harness::new(demo, 2, 7);
667 h.set_theme(theme);
668 h.hover(0, 0);
669 let lit = h.bg(0, 0).expect("drawn");
670 let plain = h.bg(0, 1).expect("drawn");
671 assert!(
672 lit.perceptual_distance(plain) >= VISIBLE,
673 "{theme}: the busiest day shows the pointer ({lit:?} against {plain:?})"
674 );
675 }
676 }
677
678 #[test]
679 fn levels_step_with_the_share_of_the_scale() {
680 let map: Heatmap<()> = Heatmap::new([0.0, 1.0, 25.0, 50.0, 75.0, 100.0]).max(100.0);
681 assert_eq!(map.level(0.0), 0);
682 assert_eq!(map.level(-4.0), 0, "a negative value is nothing");
683 assert_eq!(map.level(1.0), 1, "any day with something reaches the first step");
684 assert_eq!(map.level(25.0), 1);
685 assert_eq!(map.level(26.0), 2);
686 assert_eq!(map.level(75.0), 3);
687 assert_eq!(map.level(76.0), 4);
688 assert_eq!(map.level(100.0), 4);
689 assert_eq!(map.level(400.0), 4, "values above the scale stay at the top step");
690 let zeroes: Heatmap<()> = Heatmap::new([0.0, 0.0]);
691 assert_eq!(zeroes.level(0.0), 0, "a grid of zeroes never lights up");
692 }
693
694 #[test]
695 fn a_series_heatmap_takes_its_tone_from_the_theme() {
696 let mut demo = Demo::new([4.0]);
697 demo.series = Some(2);
698 let h = Harness::new(demo, 2, 7);
699 let theme = h.env().theme();
700 let empty = theme.color("raised").expect("token");
701 assert_eq!(h.bg(0, 0), Some(empty.mix(theme.series_color(2), MIX[3])));
702 }
703}