1use 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#[derive(Clone, Debug, PartialEq)]
13pub struct MarketHeatmapEntry {
14 pub symbol: String,
16 pub group: String,
18 pub values: BTreeMap<String, f64>,
21}
22
23#[derive(Clone, Debug, PartialEq)]
25#[non_exhaustive]
26pub struct MarketHeatmapUniverse {
27 pub id: String,
29 pub name: String,
31 pub entries: Vec<MarketHeatmapEntry>,
33}
34
35impl MarketHeatmapUniverse {
36 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#[derive(Clone, Debug, PartialEq)]
52#[non_exhaustive]
53pub struct MarketHeatmapMetric {
54 pub id: String,
56 pub label: String,
58 pub unit: String,
60 pub available: bool,
63 pub saturation: Option<f64>,
66}
67
68impl MarketHeatmapMetric {
69 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#[derive(Default)]
90pub struct MarketHeatmapPanel {
91 pub universes: Vec<MarketHeatmapUniverse>,
93 pub metrics: Vec<MarketHeatmapMetric>,
95 pub selection: MarketHeatmapSelection,
97}
98
99impl MarketHeatmapPanel {
100 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 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
339const LEGEND_HEIGHT: f32 = 44.0;
341
342fn 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
472fn 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 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 #[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}