Skip to main content

egui_charts/ui/widget_bar/panels/
market_heatmap.rs

1//! A grouped treemap of values and changes supplied by the host.
2
3use super::controls::dropdown;
4use crate::localization as l10n;
5use crate::services::MarketHeatmapSelection;
6use crate::theme::ThemeExt;
7use crate::ui_kit::{EmptyState, PanelHeader};
8use egui::{Align2, Color32, FontId, Pos2, Rect, Sense, Stroke, Ui, Vec2};
9use std::collections::BTreeMap;
10
11/// One instrument of the market heatmap. The library computes no market data.
12#[derive(Clone, Debug, PartialEq)]
13pub struct MarketHeatmapEntry {
14    /// Symbol returned when the tile is selected.
15    pub symbol: String,
16    /// Sector or another group named by the host.
17    pub group: String,
18    /// The value of each metric by [`MarketHeatmapMetric::id`]. A missing or non-finite value is
19    /// unavailable for this tile.
20    pub values: BTreeMap<String, f64>,
21}
22
23/// A market the heatmap can show, with its instruments.
24#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct MarketHeatmapUniverse {
27    /// Identifies the universe in [`MarketHeatmapSelection::universe`].
28    pub id: String,
29    /// The name shown, such as `"S&P 500"`.
30    pub name: String,
31    /// The instruments to show, grouped by `group`.
32    pub entries: Vec<MarketHeatmapEntry>,
33}
34
35impl MarketHeatmapUniverse {
36    /// The universe `id` named `name` of `entries`.
37    pub fn new(
38        id: impl Into<String>,
39        name: impl Into<String>,
40        entries: Vec<MarketHeatmapEntry>,
41    ) -> Self {
42        Self {
43            id: id.into(),
44            name: name.into(),
45            entries,
46        }
47    }
48}
49
50/// A quantity the host supplies for each tile, to size or to colour the tiles by.
51#[derive(Clone, Debug, PartialEq)]
52#[non_exhaustive]
53pub struct MarketHeatmapMetric {
54    /// Identifies the metric in [`MarketHeatmapEntry::values`] and in the selection.
55    pub id: String,
56    /// The name shown, such as `"Market cap"`.
57    pub label: String,
58    /// The unit of its values, such as `"%"` or `"USD"`; empty for none.
59    pub unit: String,
60    /// Whether the host has values for it now. An unavailable metric is listed but cannot be
61    /// chosen, and a chosen one that becomes unavailable stays chosen.
62    pub available: bool,
63    /// The magnitude that colours a tile at full strength, such as `5.0` for a percentage change;
64    /// the largest magnitude among the shown tiles when `None`.
65    pub saturation: Option<f64>,
66}
67
68impl MarketHeatmapMetric {
69    /// The available metric `id` named `label`, in `unit`.
70    pub fn new(id: impl Into<String>, label: impl Into<String>, unit: impl Into<String>) -> Self {
71        Self {
72            id: id.into(),
73            label: label.into(),
74            unit: unit.into(),
75            available: true,
76            saturation: None,
77        }
78    }
79}
80
81/// Host-fed market heatmap. Update the fields in place and request an egui repaint when the host
82/// data changes. The same panel recomputes its geometry from the current values on each frame.
83///
84/// The user picks the universe, the metric that sizes the tiles and the metric that colours them.
85/// Without a choice the first universe is shown, sized by the first available metric and coloured
86/// by the second. Store [`selection`](Self::selection) with the settings and assign it back to
87/// restore the choices; one the host does not offer at the moment stays chosen and shows as
88/// unavailable.
89#[derive(Default)]
90pub struct MarketHeatmapPanel {
91    /// The markets to choose from.
92    pub universes: Vec<MarketHeatmapUniverse>,
93    /// The metrics to size and colour by.
94    pub metrics: Vec<MarketHeatmapMetric>,
95    /// The user's choices.
96    pub selection: MarketHeatmapSelection,
97}
98
99impl MarketHeatmapPanel {
100    /// The metric that sizes the tiles and the one that colours them, or `None` for a chosen one
101    /// the host does not offer or has no values for.
102    fn chosen(&self) -> (Option<&MarketHeatmapMetric>, Option<&MarketHeatmapMetric>) {
103        let mut available = self.metrics.iter().filter(|metric| metric.available);
104        let first = available.next();
105        let second = available.next().or(first);
106        let pick = |id: &Option<String>, default| match id {
107            Some(id) => self
108                .metrics
109                .iter()
110                .find(|metric| &metric.id == id && metric.available),
111            None => default,
112        };
113        (
114            pick(&self.selection.size, first),
115            pick(&self.selection.colour, second),
116        )
117    }
118
119    /// Render the chosen universe, returning the symbol the host should open when selected.
120    /// Tile areas follow the size metric. Colours diverge from the theme's panel background
121    /// towards its down/up colours by the colour metric, with a legend of its scale. Values are
122    /// shown verbatim on hover.
123    pub fn show(&mut self, ui: &mut Ui) -> Option<String> {
124        PanelHeader::new("Market heatmap").show_plain(ui);
125        let (size, colour) = self.chosen();
126        let (size, colour) = (size.cloned(), colour.cloned());
127        let universe = match &self.selection.universe {
128            Some(id) => self.universes.iter().find(|universe| &universe.id == id),
129            None => self.universes.first(),
130        };
131        let mut selection = self.selection.clone();
132        ui.horizontal_wrapped(|ui| {
133            if !self.universes.is_empty() {
134                let shown =
135                    universe.map_or("Unavailable market", |universe| universe.name.as_str());
136                dropdown(ui, "heatmap_universe", shown, |ui| {
137                    for entry in &self.universes {
138                        ui.selectable_value(
139                            &mut selection.universe,
140                            Some(entry.id.clone()),
141                            l10n::text(ui.ctx(), &entry.name),
142                        );
143                    }
144                });
145                ui.end_row();
146            }
147            for (id, prefix, target, chosen) in [
148                ("heatmap_size", "Size", &mut selection.size, &size),
149                ("heatmap_colour", "Colour", &mut selection.colour, &colour),
150            ] {
151                if self.metrics.is_empty() {
152                    continue;
153                }
154                let name = chosen.as_ref().map_or("Unavailable", |m| m.label.as_str());
155                let text = format!("{}: {name}", l10n::text(ui.ctx(), prefix));
156                dropdown(ui, id, &text, |ui| {
157                    for metric in &self.metrics {
158                        let label = if metric.available {
159                            l10n::text(ui.ctx(), &metric.label).to_string()
160                        } else {
161                            format!(
162                                "{} ({})",
163                                l10n::text(ui.ctx(), &metric.label),
164                                l10n::text(ui.ctx(), "unavailable")
165                            )
166                        };
167                        ui.add_enabled_ui(metric.available, |ui| {
168                            ui.selectable_value(target, Some(metric.id.clone()), label)
169                        });
170                    }
171                });
172            }
173        });
174        self.selection = selection;
175        let mut rect = ui.available_rect_before_wrap();
176        rect.set_bottom(rect.bottom() - LEGEND_HEIGHT);
177        let (Some(universe), Some(size)) = (universe, &size) else {
178            EmptyState::new(if universe.is_none() && !self.universes.is_empty() {
179                "This market is unavailable"
180            } else if size.is_none() && !self.metrics.is_empty() {
181                "The size metric is unavailable"
182            } else {
183                "No market heatmap data"
184            })
185            .show(ui);
186            return None;
187        };
188        let groups = layout(&universe.entries, &size.id, rect);
189        if groups.is_empty() {
190            EmptyState::new("No market heatmap data").show(ui);
191            return None;
192        }
193        ui.allocate_exact_size(rect.size(), Sense::hover());
194        let theme = ui.active_theme();
195        let scale = colour.as_ref().and_then(|metric| {
196            metric.saturation.filter(|s| *s > 0.0).or_else(|| {
197                let largest = universe
198                    .entries
199                    .iter()
200                    .filter_map(|entry| entry.values.get(&metric.id))
201                    .filter(|v| v.is_finite())
202                    .fold(0.0_f64, |a, v| a.max(v.abs()));
203                (largest > 0.0).then_some(largest)
204            })
205        });
206        let format = |metric: &MarketHeatmapMetric, value: Option<&f64>, signed: bool| {
207            let Some(value) = value.filter(|v| v.is_finite()) else {
208                return "No data".to_owned();
209            };
210            let number = if signed {
211                format!("{value:+.2}")
212            } else {
213                format!("{value}")
214            };
215            match metric.unit.as_str() {
216                "" => number,
217                "%" => format!("{number}%"),
218                unit => format!("{number} {unit}"),
219            }
220        };
221        let mut selected = None;
222        for group in groups {
223            for (index, tile) in group.tiles {
224                let entry = &universe.entries[index];
225                let tile = tile.shrink(1.0);
226                if !tile.is_positive() {
227                    continue;
228                }
229                let response = ui.interact(
230                    tile,
231                    ui.scope_id()
232                        .with(("market_heatmap", &entry.symbol, &entry.group)),
233                    Sense::click(),
234                );
235                let translated_label = l10n::text(ui.ctx(), &entry.symbol);
236                response.widget_info(|| {
237                    egui::WidgetInfo::labeled(
238                        egui::Role::Button,
239                        ui.is_enabled(),
240                        &translated_label,
241                    )
242                });
243                let base = theme.ui.panel_bg_secondary;
244                let shade = colour
245                    .as_ref()
246                    .and_then(|metric| entry.values.get(&metric.id))
247                    .filter(|v| v.is_finite())
248                    .zip(scale);
249                let direction = if shade.is_none_or(|(value, _)| *value >= 0.0) {
250                    theme.chart.candle_up
251                } else {
252                    theme.chart.candle_down
253                };
254                let amount =
255                    shade.map_or(0.0, |(value, scale)| (value.abs() / scale).min(1.0) as f32);
256                let color: Color32 = (egui::Rgba::from(base) * (1.0 - amount)
257                    + egui::Rgba::from(direction) * amount)
258                    .into();
259                ui.painter().rect_filled(tile, 0.0, color);
260                let linear = egui::Rgba::from(color);
261                let luminance = 0.2126 * linear.r() + 0.7152 * linear.g() + 0.0722 * linear.b();
262                let text = if luminance > 0.179 {
263                    Color32::BLACK
264                } else {
265                    Color32::WHITE
266                };
267                let painter = ui.painter().with_clip_rect(tile);
268                let shown = colour.as_ref().map_or_else(String::new, |metric| {
269                    format(metric, entry.values.get(&metric.id), true)
270                });
271                if tile.width() >= 35.0 && tile.height() >= 32.0 {
272                    painter.text(
273                        tile.center() - Vec2::new(0.0, 8.0),
274                        Align2::CENTER_CENTER,
275                        l10n::text(ui.ctx(), &entry.symbol),
276                        FontId::proportional(14.0),
277                        text,
278                    );
279                    painter.text(
280                        tile.center() + Vec2::new(0.0, 10.0),
281                        Align2::CENTER_CENTER,
282                        l10n::text(ui.ctx(), &shown),
283                        FontId::proportional(11.0),
284                        text,
285                    );
286                }
287                if response.hovered() || response.has_focus() {
288                    painter.rect_stroke(
289                        tile,
290                        0.0,
291                        Stroke::new(2.0, theme.ui.text),
292                        egui::StrokeKind::Inside,
293                    );
294                }
295                if response.clicked() {
296                    selected = Some(entry.symbol.clone());
297                }
298                let colour_line = colour.as_ref().map_or_else(String::new, |metric| {
299                    format!("\n{}: {shown}", l10n::text(ui.ctx(), &metric.label))
300                });
301                response.on_hover_text(format!(
302                    "{}\n{}\n{}: {}{colour_line}",
303                    entry.symbol,
304                    entry.group,
305                    l10n::text(ui.ctx(), &size.label),
306                    format(size, entry.values.get(&size.id), false),
307                ));
308            }
309            ui.painter().rect_stroke(
310                group.rect,
311                0.0,
312                Stroke::new(2.0, theme.ui.border),
313                egui::StrokeKind::Inside,
314            );
315            let painter = ui.painter().with_clip_rect(group.rect);
316            let label = painter.layout_no_wrap(
317                l10n::text(ui.ctx(), group.name),
318                FontId::proportional(11.0),
319                theme.ui.text,
320            );
321            let at = group.rect.left_top() + Vec2::new(4.0, 3.0);
322            painter.rect_filled(
323                Rect::from_min_size(at, label.size()).expand(2.0),
324                0.0,
325                theme.ui.panel_bg,
326            );
327            painter.galley(at, label, theme.ui.text);
328        }
329        let legend = Rect::from_min_size(
330            Pos2::new(rect.left(), rect.bottom() + 4.0),
331            Vec2::new(rect.width(), LEGEND_HEIGHT - 4.0),
332        );
333        ui.allocate_exact_size(Vec2::new(rect.width(), LEGEND_HEIGHT), Sense::hover());
334        legend_bar(ui, legend, size, colour.as_ref(), scale, &format);
335        selected
336    }
337}
338
339/// Height reserved below the tiles for the legend.
340const LEGEND_HEIGHT: f32 = 44.0;
341
342/// The size metric in words and the colour scale from the falling colour through the background
343/// to the rising colour, labelled with the values at its ends and middle.
344fn legend_bar(
345    ui: &Ui,
346    rect: Rect,
347    size: &MarketHeatmapMetric,
348    colour: Option<&MarketHeatmapMetric>,
349    scale: Option<f64>,
350    format: &dyn Fn(&MarketHeatmapMetric, Option<&f64>, bool) -> String,
351) {
352    let theme = ui.active_theme();
353    let painter = ui.painter();
354    let unit = |metric: &MarketHeatmapMetric| {
355        if metric.unit.is_empty() {
356            l10n::text(ui.ctx(), &metric.label).to_string()
357        } else {
358            format!("{} ({})", l10n::text(ui.ctx(), &metric.label), metric.unit)
359        }
360    };
361    painter.text(
362        rect.left_top(),
363        Align2::LEFT_TOP,
364        format!("{}: {}", l10n::text(ui.ctx(), "Size"), unit(size)),
365        FontId::proportional(11.0),
366        theme.ui.text_secondary,
367    );
368    let (Some(colour), Some(scale)) = (colour, scale) else {
369        painter.text(
370            rect.left_top() + Vec2::new(0.0, 14.0),
371            Align2::LEFT_TOP,
372            l10n::text(ui.ctx(), "Colour data unavailable"),
373            FontId::proportional(11.0),
374            theme.ui.text_secondary,
375        );
376        return;
377    };
378    let bar = Rect::from_min_size(
379        Pos2::new(rect.left() + rect.width() * 0.45, rect.top() + 18.0),
380        Vec2::new(rect.width() * 0.5, 8.0),
381    );
382    const STEPS: usize = 24;
383    for step in 0..STEPS {
384        let position = (step as f32 + 0.5) / STEPS as f32 * 2.0 - 1.0;
385        let direction = if position < 0.0 {
386            theme.chart.candle_down
387        } else {
388            theme.chart.candle_up
389        };
390        let color: Color32 = (egui::Rgba::from(theme.ui.panel_bg_secondary)
391            * (1.0 - position.abs())
392            + egui::Rgba::from(direction) * position.abs())
393        .into();
394        let x = bar.left() + bar.width() * step as f32 / STEPS as f32;
395        painter.rect_filled(
396            Rect::from_min_max(
397                Pos2::new(x, bar.top()),
398                Pos2::new(x + bar.width() / STEPS as f32 + 0.5, bar.bottom()),
399            ),
400            0.0,
401            color,
402        );
403    }
404    for (value, at, align) in [
405        (-scale, bar.left_bottom(), Align2::LEFT_TOP),
406        (0.0, bar.center_bottom(), Align2::CENTER_TOP),
407        (scale, bar.right_bottom(), Align2::RIGHT_TOP),
408    ] {
409        painter.text(
410            at + Vec2::new(0.0, 1.0),
411            align,
412            format(colour, Some(&value), value != 0.0),
413            FontId::proportional(10.0),
414            theme.ui.text_secondary,
415        );
416    }
417    painter.text(
418        Pos2::new(rect.left(), rect.top() + 14.0),
419        Align2::LEFT_TOP,
420        format!("{}: {}", l10n::text(ui.ctx(), "Colour"), unit(colour)),
421        FontId::proportional(11.0),
422        theme.ui.text_secondary,
423    );
424}
425
426struct Group<'a> {
427    name: &'a str,
428    rect: Rect,
429    tiles: Vec<(usize, Rect)>,
430}
431
432fn layout<'a>(entries: &'a [MarketHeatmapEntry], size: &str, rect: Rect) -> Vec<Group<'a>> {
433    let value = |entry: &MarketHeatmapEntry| entry.values.get(size).copied().unwrap_or(f64::NAN);
434    let maximum = entries
435        .iter()
436        .map(value)
437        .filter(|value| value.is_finite())
438        .fold(0.0_f64, f64::max);
439    if maximum <= 0.0 || !rect.is_positive() {
440        return vec![];
441    }
442    let mut groups: BTreeMap<&str, Vec<(usize, f64)>> = BTreeMap::new();
443    for (index, entry) in entries.iter().enumerate() {
444        let weight = value(entry) / maximum;
445        if value(entry).is_finite() && weight > 0.0 {
446            groups
447                .entry(&entry.group)
448                .or_default()
449                .push((index, weight));
450        }
451    }
452    let mut groups: Vec<_> = groups.into_iter().collect();
453    for (_, items) in &mut groups {
454        items.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
455    }
456    let mut weights: Vec<_> = groups
457        .iter()
458        .enumerate()
459        .map(|(index, (_, items))| (index, items.iter().map(|item| item.1).sum::<f64>()))
460        .collect();
461    weights.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
462    partition(rect, &weights)
463        .into_iter()
464        .map(|(index, rect)| Group {
465            name: groups[index].0,
466            rect,
467            tiles: partition(rect, &groups[index].1),
468        })
469        .collect()
470}
471
472/// Binary treemap: each split allocates exactly its share of the remaining area along the
473/// longer edge. Splitting near half the weight avoids unnecessary thin strips.
474fn partition(rect: Rect, items: &[(usize, f64)]) -> Vec<(usize, Rect)> {
475    let mut pending = vec![(rect, items)];
476    let mut tiles = Vec::new();
477    while let Some((rect, items)) = pending.pop() {
478        if items.is_empty() {
479            continue;
480        }
481        if items.len() == 1 {
482            tiles.push((items[0].0, rect));
483            continue;
484        }
485        let total: f64 = items.iter().map(|item| item.1).sum::<f64>();
486        let mut sum = 0.0;
487        let mut split = (1, items[0].1);
488        for (index, item) in items.iter().take(items.len() - 1).enumerate() {
489            sum += item.1;
490            if (sum - total / 2.0).abs() < (split.1 - total / 2.0).abs() {
491                split = (index + 1, sum);
492            }
493        }
494        let fraction = (split.1 / total) as f32;
495        let (first, second) = if rect.width() >= rect.height() {
496            let x = rect.left() + rect.width() * fraction;
497            (
498                Rect::from_min_max(rect.min, Pos2::new(x, rect.bottom())),
499                Rect::from_min_max(Pos2::new(x, rect.top()), rect.max),
500            )
501        } else {
502            let y = rect.top() + rect.height() * fraction;
503            (
504                Rect::from_min_max(rect.min, Pos2::new(rect.right(), y)),
505                Rect::from_min_max(Pos2::new(rect.left(), y), rect.max),
506            )
507        };
508        pending.push((second, &items[split.0..]));
509        pending.push((first, &items[..split.0]));
510    }
511    tiles
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    fn entry(symbol: &str, group: &str, size: f64) -> MarketHeatmapEntry {
519        MarketHeatmapEntry {
520            symbol: symbol.into(),
521            group: group.into(),
522            values: BTreeMap::from([("cap".into(), size), ("change".into(), 1.0)]),
523        }
524    }
525
526    #[test]
527    fn grouped_tile_areas_follow_host_weights_including_updates() {
528        for factor in [1.0, 1e307] {
529            let mut entries: Vec<_> = [
530                ("A", "Tech", 6.0),
531                ("B", "Tech", 3.0),
532                ("C", "Other", 1.0),
533                ("bad", "Other", f64::NAN),
534                ("zero", "Other", 0.0),
535                ("negative", "Other", -1.0),
536            ]
537            .into_iter()
538            .map(|(symbol, group, value)| entry(symbol, group, value * factor))
539            .collect();
540            entries.push(MarketHeatmapEntry {
541                symbol: "missing".into(),
542                group: "Other".into(),
543                values: BTreeMap::new(),
544            });
545            for updated in [false, true] {
546                if updated {
547                    entries[0].values.insert("cap".into(), factor);
548                }
549                let rect = Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0));
550                let groups = layout(&entries, "cap", rect);
551                let tiles: Vec<_> = groups.iter().flat_map(|group| group.tiles.iter()).collect();
552                // 10,000 pixels / (6+3+1): 6,000, 3,000, 1,000. After A=1:
553                // 10,000 / (1+3+1): 2,000, 6,000, 2,000, across both groups.
554                let expected = if updated {
555                    [2000.0, 6000.0, 2000.0]
556                } else {
557                    [6000.0, 3000.0, 1000.0]
558                };
559                assert_eq!(tiles.len(), 3);
560                for (index, tile) in &tiles {
561                    assert!(
562                        (tile.area() - expected[*index]).abs() < 0.01,
563                        "{index}: {tile:?}"
564                    );
565                    assert!(rect.contains_rect(*tile));
566                }
567                for (i, (_, a)) in tiles.iter().enumerate() {
568                    for (_, b) in &tiles[i + 1..] {
569                        assert!(a.intersect(*b).area() <= 0.0);
570                    }
571                }
572            }
573        }
574    }
575
576    /// Guards the choices: size and colour are picked independently from the host's metrics, the
577    /// first two available ones are the defaults, and a chosen metric or universe the host does
578    /// not offer or has no data for stays chosen, through a frame, rather than falling back.
579    #[test]
580    fn size_and_colour_are_independent_and_unavailable_choices_stay_chosen() {
581        let mut volume = MarketHeatmapMetric::new("volume", "Volume", "");
582        volume.available = false;
583        let mut panel = MarketHeatmapPanel {
584            universes: vec![
585                MarketHeatmapUniverse::new("us", "US stocks", vec![entry("A", "Tech", 2.0)]),
586                MarketHeatmapUniverse::new("coins", "Coins", vec![]),
587            ],
588            metrics: vec![
589                volume,
590                MarketHeatmapMetric::new("cap", "Market cap", "USD"),
591                MarketHeatmapMetric::new("change", "Change", "%"),
592            ],
593            selection: MarketHeatmapSelection::default(),
594        };
595        let ids = |panel: &MarketHeatmapPanel| {
596            let (size, colour) = panel.chosen();
597            (size.map(|m| m.id.clone()), colour.map(|m| m.id.clone()))
598        };
599        assert_eq!(ids(&panel), (Some("cap".into()), Some("change".into())));
600        panel.selection.colour = Some("cap".into());
601        assert_eq!(ids(&panel), (Some("cap".into()), Some("cap".into())));
602        panel.selection.size = Some("volume".into());
603        assert_eq!(ids(&panel), (None, Some("cap".into())));
604        panel.selection.size = Some("gone".into());
605        panel.selection.universe = Some("delisted".into());
606        assert_eq!(ids(&panel), (None, Some("cap".into())));
607
608        let before = panel.selection.clone();
609        let mut h = egui_kittest::Harness::builder()
610            .with_size(Vec2::new(320.0, 500.0))
611            .with_context_setup(|ctx| {
612                crate::theme::apply_to_egui(ctx, &crate::theme::Theme::dark());
613                egui_extras::install_image_loaders(ctx);
614            })
615            .build_ui_state(
616                |ui, panel: &mut MarketHeatmapPanel| _ = panel.show(ui),
617                panel,
618            );
619        h.run();
620        assert_eq!(h.state().selection, before);
621    }
622}