Improve map interaction and connection feedback
This commit is contained in:
parent
eace75675a
commit
b317ddbb7a
3 changed files with 341 additions and 107 deletions
75
src/app.rs
75
src/app.rs
|
|
@ -13,6 +13,20 @@ use crate::ui::config_dialog::{ConfigDialog, DialogResult};
|
||||||
use crate::ui::country_selector::CountrySelector;
|
use crate::ui::country_selector::CountrySelector;
|
||||||
use crate::world_map::WorldMap;
|
use crate::world_map::WorldMap;
|
||||||
|
|
||||||
|
enum ActiveCommand {
|
||||||
|
Connecting(String),
|
||||||
|
Disconnecting,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveCommand {
|
||||||
|
fn label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Connecting(country) => format!("Connecting to {country}…"),
|
||||||
|
Self::Disconnecting => "Disconnecting…".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
config: Config,
|
config: Config,
|
||||||
countries: Vec<Country>,
|
countries: Vec<Country>,
|
||||||
|
|
@ -33,6 +47,7 @@ pub struct App {
|
||||||
/// When the pending connect was queued — dialog is shown only after the
|
/// When the pending connect was queued — dialog is shown only after the
|
||||||
/// double-click window (300 ms, matching egui's double_click_speed) has passed.
|
/// double-click window (300 ms, matching egui's double_click_speed) has passed.
|
||||||
pending_connect_at: Option<Instant>,
|
pending_connect_at: Option<Instant>,
|
||||||
|
active_command: Option<ActiveCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -60,10 +75,21 @@ impl App {
|
||||||
notification: None,
|
notification: None,
|
||||||
pending_connect: None,
|
pending_connect: None,
|
||||||
pending_connect_at: None,
|
pending_connect_at: None,
|
||||||
|
active_command: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_connect(&self, nordvpn_key: String) {
|
fn spawn_connect(&mut self, nordvpn_key: String) {
|
||||||
|
let country = self
|
||||||
|
.countries
|
||||||
|
.iter()
|
||||||
|
.find(|country| country.nordvpn_key == nordvpn_key)
|
||||||
|
.map(|country| country.name.clone())
|
||||||
|
.unwrap_or_else(|| nordvpn_key.replace('_', " "));
|
||||||
|
self.active_command = Some(ActiveCommand::Connecting(country));
|
||||||
|
self.notification = None;
|
||||||
|
self.pending_connect = None;
|
||||||
|
self.pending_connect_at = None;
|
||||||
let app = self.config.external_app.clone();
|
let app = self.config.external_app.clone();
|
||||||
let tx = self.cmd_tx.clone();
|
let tx = self.cmd_tx.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|
@ -74,18 +100,25 @@ impl App {
|
||||||
/// If already connected and not forced, queue a confirmation prompt; otherwise connect immediately.
|
/// If already connected and not forced, queue a confirmation prompt; otherwise connect immediately.
|
||||||
/// `force` is true on double-click — skips the confirmation dialog entirely.
|
/// `force` is true on double-click — skips the confirmation dialog entirely.
|
||||||
fn request_connect(&mut self, key: String, status: &StatusInfo, force: bool) {
|
fn request_connect(&mut self, key: String, status: &StatusInfo, force: bool) {
|
||||||
|
if self.active_command.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !force && status.is_connected() {
|
if !force && status.is_connected() {
|
||||||
self.pending_connect = Some(key);
|
self.pending_connect = Some(key);
|
||||||
self.pending_connect_at = Some(Instant::now());
|
self.pending_connect_at = Some(Instant::now());
|
||||||
} else {
|
} else {
|
||||||
self.notification = None;
|
|
||||||
self.pending_connect = None;
|
|
||||||
self.pending_connect_at = None;
|
|
||||||
self.spawn_connect(key);
|
self.spawn_connect(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_disconnect(&self) {
|
fn spawn_disconnect(&mut self) {
|
||||||
|
if self.active_command.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active_command = Some(ActiveCommand::Disconnecting);
|
||||||
|
self.notification = None;
|
||||||
|
self.pending_connect = None;
|
||||||
|
self.pending_connect_at = None;
|
||||||
let app = self.config.external_app.clone();
|
let app = self.config.external_app.clone();
|
||||||
let tx = self.cmd_tx.clone();
|
let tx = self.cmd_tx.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|
@ -105,6 +138,7 @@ impl eframe::App for App {
|
||||||
|
|
||||||
// Drain completed command results.
|
// Drain completed command results.
|
||||||
while let Ok(result) = self.cmd_rx.try_recv() {
|
while let Ok(result) = self.cmd_rx.try_recv() {
|
||||||
|
self.active_command = None;
|
||||||
self.notification = Some(match result {
|
self.notification = Some(match result {
|
||||||
CmdResult::Success { output, .. } => output.trim().to_string(),
|
CmdResult::Success { output, .. } => output.trim().to_string(),
|
||||||
CmdResult::Failure { cmd, message } => {
|
CmdResult::Failure { cmd, message } => {
|
||||||
|
|
@ -137,6 +171,11 @@ impl eframe::App for App {
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.weak(uptime);
|
ui.weak(uptime);
|
||||||
}
|
}
|
||||||
|
if let Some(command) = &self.active_command {
|
||||||
|
ui.separator();
|
||||||
|
ui.add(egui::Spinner::new().size(14.0));
|
||||||
|
ui.strong(command.label());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if let Some(note) = &self.notification {
|
if let Some(note) = &self.notification {
|
||||||
ui.add_space(2.0);
|
ui.add_space(2.0);
|
||||||
|
|
@ -151,12 +190,21 @@ impl eframe::App for App {
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("Disconnect").clicked() {
|
let can_disconnect = self.active_command.is_none() && status.is_connected();
|
||||||
self.notification = None;
|
if ui
|
||||||
|
.add_enabled(can_disconnect, egui::Button::new("Disconnect"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
self.spawn_disconnect();
|
self.spawn_disconnect();
|
||||||
}
|
}
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
if ui.button("Configure").clicked() {
|
if ui
|
||||||
|
.add_enabled(
|
||||||
|
self.active_command.is_none(),
|
||||||
|
egui::Button::new("Configure"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
self.config_dialog = Some(ConfigDialog::new(&self.config));
|
self.config_dialog = Some(ConfigDialog::new(&self.config));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -195,6 +243,7 @@ impl eframe::App for App {
|
||||||
&self.config.favorites,
|
&self.config.favorites,
|
||||||
status.country.as_deref(),
|
status.country.as_deref(),
|
||||||
&self.flag_textures,
|
&self.flag_textures,
|
||||||
|
self.active_command.is_none(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
|
|
@ -205,7 +254,12 @@ impl eframe::App for App {
|
||||||
|
|
||||||
// ── Central panel — world map ────────────────────────────────────────
|
// ── Central panel — world map ────────────────────────────────────────
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
if let Some((key, force)) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
|
if let Some((key, force)) = self.world_map.show(
|
||||||
|
ui,
|
||||||
|
&self.countries,
|
||||||
|
status.country.as_deref(),
|
||||||
|
self.active_command.is_none(),
|
||||||
|
) {
|
||||||
self.request_connect(key, &status, force);
|
self.request_connect(key, &status, force);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -252,10 +306,7 @@ impl eframe::App for App {
|
||||||
});
|
});
|
||||||
|
|
||||||
if confirmed {
|
if confirmed {
|
||||||
self.notification = None;
|
|
||||||
self.spawn_connect(key);
|
self.spawn_connect(key);
|
||||||
self.pending_connect = None;
|
|
||||||
self.pending_connect_at = None;
|
|
||||||
} else if cancelled {
|
} else if cancelled {
|
||||||
self.pending_connect = None;
|
self.pending_connect = None;
|
||||||
self.pending_connect_at = None;
|
self.pending_connect_at = None;
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ impl CountrySelector {
|
||||||
favorites: &[String],
|
favorites: &[String],
|
||||||
connected_country: Option<&str>,
|
connected_country: Option<&str>,
|
||||||
flags: &HashMap<String, egui::TextureHandle>,
|
flags: &HashMap<String, egui::TextureHandle>,
|
||||||
|
connect_enabled: bool,
|
||||||
) -> Option<(String, bool)> {
|
) -> Option<(String, bool)> {
|
||||||
// ── Toggle + search bar ─────────────────────────────────────────────
|
// ── Toggle + search bar ─────────────────────────────────────────────
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -69,8 +70,12 @@ impl CountrySelector {
|
||||||
|
|
||||||
// ── Virtual-scrolled content ────────────────────────────────────────
|
// ── Virtual-scrolled content ────────────────────────────────────────
|
||||||
match self.view_mode {
|
match self.view_mode {
|
||||||
ViewMode::List => show_list(ui, &favs, &rest, connected_country, flags),
|
ViewMode::List => {
|
||||||
ViewMode::Grid => show_grid(ui, &favs, &rest, connected_country, flags),
|
show_list(ui, &favs, &rest, connected_country, flags, connect_enabled)
|
||||||
|
}
|
||||||
|
ViewMode::Grid => {
|
||||||
|
show_grid(ui, &favs, &rest, connected_country, flags, connect_enabled)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +120,7 @@ fn show_list(
|
||||||
rest: &[&Country],
|
rest: &[&Country],
|
||||||
connected_country: Option<&str>,
|
connected_country: Option<&str>,
|
||||||
flags: &HashMap<String, egui::TextureHandle>,
|
flags: &HashMap<String, egui::TextureHandle>,
|
||||||
|
connect_enabled: bool,
|
||||||
) -> Option<(String, bool)> {
|
) -> Option<(String, bool)> {
|
||||||
let rows = build_list_rows(favs, rest);
|
let rows = build_list_rows(favs, rest);
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
|
|
@ -136,7 +142,8 @@ fn show_list(
|
||||||
ui.separator();
|
ui.separator();
|
||||||
}
|
}
|
||||||
ListRow::Country(c) => {
|
ListRow::Country(c) => {
|
||||||
let (single, double) = list_row(ui, c, connected_country, flags);
|
let (single, double) =
|
||||||
|
list_row(ui, c, connected_country, flags, connect_enabled);
|
||||||
if double {
|
if double {
|
||||||
clicked = Some((c.nordvpn_key.clone(), true));
|
clicked = Some((c.nordvpn_key.clone(), true));
|
||||||
} else if single {
|
} else if single {
|
||||||
|
|
@ -159,6 +166,7 @@ fn list_row(
|
||||||
country: &Country,
|
country: &Country,
|
||||||
connected_country: Option<&str>,
|
connected_country: Option<&str>,
|
||||||
flags: &HashMap<String, egui::TextureHandle>,
|
flags: &HashMap<String, egui::TextureHandle>,
|
||||||
|
connect_enabled: bool,
|
||||||
) -> (bool, bool) {
|
) -> (bool, bool) {
|
||||||
let is_connected = connected_country.is_some_and(|k| names_match(&country.name, k));
|
let is_connected = connected_country.is_some_and(|k| names_match(&country.name, k));
|
||||||
|
|
||||||
|
|
@ -176,7 +184,12 @@ fn list_row(
|
||||||
egui::RichText::new(&country.name)
|
egui::RichText::new(&country.name)
|
||||||
};
|
};
|
||||||
let remaining = ui.available_width();
|
let remaining = ui.available_width();
|
||||||
let resp = ui.add(egui::Button::new(text).frame(false).min_size(egui::vec2(remaining, 0.0)));
|
let resp = ui.add_enabled(
|
||||||
|
connect_enabled,
|
||||||
|
egui::Button::new(text)
|
||||||
|
.frame(false)
|
||||||
|
.min_size(egui::vec2(remaining, 0.0)),
|
||||||
|
);
|
||||||
(resp.clicked(), resp.double_clicked())
|
(resp.clicked(), resp.double_clicked())
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
|
|
@ -192,6 +205,7 @@ fn show_grid(
|
||||||
rest: &[&Country],
|
rest: &[&Country],
|
||||||
connected_country: Option<&str>,
|
connected_country: Option<&str>,
|
||||||
flags: &HashMap<String, egui::TextureHandle>,
|
flags: &HashMap<String, egui::TextureHandle>,
|
||||||
|
connect_enabled: bool,
|
||||||
) -> Option<(String, bool)> {
|
) -> Option<(String, bool)> {
|
||||||
// Compute columns from available width once, before the scroll area consumes it.
|
// Compute columns from available width once, before the scroll area consumes it.
|
||||||
let available_w = ui.available_width();
|
let available_w = ui.available_width();
|
||||||
|
|
@ -251,7 +265,8 @@ fn show_grid(
|
||||||
handle.id(),
|
handle.id(),
|
||||||
egui::vec2(GRID_DISPLAY_W, GRID_DISPLAY_H),
|
egui::vec2(GRID_DISPLAY_W, GRID_DISPLAY_H),
|
||||||
);
|
);
|
||||||
ui.add(
|
ui.add_enabled(
|
||||||
|
connect_enabled,
|
||||||
egui::Button::image(sized)
|
egui::Button::image(sized)
|
||||||
.selected(is_connected),
|
.selected(is_connected),
|
||||||
)
|
)
|
||||||
|
|
@ -261,7 +276,8 @@ fn show_grid(
|
||||||
} else {
|
} else {
|
||||||
egui::RichText::new(short_label(&c.name))
|
egui::RichText::new(short_label(&c.name))
|
||||||
};
|
};
|
||||||
ui.add(
|
ui.add_enabled(
|
||||||
|
connect_enabled,
|
||||||
egui::Button::new(text)
|
egui::Button::new(text)
|
||||||
.min_size(egui::vec2(GRID_DISPLAY_W, GRID_DISPLAY_H)),
|
.min_size(egui::vec2(GRID_DISPLAY_W, GRID_DISPLAY_H)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
337
src/world_map.rs
337
src/world_map.rs
|
|
@ -8,17 +8,21 @@ use crate::projection::{Equirectangular, MapProjection};
|
||||||
|
|
||||||
const COLOR_AVAILABLE: egui::Color32 = egui::Color32::from_rgb(56, 148, 136);
|
const COLOR_AVAILABLE: egui::Color32 = egui::Color32::from_rgb(56, 148, 136);
|
||||||
const COLOR_CONNECTED: egui::Color32 = egui::Color32::from_rgb(60, 210, 80);
|
const COLOR_CONNECTED: egui::Color32 = egui::Color32::from_rgb(60, 210, 80);
|
||||||
const COLOR_UNAVAILABLE: egui::Color32 = egui::Color32::from_rgb(50, 52, 58);
|
const COLOR_UNAVAILABLE: egui::Color32 = egui::Color32::from_rgb(72, 75, 83);
|
||||||
const COLOR_OCEAN: egui::Color32 = egui::Color32::from_rgb(22, 24, 32);
|
const COLOR_OCEAN: egui::Color32 = egui::Color32::from_rgb(22, 24, 32);
|
||||||
const COLOR_BORDER: egui::Color32 = egui::Color32::from_rgb(15, 17, 22);
|
const COLOR_BORDER: egui::Color32 = egui::Color32::from_rgb(15, 17, 22);
|
||||||
|
const MIN_ZOOM: f32 = 1.0;
|
||||||
|
const MAX_ZOOM: f32 = 8.0;
|
||||||
|
const ZOOM_STEP: f32 = 1.25;
|
||||||
|
const KEYBOARD_PAN: f32 = 48.0;
|
||||||
|
|
||||||
struct CountryShape {
|
struct CountryShape {
|
||||||
name: String,
|
name: String,
|
||||||
|
normalized_name: String,
|
||||||
/// Tessellated vertices in lon/lat space, for filled rendering.
|
/// Tessellated vertices in lon/lat space, for filled rendering.
|
||||||
vertices: Vec<[f64; 2]>,
|
vertices: Vec<[f64; 2]>,
|
||||||
indices: Vec<u32>,
|
indices: Vec<u32>,
|
||||||
/// Outer ring in lon/lat space, for hit-testing and border drawing.
|
rings: Vec<Vec<[f64; 2]>>,
|
||||||
outer_ring: Vec<[f64; 2]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WorldMap {
|
pub struct WorldMap {
|
||||||
|
|
@ -52,6 +56,7 @@ impl WorldMap {
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
nordvpn_countries: &[Country],
|
nordvpn_countries: &[Country],
|
||||||
connected_country: Option<&str>,
|
connected_country: Option<&str>,
|
||||||
|
connect_enabled: bool,
|
||||||
) -> Option<(String, bool)> {
|
) -> Option<(String, bool)> {
|
||||||
let available = ui.available_rect_before_wrap();
|
let available = ui.available_rect_before_wrap();
|
||||||
|
|
||||||
|
|
@ -60,58 +65,94 @@ impl WorldMap {
|
||||||
let (mw, mh) = if aw / ah > 2.0 { (ah * 2.0, ah) } else { (aw, aw / 2.0) };
|
let (mw, mh) = if aw / ah > 2.0 { (ah * 2.0, ah) } else { (aw, aw / 2.0) };
|
||||||
let map_rect = egui::Rect::from_center_size(available.center(), egui::vec2(mw, mh));
|
let map_rect = egui::Rect::from_center_size(available.center(), egui::vec2(mw, mh));
|
||||||
|
|
||||||
// Virtual map rect: the world map as scaled + panned within map_rect.
|
let response = ui.allocate_rect(available, egui::Sense::click_and_drag());
|
||||||
let virtual_rect = egui::Rect::from_min_size(
|
let controls = show_map_controls(ui, available, self.zoom);
|
||||||
map_rect.min + self.pan,
|
let map_hovered = response.hovered() && !controls.hovered;
|
||||||
map_rect.size() * self.zoom,
|
|
||||||
);
|
|
||||||
|
|
||||||
let response = ui.allocate_rect(map_rect, egui::Sense::click_and_drag());
|
if controls.zoom_in {
|
||||||
|
self.zoom_at(ZOOM_STEP, map_rect.center(), map_rect);
|
||||||
|
}
|
||||||
|
if controls.zoom_out {
|
||||||
|
self.zoom_at(1.0 / ZOOM_STEP, map_rect.center(), map_rect);
|
||||||
|
}
|
||||||
|
if controls.reset {
|
||||||
|
self.reset_view();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scroll to zoom ───────────────────────────────────────────────────
|
// ── Scroll to zoom ───────────────────────────────────────────────────
|
||||||
if response.hovered() {
|
if map_hovered {
|
||||||
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
|
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
|
||||||
if scroll != 0.0 {
|
if scroll != 0.0 {
|
||||||
let factor = (scroll * 0.005).exp();
|
let factor = (scroll * 0.005).exp();
|
||||||
let cursor = response.hover_pos().unwrap_or(map_rect.center());
|
let cursor = response.hover_pos().unwrap_or(map_rect.center());
|
||||||
let cursor_in_map = cursor - map_rect.min - self.pan;
|
self.zoom_at(factor, cursor, map_rect);
|
||||||
let old_zoom = self.zoom;
|
|
||||||
self.zoom = (self.zoom * factor).max(1.0);
|
|
||||||
self.pan = cursor - map_rect.min - cursor_in_map * (self.zoom / old_zoom);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag to pan ──────────────────────────────────────────────────────
|
// ── Drag to pan ──────────────────────────────────────────────────────
|
||||||
if response.dragged() {
|
if response.dragged() && !controls.hovered {
|
||||||
self.pan += response.drag_delta();
|
self.pan += response.drag_delta();
|
||||||
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grabbing);
|
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grabbing);
|
||||||
} else if response.hovered() && self.zoom > 1.0 {
|
} else if map_hovered && self.zoom > MIN_ZOOM {
|
||||||
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grab);
|
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.has_focus() {
|
||||||
|
let (zoom_in, zoom_out, reset, left, right, up, down) = ui.input_mut(|input| {
|
||||||
|
let modifiers = egui::Modifiers::NONE;
|
||||||
|
(
|
||||||
|
input.consume_key(modifiers, egui::Key::Plus)
|
||||||
|
|| input.consume_key(modifiers, egui::Key::Equals),
|
||||||
|
input.consume_key(modifiers, egui::Key::Minus),
|
||||||
|
input.consume_key(modifiers, egui::Key::Num0)
|
||||||
|
|| input.consume_key(modifiers, egui::Key::Home),
|
||||||
|
input.consume_key(modifiers, egui::Key::ArrowLeft),
|
||||||
|
input.consume_key(modifiers, egui::Key::ArrowRight),
|
||||||
|
input.consume_key(modifiers, egui::Key::ArrowUp),
|
||||||
|
input.consume_key(modifiers, egui::Key::ArrowDown),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if zoom_in {
|
||||||
|
self.zoom_at(ZOOM_STEP, map_rect.center(), map_rect);
|
||||||
|
}
|
||||||
|
if zoom_out {
|
||||||
|
self.zoom_at(1.0 / ZOOM_STEP, map_rect.center(), map_rect);
|
||||||
|
}
|
||||||
|
if reset {
|
||||||
|
self.reset_view();
|
||||||
|
}
|
||||||
|
self.pan.x += (left as i8 - right as i8) as f32 * KEYBOARD_PAN;
|
||||||
|
self.pan.y += (up as i8 - down as i8) as f32 * KEYBOARD_PAN;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clamp pan so the map always covers map_rect ──────────────────────
|
// ── Clamp pan so the map always covers map_rect ──────────────────────
|
||||||
self.pan.x = self.pan.x.clamp(-(self.zoom - 1.0) * map_rect.width(), 0.0);
|
self.pan.x = self.pan.x.clamp(-(self.zoom - 1.0) * map_rect.width(), 0.0);
|
||||||
self.pan.y = self.pan.y.clamp(-(self.zoom - 1.0) * map_rect.height(), 0.0);
|
self.pan.y = self.pan.y.clamp(-(self.zoom - 1.0) * map_rect.height(), 0.0);
|
||||||
|
let virtual_rect = egui::Rect::from_min_size(
|
||||||
|
map_rect.min + self.pan,
|
||||||
|
map_rect.size() * self.zoom,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Hover detection ──────────────────────────────────────────────────
|
// ── Hover detection ──────────────────────────────────────────────────
|
||||||
self.hovered = None;
|
self.hovered = None;
|
||||||
if let Some(cursor) = response.hover_pos() {
|
if map_hovered {
|
||||||
|
let cursor = response.hover_pos().unwrap_or(map_rect.center());
|
||||||
let (lon, lat) = self.projection.unproject(cursor, virtual_rect);
|
let (lon, lat) = self.projection.unproject(cursor, virtual_rect);
|
||||||
self.hovered = self
|
self.hovered = self
|
||||||
.shapes
|
.shapes
|
||||||
.iter()
|
.iter()
|
||||||
.position(|s| point_in_polygon([lon, lat], &s.outer_ring));
|
.position(|shape| point_in_shape([lon, lat], &shape.rings));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Click to connect ─────────────────────────────────────────────────
|
// ── Click to connect ─────────────────────────────────────────────────
|
||||||
let mut clicked_key: Option<(String, bool)> = None;
|
let mut clicked_key: Option<(String, bool)> = None;
|
||||||
if let Some(idx) = self.hovered {
|
if connect_enabled && !controls.hovered && let Some(idx) = self.hovered {
|
||||||
let sn = norm(&self.shapes[idx].name);
|
let sn = &self.shapes[idx].normalized_name;
|
||||||
let key = nordvpn_countries
|
let key = nordvpn_countries
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| {
|
.find(|c| {
|
||||||
let cn = norm(&c.name);
|
let cn = norm(&c.name);
|
||||||
sn == cn || sn.starts_with(&cn)
|
sn == &cn || sn.starts_with(&cn)
|
||||||
})
|
})
|
||||||
.map(|c| c.nordvpn_key.clone());
|
.map(|c| c.nordvpn_key.clone());
|
||||||
if let Some(k) = key {
|
if let Some(k) = key {
|
||||||
|
|
@ -127,41 +168,26 @@ impl WorldMap {
|
||||||
let available_set: HashSet<String> =
|
let available_set: HashSet<String> =
|
||||||
nordvpn_countries.iter().map(|c| norm(&c.name)).collect();
|
nordvpn_countries.iter().map(|c| norm(&c.name)).collect();
|
||||||
let connected_norm = connected_country.map(norm);
|
let connected_norm = connected_country.map(norm);
|
||||||
|
let hovered_norm = self
|
||||||
|
.hovered
|
||||||
|
.map(|idx| self.shapes[idx].normalized_name.as_str());
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────────
|
// ── Render ───────────────────────────────────────────────────────────
|
||||||
// Ocean background for full panel; country shapes clipped to map_rect.
|
|
||||||
ui.painter_at(available).rect_filled(available, 0.0, COLOR_OCEAN);
|
ui.painter_at(available).rect_filled(available, 0.0, COLOR_OCEAN);
|
||||||
|
|
||||||
let painter = ui.painter_at(map_rect);
|
let painter = ui.painter_at(available);
|
||||||
let stroke_w = (0.8 * self.zoom).min(2.0);
|
let stroke_w = (0.8 * self.zoom).min(2.0);
|
||||||
|
|
||||||
// Project the south pole to screen space. When zoomed/panned away it
|
|
||||||
// projects far below the viewport and the letterbox fill is suppressed.
|
|
||||||
let south_pole_y = self.projection.project(0.0, -90.0, virtual_rect).y;
|
let south_pole_y = self.projection.project(0.0, -90.0, virtual_rect).y;
|
||||||
let antarctica_near_bottom =
|
let mut antarctica_fill = COLOR_UNAVAILABLE;
|
||||||
south_pole_y >= map_rect.max.y - 3.0 && south_pole_y <= map_rect.max.y + 3.0;
|
|
||||||
|
|
||||||
// Fill the letterbox below the 2:1 map rect with land color.
|
for shape in &self.shapes {
|
||||||
// This is below map_rect so it never overlaps with shape rendering.
|
let sn = &shape.normalized_name;
|
||||||
if antarctica_near_bottom && map_rect.max.y < available.max.y {
|
|
||||||
ui.painter_at(available).rect_filled(
|
|
||||||
egui::Rect::from_min_max(
|
|
||||||
egui::pos2(available.min.x, map_rect.max.y),
|
|
||||||
available.max,
|
|
||||||
),
|
|
||||||
0.0,
|
|
||||||
COLOR_UNAVAILABLE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (idx, shape) in self.shapes.iter().enumerate() {
|
|
||||||
let sn = norm(&shape.name);
|
|
||||||
let is_connected = connected_norm
|
let is_connected = connected_norm
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|cn| sn == cn || sn.starts_with(cn));
|
.is_some_and(|cn| sn == cn || sn.starts_with(cn));
|
||||||
let is_available = available_set.contains(&sn)
|
let is_available = available_set.contains(sn)
|
||||||
|| available_set.iter().any(|a| sn.starts_with(a.as_str()));
|
|| available_set.iter().any(|a| sn.starts_with(a.as_str()));
|
||||||
let is_hovered = self.hovered == Some(idx);
|
let is_hovered = hovered_norm == Some(sn.as_str());
|
||||||
|
|
||||||
let base = if is_connected {
|
let base = if is_connected {
|
||||||
COLOR_CONNECTED
|
COLOR_CONNECTED
|
||||||
|
|
@ -171,36 +197,115 @@ impl WorldMap {
|
||||||
COLOR_UNAVAILABLE
|
COLOR_UNAVAILABLE
|
||||||
};
|
};
|
||||||
let fill = if is_hovered { brightened(base, 50) } else { base };
|
let fill = if is_hovered { brightened(base, 50) } else { base };
|
||||||
|
if sn == "antarctica" {
|
||||||
|
antarctica_fill = fill;
|
||||||
|
}
|
||||||
|
|
||||||
render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
|
render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
for shape in &self.shapes {
|
||||||
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER, stroke_w);
|
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER, stroke_w);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover Antarctica's border seam at the bottom of the map. Uses the
|
if let Some(connected) = connected_norm.as_deref() {
|
||||||
// same painter (map_rect clip) as the shapes so egui keeps it in the
|
for shape in &self.shapes {
|
||||||
// same draw batch — guaranteed to render after the borders.
|
let sn = &shape.normalized_name;
|
||||||
if antarctica_near_bottom {
|
if sn == connected || sn.starts_with(connected) {
|
||||||
painter.rect_filled(
|
render_border(
|
||||||
egui::Rect::from_min_max(
|
&painter,
|
||||||
egui::pos2(map_rect.min.x, map_rect.max.y - 3.0),
|
shape,
|
||||||
map_rect.max,
|
&*self.projection,
|
||||||
),
|
virtual_rect,
|
||||||
0.0,
|
COLOR_BORDER,
|
||||||
COLOR_UNAVAILABLE,
|
stroke_w + 1.5,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let antarctica_top = (south_pole_y - 3.0).clamp(available.min.y, available.max.y);
|
||||||
|
painter.rect_filled(
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
egui::pos2(available.min.x, antarctica_top),
|
||||||
|
available.max,
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
antarctica_fill,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Tooltip ──────────────────────────────────────────────────────────
|
// ── Tooltip ──────────────────────────────────────────────────────────
|
||||||
let hover_name: Option<String> =
|
let hover_name: Option<String> =
|
||||||
self.hovered.map(|i| self.shapes[i].name.clone());
|
self.hovered.map(|i| self.shapes[i].name.clone());
|
||||||
if let Some(name) = hover_name {
|
if let Some(name) = hover_name {
|
||||||
response.on_hover_ui_at_pointer(|ui| {
|
response.on_hover_text_at_pointer(name);
|
||||||
ui.label(&name);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clicked_key
|
clicked_key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn zoom_at(&mut self, factor: f32, anchor: egui::Pos2, map_rect: egui::Rect) {
|
||||||
|
let old_zoom = self.zoom;
|
||||||
|
self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
|
||||||
|
let anchor_in_map = anchor - map_rect.min - self.pan;
|
||||||
|
self.pan = anchor - map_rect.min - anchor_in_map * (self.zoom / old_zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_view(&mut self) {
|
||||||
|
self.zoom = MIN_ZOOM;
|
||||||
|
self.pan = egui::Vec2::ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MapControlResponse {
|
||||||
|
zoom_in: bool,
|
||||||
|
zoom_out: bool,
|
||||||
|
reset: bool,
|
||||||
|
hovered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_map_controls(ui: &egui::Ui, viewport: egui::Rect, zoom: f32) -> MapControlResponse {
|
||||||
|
let output = egui::Area::new(ui.id().with("map_controls"))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.fixed_pos(egui::pos2(viewport.max.x - 48.0, viewport.min.y + 12.0))
|
||||||
|
.show(ui.ctx(), |ui| {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(egui::Color32::from_black_alpha(210))
|
||||||
|
.corner_radius(4.0)
|
||||||
|
.inner_margin(4.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
let zoom_in = ui
|
||||||
|
.add_enabled(
|
||||||
|
zoom < MAX_ZOOM,
|
||||||
|
egui::Button::new("+").min_size(egui::vec2(32.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Zoom In (+)")
|
||||||
|
.clicked();
|
||||||
|
let zoom_out = ui
|
||||||
|
.add_enabled(
|
||||||
|
zoom > MIN_ZOOM,
|
||||||
|
egui::Button::new("−").min_size(egui::vec2(32.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Zoom Out (−)")
|
||||||
|
.clicked();
|
||||||
|
let reset = ui
|
||||||
|
.add_enabled(
|
||||||
|
zoom > MIN_ZOOM,
|
||||||
|
egui::Button::new("⌂").min_size(egui::vec2(32.0, 32.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text("Reset View (0 or Home)")
|
||||||
|
.clicked();
|
||||||
|
(zoom_in, zoom_out, reset)
|
||||||
|
})
|
||||||
|
.inner
|
||||||
|
});
|
||||||
|
let (zoom_in, zoom_out, reset) = output.inner;
|
||||||
|
MapControlResponse {
|
||||||
|
zoom_in,
|
||||||
|
zoom_out,
|
||||||
|
reset,
|
||||||
|
hovered: output.response.contains_pointer(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -235,15 +340,20 @@ fn render_border(
|
||||||
color: egui::Color32,
|
color: egui::Color32,
|
||||||
stroke_width: f32,
|
stroke_width: f32,
|
||||||
) {
|
) {
|
||||||
if shape.outer_ring.len() < 2 {
|
let stroke = egui::Stroke::new(stroke_width, color);
|
||||||
return;
|
for ring in &shape.rings {
|
||||||
|
if ring.len() < 2 {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let pts: Vec<egui::Pos2> = shape
|
let pts: Vec<egui::Pos2> = ring
|
||||||
.outer_ring
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&[lon, lat]| projection.project(lon, lat, rect))
|
.map(|&[lon, lat]| projection.project(lon, lat, rect))
|
||||||
.collect();
|
.collect();
|
||||||
painter.add(egui::Shape::closed_line(pts, egui::Stroke::new(stroke_width, color)));
|
for edge in pts.windows(2) {
|
||||||
|
painter.line_segment([edge[0], edge[1]], stroke);
|
||||||
|
}
|
||||||
|
painter.line_segment([pts[pts.len() - 1], pts[0]], stroke);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -285,40 +395,43 @@ fn parse_geojson(bytes: &[u8]) -> Vec<CountryShape> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tessellate(coordinates: &Value, name: String) -> Option<CountryShape> {
|
fn tessellate(coordinates: &Value, name: String) -> Option<CountryShape> {
|
||||||
let rings = coordinates.as_array()?;
|
let coordinate_rings = coordinates.as_array()?;
|
||||||
if rings.is_empty() {
|
if coordinate_rings.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture the outer ring for hit-testing and border drawing.
|
let rings: Vec<Vec<[f64; 2]>> = coordinate_rings
|
||||||
let outer_pts = rings[0].as_array()?;
|
|
||||||
let n_outer = outer_pts.len().saturating_sub(1); // GeoJSON rings are closed
|
|
||||||
let outer_ring: Vec<[f64; 2]> = outer_pts[..n_outer]
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|pt| {
|
.map(|ring| {
|
||||||
let c = pt.as_array()?;
|
let points = ring.as_array()?;
|
||||||
Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?])
|
let len = points.len().saturating_sub(1);
|
||||||
|
points[..len]
|
||||||
|
.iter()
|
||||||
|
.map(|point| {
|
||||||
|
let coordinates = point.as_array()?;
|
||||||
|
Some([
|
||||||
|
coordinates.first()?.as_f64()?,
|
||||||
|
coordinates.get(1)?.as_f64()?,
|
||||||
|
])
|
||||||
})
|
})
|
||||||
.collect();
|
.collect::<Option<Vec<_>>>()
|
||||||
|
})
|
||||||
|
.collect::<Option<Vec<_>>>()?;
|
||||||
|
|
||||||
if outer_ring.len() < 3 {
|
if rings.first()?.len() < 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build flat vertex + hole data for earcutr.
|
|
||||||
let mut data: Vec<f64> = Vec::new();
|
let mut data: Vec<f64> = Vec::new();
|
||||||
let mut hole_indices: Vec<usize> = Vec::new();
|
let mut hole_indices: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
for (i, ring) in rings.iter().enumerate() {
|
for (index, ring) in rings.iter().enumerate() {
|
||||||
if i > 0 {
|
if index > 0 {
|
||||||
hole_indices.push(data.len() / 2);
|
hole_indices.push(data.len() / 2);
|
||||||
}
|
}
|
||||||
let pts = ring.as_array()?;
|
for &[lon, lat] in ring {
|
||||||
let n = pts.len().saturating_sub(1);
|
data.push(lon);
|
||||||
for pt in &pts[..n] {
|
data.push(lat);
|
||||||
let c = pt.as_array()?;
|
|
||||||
data.push(c.first()?.as_f64()?);
|
|
||||||
data.push(c.get(1)?.as_f64()?);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,13 +443,24 @@ fn tessellate(coordinates: &Value, name: String) -> Option<CountryShape> {
|
||||||
let vertices: Vec<[f64; 2]> = data.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
|
let vertices: Vec<[f64; 2]> = data.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
|
||||||
let indices: Vec<u32> = tri_indices.into_iter().map(|i| i as u32).collect();
|
let indices: Vec<u32> = tri_indices.into_iter().map(|i| i as u32).collect();
|
||||||
|
|
||||||
Some(CountryShape { name, vertices, indices, outer_ring })
|
let normalized_name = norm(&name);
|
||||||
|
Some(CountryShape { name, normalized_name, vertices, indices, rings })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Geometry helpers
|
// Geometry helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn point_in_shape(point: [f64; 2], rings: &[Vec<[f64; 2]>]) -> bool {
|
||||||
|
let Some((outer, holes)) = rings.split_first() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
point_in_polygon(point, outer)
|
||||||
|
&& !holes
|
||||||
|
.iter()
|
||||||
|
.any(|hole| point_in_polygon(point, hole))
|
||||||
|
}
|
||||||
|
|
||||||
/// Ray-casting point-in-polygon test (2D, Cartesian).
|
/// Ray-casting point-in-polygon test (2D, Cartesian).
|
||||||
fn point_in_polygon(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
|
fn point_in_polygon(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
|
||||||
let [px, py] = p;
|
let [px, py] = p;
|
||||||
|
|
@ -412,6 +536,49 @@ mod tests {
|
||||||
assert!(!point_in_polygon([1.5, 1.5], &l_shape)); // in the notch
|
assert!(!point_in_polygon([1.5, 1.5], &l_shape)); // in the notch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_excludes_holes() {
|
||||||
|
let rings = vec![
|
||||||
|
vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
|
||||||
|
vec![[1.0, 1.0], [3.0, 1.0], [3.0, 3.0], [1.0, 3.0]],
|
||||||
|
];
|
||||||
|
assert!(point_in_shape([0.5, 0.5], &rings));
|
||||||
|
assert!(!point_in_shape([2.0, 2.0], &rings));
|
||||||
|
assert!(!point_in_shape([5.0, 2.0], &rings));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_map_assigns_lesotho_to_lesotho() {
|
||||||
|
let map = WorldMap::new();
|
||||||
|
let maseru = [27.48, -29.31];
|
||||||
|
let south_africa = map
|
||||||
|
.shapes
|
||||||
|
.iter()
|
||||||
|
.find(|shape| shape.name == "South Africa")
|
||||||
|
.unwrap();
|
||||||
|
let lesotho = map
|
||||||
|
.shapes
|
||||||
|
.iter()
|
||||||
|
.find(|shape| shape.name == "Lesotho")
|
||||||
|
.unwrap();
|
||||||
|
assert!(!point_in_shape(maseru, &south_africa.rings));
|
||||||
|
assert!(point_in_shape(maseru, &lesotho.rings));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoom_is_bounded_and_resettable() {
|
||||||
|
let mut map = WorldMap::new();
|
||||||
|
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 180.0));
|
||||||
|
map.zoom_at(100.0, rect.center(), rect);
|
||||||
|
assert_eq!(map.zoom, MAX_ZOOM);
|
||||||
|
map.zoom_at(0.001, rect.center(), rect);
|
||||||
|
assert_eq!(map.zoom, MIN_ZOOM);
|
||||||
|
map.pan = egui::vec2(-20.0, -10.0);
|
||||||
|
map.reset_view();
|
||||||
|
assert_eq!(map.zoom, MIN_ZOOM);
|
||||||
|
assert_eq!(map.pan, egui::Vec2::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
// ── norm ────────────────────────────────────────────────────────────────
|
// ── norm ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue