use crate::frontend::tui_app::TuiApp;
use crate::t;
use itertools::Itertools;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
use ratatui::prelude::Line;
use ratatui::style::{Color, Style};
use ratatui::symbols::Marker;
use ratatui::text::Span;
use ratatui::widgets::canvas::{Canvas, Circle, Context, Map, MapResolution, Rectangle};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use ratatui::Frame;
use std::collections::HashMap;
use trippy_core::Hop;
pub fn render(f: &mut Frame<'_>, app: &TuiApp, rect: Rect) {
let entries = build_map_entries(app);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(MAP_LAYOUT)
.split(rect);
let info_rect = chunks[1].inner(Margin {
vertical: 0,
horizontal: 16,
});
render_map_canvas(f, app, rect, &entries);
render_map_info_panel(f, app, info_rect, &entries);
}
fn render_map_canvas(f: &mut Frame<'_>, app: &TuiApp, rect: Rect, entries: &[MapEntry]) {
let theme = app.tui_config.theme;
let map = Canvas::default()
.background_color(app.tui_config.theme.bg)
.block(
Block::default()
.title(Line::raw(t!("title_map")))
.borders(Borders::ALL)
.border_style(Style::default().fg(app.tui_config.theme.border))
.style(
Style::default()
.bg(app.tui_config.theme.bg)
.fg(app.tui_config.theme.text),
),
)
.paint(|ctx| {
render_map_canvas_world(ctx, theme.map_world);
ctx.layer();
for entry in entries {
let any_show = entry
.hops
.iter()
.any(|hop| Some(*hop) > app.tui_config.privacy_max_ttl);
if any_show {
render_map_canvas_pin(ctx, entry);
render_map_canvas_radius(ctx, entry, theme.map_radius);
render_map_canvas_selected(
ctx,
entry,
app.selected_hop_or_target(),
theme.map_selected,
);
}
}
})
.marker(Marker::Braille)
.x_bounds([-180.0, 180.0])
.y_bounds([-90.0, 90.0]);
f.render_widget(Clear, rect);
f.render_widget(map, rect);
}
fn render_map_canvas_world(ctx: &mut Context<'_>, color: Color) {
ctx.draw(&Map {
color,
resolution: MapResolution::High,
});
}
fn render_map_canvas_pin(ctx: &mut Context<'_>, entry: &MapEntry) {
let MapEntry {
latitude,
longitude,
..
} = entry;
ctx.print(*longitude, *latitude, Span::styled("📍", Style::default()));
}
fn render_map_canvas_radius(ctx: &mut Context<'_>, entry: &MapEntry, color: Color) {
let MapEntry {
latitude,
longitude,
radius,
..
} = entry;
let radius_degrees = f64::from(*radius) / 110_f64;
if radius_degrees > 2_f64 {
let circle_widget = Circle {
x: *longitude,
y: *latitude,
radius: radius_degrees,
color,
};
ctx.draw(&circle_widget);
}
}
fn render_map_canvas_selected(
ctx: &mut Context<'_>,
entry: &MapEntry,
selected_hop: &Hop,
color: Color,
) {
let MapEntry {
latitude,
longitude,
hops,
..
} = entry;
if hops.contains(&selected_hop.ttl()) {
ctx.draw(&Rectangle {
x: longitude - 5.0_f64,
y: latitude - 5.0_f64,
width: 10.0_f64,
height: 10.0_f64,
color,
});
}
}
fn render_map_info_panel(f: &mut Frame<'_>, app: &TuiApp, rect: Rect, entries: &[MapEntry]) {
let theme = app.tui_config.theme;
let selected_hop = app.selected_hop_or_target();
let locations = entries
.iter()
.filter_map(|entry| {
if entry.hops.contains(&selected_hop.ttl()) {
Some(format!("{} [{}]", entry.long_name, entry.location))
} else {
None
}
})
.collect::<Vec<_>>();
let info = if app.tui_config.privacy_max_ttl >= Some(selected_hop.ttl()) {
format!("**{}**", t!("hidden"))
} else {
match locations.as_slice() {
_ if app.tui_config.geoip_mmdb_file.is_none() => t!("geoip_not_enabled").to_string(),
[] if selected_hop.addr_count() > 0 => format!(
"{} {} ({})",
t!("geoip_no_data_for_hop"),
selected_hop.ttl(),
selected_hop.addrs().join(", ")
),
[] => format!("{} {}", t!("geoip_no_data_for_hop"), selected_hop.ttl()),
[loc] => loc.to_string(),
_ => format!(
"{} {}",
t!("geoip_multiple_data_for_hop"),
selected_hop.ttl()
),
}
};
let info_panel = Paragraph::new(info)
.block(
Block::default()
.title(format!("{} {}", t!("hop"), selected_hop.ttl()))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.map_info_panel_border))
.style(
Style::default()
.bg(theme.map_info_panel_bg)
.fg(theme.map_info_panel_text),
),
)
.alignment(Alignment::Left);
f.render_widget(Clear, rect);
f.render_widget(info_panel, rect);
}
struct MapEntry {
long_name: String,
location: String,
latitude: f64,
longitude: f64,
radius: u16,
hops: Vec<u8>,
}
fn build_map_entries(app: &TuiApp) -> Vec<MapEntry> {
let mut geo_map: HashMap<String, MapEntry> = HashMap::new();
for hop in app.tracer_data().hops_for_flow(app.selected_flow) {
for addr in hop.addrs() {
if let Some(geo) = app.geoip_lookup.lookup(*addr).unwrap_or_default() {
if let Some((latitude, longitude, radius)) = geo.coordinates() {
let entry = geo_map.entry(geo.long_name()).or_insert_with(|| MapEntry {
long_name: geo.long_name(),
location: format!("{latitude}, {longitude} ~{radius}{}", t!("kilometer")),
latitude,
longitude,
radius,
hops: vec![],
});
entry.hops.push(hop.ttl());
}
};
}
}
geo_map.into_values().collect_vec()
}
const MAP_LAYOUT: [Constraint; 3] = [
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
];