implement phase 5~7
This commit is contained in:
parent
8664a80f1e
commit
6140548774
10 changed files with 591 additions and 6 deletions
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -803,6 +803,16 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "earcutr"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "afc28d89ac6cb489f679fcf51aaa62b4667bb0fb73a30f1d4d23949069707c07"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.33.3"
|
||||
|
|
@ -941,6 +951,12 @@ dependencies = [
|
|||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.33.3"
|
||||
|
|
@ -1645,6 +1661,15 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
|
|
@ -2001,11 +2026,13 @@ checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
|||
name = "nordvpn-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"earcutr",
|
||||
"eframe",
|
||||
"egui_extras",
|
||||
"image",
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ version = "0.1.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
earcutr = "0.5.0"
|
||||
eframe = "0.33.3"
|
||||
egui_extras = { version = "0.33.3", features = ["file", "image", "svg"] }
|
||||
image = { version = "0.25.10", default-features = false, features = ["png"] }
|
||||
resvg = "0.45"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
toml = "1.0.7"
|
||||
|
|
|
|||
16
SPEC.md
16
SPEC.md
|
|
@ -264,7 +264,7 @@ gantt
|
|||
| 6 | Map pan/zoom, hover highlight, click-to-connect, color states by availability |
|
||||
| 7 | `config_dialog.rs` modal, full settings flow, apply restarts poller |
|
||||
| 8 | Unit tests (see below) |
|
||||
| 9 | Polish pass |
|
||||
| 9 | Polish pass: confirmation prompts, status formatting, edge cases, deprecation cleanup |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -280,6 +280,20 @@ gantt
|
|||
|
||||
---
|
||||
|
||||
## Phase 9 — Polish Details
|
||||
|
||||
- **Confirmation prompts on connect/switch**: connecting takes long enough to feel costly, so clicking
|
||||
a country (list, grid, or map) when already connected should show a small modal or inline prompt
|
||||
("Switch to X? [Connect] [Cancel]") before issuing the CLI call. Disconnected → connect can proceed
|
||||
immediately.
|
||||
- **Deprecated API cleanup**: replace `ctx.screen_rect()` with `content_rect()`, replace
|
||||
`show_tooltip_at_pointer` with `egui::Tooltip`, remove `#![allow(dead_code)]`.
|
||||
- **Border stroke scaling**: consider thinning borders at low zoom and thickening at high zoom.
|
||||
- **Notification display**: show `self.notification` somewhere visible (inline banner or status bar).
|
||||
- **Cursor polish**: grab/grabbing cursor icons on map pan.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements (out of scope)
|
||||
|
||||
- **Flags setup script**: download SVGs from https://github.com/emcrisostomo/flags (public domain,
|
||||
|
|
|
|||
1
assets/world.geojson
Normal file
1
assets/world.geojson
Normal file
File diff suppressed because one or more lines are too long
36
src/app.rs
36
src/app.rs
|
|
@ -9,7 +9,9 @@ use crate::config::Config;
|
|||
use crate::countries::{get_countries, Country};
|
||||
use crate::flags;
|
||||
use crate::nordvpn::{self, CmdResult, Poller};
|
||||
use crate::ui::config_dialog::{ConfigDialog, DialogResult};
|
||||
use crate::ui::country_selector::CountrySelector;
|
||||
use crate::world_map::WorldMap;
|
||||
|
||||
pub struct App {
|
||||
config: Config,
|
||||
|
|
@ -22,6 +24,8 @@ pub struct App {
|
|||
/// Fully loaded flag textures, keyed by nordvpn_key.
|
||||
flag_textures: HashMap<String, egui::TextureHandle>,
|
||||
selector: CountrySelector,
|
||||
world_map: WorldMap,
|
||||
config_dialog: Option<ConfigDialog>,
|
||||
/// Last connect/disconnect result shown inline below the status bar.
|
||||
notification: Option<String>,
|
||||
}
|
||||
|
|
@ -46,6 +50,8 @@ impl App {
|
|||
flag_rx,
|
||||
flag_textures: HashMap::new(),
|
||||
selector: CountrySelector::default(),
|
||||
world_map: WorldMap::new(),
|
||||
config_dialog: None,
|
||||
notification: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -130,12 +136,31 @@ impl eframe::App for App {
|
|||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Configure").clicked() {
|
||||
// Phase 7
|
||||
self.config_dialog = Some(ConfigDialog::new(&self.config));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Config dialog ────────────────────────────────────────────────────
|
||||
if let Some(dialog) = &mut self.config_dialog {
|
||||
match dialog.show(ctx, &self.countries) {
|
||||
DialogResult::Apply(new_cfg) => {
|
||||
self.poller.update_config(
|
||||
new_cfg.external_app.clone(),
|
||||
new_cfg.refresh_rate,
|
||||
);
|
||||
self.config = new_cfg;
|
||||
if let Err(e) = self.config.save() {
|
||||
self.notification = Some(format!("Failed to save config: {e}"));
|
||||
}
|
||||
self.config_dialog = None;
|
||||
}
|
||||
DialogResult::Cancel => self.config_dialog = None,
|
||||
DialogResult::Open => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Left panel — country selector ───────────────────────────────────
|
||||
let panel_width = ctx.screen_rect().width() * 0.3;
|
||||
let connect_key =
|
||||
|
|
@ -158,11 +183,12 @@ impl eframe::App for App {
|
|||
self.spawn_connect(key);
|
||||
}
|
||||
|
||||
// ── Central panel — world map placeholder ───────────────────────────
|
||||
// ── Central panel — world map ────────────────────────────────────────
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.weak("World map — Phase 5");
|
||||
});
|
||||
if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
|
||||
self.notification = None;
|
||||
self.spawn_connect(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ mod config;
|
|||
mod countries;
|
||||
mod flags;
|
||||
mod nordvpn;
|
||||
mod projection;
|
||||
mod ui;
|
||||
mod world_map;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
|
|
|
|||
77
src/projection.rs
Normal file
77
src/projection.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use eframe::egui;
|
||||
|
||||
pub trait MapProjection: Send + Sync {
|
||||
fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2;
|
||||
fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64);
|
||||
fn name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
pub struct Equirectangular;
|
||||
|
||||
impl MapProjection for Equirectangular {
|
||||
fn project(&self, lon: f64, lat: f64, rect: egui::Rect) -> egui::Pos2 {
|
||||
egui::pos2(
|
||||
rect.left() + ((lon + 180.0) / 360.0) as f32 * rect.width(),
|
||||
rect.top() + ((90.0 - lat) / 180.0) as f32 * rect.height(),
|
||||
)
|
||||
}
|
||||
|
||||
fn unproject(&self, pos: egui::Pos2, rect: egui::Rect) -> (f64, f64) {
|
||||
let lon = (pos.x - rect.left()) as f64 / rect.width() as f64 * 360.0 - 180.0;
|
||||
let lat = 90.0 - (pos.y - rect.top()) as f64 / rect.height() as f64 * 180.0;
|
||||
(lon, lat)
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Equirectangular"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn unit_rect() -> egui::Rect {
|
||||
egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(360.0, 180.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_center() {
|
||||
let proj = Equirectangular;
|
||||
let r = unit_rect();
|
||||
let pos = proj.project(0.0, 0.0, r);
|
||||
let (lon, lat) = proj.unproject(pos, r);
|
||||
assert!((lon).abs() < 1e-4);
|
||||
assert!((lat).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_corners() {
|
||||
let proj = Equirectangular;
|
||||
let r = unit_rect();
|
||||
for (lon, lat) in [(-180.0_f64, 90.0_f64), (180.0, -90.0), (-180.0, -90.0), (180.0, 90.0)] {
|
||||
let pos = proj.project(lon, lat, r);
|
||||
let (lon2, lat2) = proj.unproject(pos, r);
|
||||
assert!((lon - lon2).abs() < 1e-4, "lon {lon} → {lon2}");
|
||||
assert!((lat - lat2).abs() < 1e-4, "lat {lat} → {lat2}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_prime_meridian_equator() {
|
||||
let proj = Equirectangular;
|
||||
let r = unit_rect();
|
||||
let pos = proj.project(0.0, 0.0, r);
|
||||
assert!((pos.x - 180.0).abs() < 1e-3, "x={}", pos.x);
|
||||
assert!((pos.y - 90.0).abs() < 1e-3, "y={}", pos.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_scaling() {
|
||||
let proj = Equirectangular;
|
||||
let r = egui::Rect::from_min_size(egui::pos2(10.0, 20.0), egui::vec2(720.0, 360.0));
|
||||
let pos = proj.project(0.0, 0.0, r);
|
||||
assert!((pos.x - 370.0).abs() < 1e-3, "x={}", pos.x);
|
||||
assert!((pos.y - 200.0).abs() < 1e-3, "y={}", pos.y);
|
||||
}
|
||||
}
|
||||
102
src/ui/config_dialog.rs
Normal file
102
src/ui/config_dialog.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
use eframe::egui;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::countries::Country;
|
||||
|
||||
pub enum DialogResult {
|
||||
Open,
|
||||
Apply(Config),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct ConfigDialog {
|
||||
draft: Config,
|
||||
fav_filter: String,
|
||||
}
|
||||
|
||||
impl ConfigDialog {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
Self { draft: config.clone(), fav_filter: String::new() }
|
||||
}
|
||||
|
||||
pub fn show(&mut self, ctx: &egui::Context, countries: &[Country]) -> DialogResult {
|
||||
let mut result = DialogResult::Open;
|
||||
|
||||
egui::Window::new("Settings")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.min_width(300.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("cfg_grid")
|
||||
.num_columns(2)
|
||||
.spacing([8.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("NordVPN binary:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.draft.external_app)
|
||||
.desired_width(180.0),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Refresh rate:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut self.draft.refresh_rate)
|
||||
.range(1..=300),
|
||||
);
|
||||
ui.label("s");
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.label("Favorites:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.fav_filter)
|
||||
.hint_text("🔍 Filter…")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
|
||||
let filter = self.fav_filter.to_lowercase();
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("fav_scroll")
|
||||
.max_height(220.0)
|
||||
.show(ui, |ui| {
|
||||
for country in countries {
|
||||
if !filter.is_empty()
|
||||
&& !country.name.to_lowercase().contains(&filter)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut checked =
|
||||
self.draft.favorites.contains(&country.nordvpn_key);
|
||||
if ui.checkbox(&mut checked, &country.name).changed() {
|
||||
if checked {
|
||||
self.draft.favorites.push(country.nordvpn_key.clone());
|
||||
} else {
|
||||
self.draft
|
||||
.favorites
|
||||
.retain(|f| f != &country.nordvpn_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Apply").clicked() {
|
||||
result = DialogResult::Apply(self.draft.clone());
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
result = DialogResult::Cancel;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
pub mod config_dialog;
|
||||
pub mod country_selector;
|
||||
|
|
|
|||
333
src/world_map.rs
Normal file
333
src/world_map.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use eframe::egui;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::countries::Country;
|
||||
use crate::projection::{Equirectangular, MapProjection};
|
||||
|
||||
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_UNAVAILABLE: egui::Color32 = egui::Color32::from_rgb(50, 52, 58);
|
||||
const COLOR_OCEAN: egui::Color32 = egui::Color32::from_rgb(22, 24, 32);
|
||||
const COLOR_BORDER: egui::Color32 = egui::Color32::from_rgb(15, 17, 22);
|
||||
|
||||
struct CountryShape {
|
||||
name: String,
|
||||
/// Tessellated vertices in lon/lat space, for filled rendering.
|
||||
vertices: Vec<[f64; 2]>,
|
||||
indices: Vec<u32>,
|
||||
/// Outer ring in lon/lat space, for hit-testing and border drawing.
|
||||
outer_ring: Vec<[f64; 2]>,
|
||||
}
|
||||
|
||||
pub struct WorldMap {
|
||||
shapes: Vec<CountryShape>,
|
||||
projection: Box<dyn MapProjection>,
|
||||
zoom: f32,
|
||||
pan: egui::Vec2,
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for WorldMap {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorldMap {
|
||||
pub fn new() -> Self {
|
||||
let bytes = include_bytes!("../assets/world.geojson");
|
||||
Self {
|
||||
shapes: parse_geojson(bytes),
|
||||
projection: Box::new(Equirectangular),
|
||||
zoom: 1.0,
|
||||
pan: egui::Vec2::ZERO,
|
||||
hovered: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
nordvpn_countries: &[Country],
|
||||
connected_country: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let available = ui.available_rect_before_wrap();
|
||||
|
||||
// 2:1 aspect ratio map rect, centred in available space.
|
||||
let (aw, ah) = (available.width(), available.height());
|
||||
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));
|
||||
|
||||
// Virtual map rect: the world map as scaled + panned within map_rect.
|
||||
let virtual_rect = egui::Rect::from_min_size(
|
||||
map_rect.min + self.pan,
|
||||
map_rect.size() * self.zoom,
|
||||
);
|
||||
|
||||
let response = ui.allocate_rect(map_rect, egui::Sense::click_and_drag());
|
||||
|
||||
// ── Scroll to zoom ───────────────────────────────────────────────────
|
||||
if response.hovered() {
|
||||
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
|
||||
if scroll != 0.0 {
|
||||
let factor = (scroll * 0.005).exp();
|
||||
let cursor = response.hover_pos().unwrap_or(map_rect.center());
|
||||
let cursor_in_map = cursor - map_rect.min - self.pan;
|
||||
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 ──────────────────────────────────────────────────────
|
||||
if response.dragged() {
|
||||
self.pan += response.drag_delta();
|
||||
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grabbing);
|
||||
} else if response.hovered() && self.zoom > 1.0 {
|
||||
ui.output_mut(|o| o.cursor_icon = egui::CursorIcon::Grab);
|
||||
}
|
||||
|
||||
// ── 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.y = self.pan.y.clamp(-(self.zoom - 1.0) * map_rect.height(), 0.0);
|
||||
|
||||
// ── Hover detection ──────────────────────────────────────────────────
|
||||
self.hovered = None;
|
||||
if let Some(cursor) = response.hover_pos() {
|
||||
let (lon, lat) = self.projection.unproject(cursor, virtual_rect);
|
||||
self.hovered = self
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|s| point_in_polygon([lon, lat], &s.outer_ring));
|
||||
}
|
||||
|
||||
// ── Click to connect ─────────────────────────────────────────────────
|
||||
let mut clicked_key = None;
|
||||
if response.clicked() {
|
||||
if let Some(idx) = self.hovered {
|
||||
let sn = norm(&self.shapes[idx].name);
|
||||
clicked_key = nordvpn_countries
|
||||
.iter()
|
||||
.find(|c| {
|
||||
let cn = norm(&c.name);
|
||||
sn == cn || sn.starts_with(&cn)
|
||||
})
|
||||
.map(|c| c.nordvpn_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build availability sets ──────────────────────────────────────────
|
||||
let available_set: HashSet<String> =
|
||||
nordvpn_countries.iter().map(|c| norm(&c.name)).collect();
|
||||
let connected_norm = connected_country.map(norm);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────
|
||||
// Ocean background for full panel; country shapes clipped to map_rect.
|
||||
ui.painter_at(available).rect_filled(available, 0.0, COLOR_OCEAN);
|
||||
let painter = ui.painter_at(map_rect);
|
||||
|
||||
for (idx, shape) in self.shapes.iter().enumerate() {
|
||||
let sn = norm(&shape.name);
|
||||
let is_connected = connected_norm
|
||||
.as_deref()
|
||||
.is_some_and(|cn| sn == cn || sn.starts_with(cn));
|
||||
let is_available = available_set.contains(&sn)
|
||||
|| available_set.iter().any(|a| sn.starts_with(a.as_str()));
|
||||
let is_hovered = self.hovered == Some(idx);
|
||||
|
||||
let base = if is_connected {
|
||||
COLOR_CONNECTED
|
||||
} else if is_available {
|
||||
COLOR_AVAILABLE
|
||||
} else {
|
||||
COLOR_UNAVAILABLE
|
||||
};
|
||||
let fill = if is_hovered { brightened(base, 50) } else { base };
|
||||
|
||||
render_fill(&painter, shape, &*self.projection, virtual_rect, fill);
|
||||
render_border(&painter, shape, &*self.projection, virtual_rect, COLOR_BORDER);
|
||||
}
|
||||
|
||||
// ── Tooltip ──────────────────────────────────────────────────────────
|
||||
let hover_name: Option<String> =
|
||||
self.hovered.map(|i| self.shapes[i].name.clone());
|
||||
if let Some(name) = hover_name {
|
||||
response.on_hover_ui_at_pointer(|ui| {
|
||||
ui.label(&name);
|
||||
});
|
||||
}
|
||||
|
||||
clicked_key
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn render_fill(
|
||||
painter: &egui::Painter,
|
||||
shape: &CountryShape,
|
||||
projection: &dyn MapProjection,
|
||||
rect: egui::Rect,
|
||||
color: egui::Color32,
|
||||
) {
|
||||
if shape.indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for &[lon, lat] in &shape.vertices {
|
||||
mesh.colored_vertex(projection.project(lon, lat, rect), color);
|
||||
}
|
||||
for tri in shape.indices.chunks_exact(3) {
|
||||
mesh.add_triangle(tri[0], tri[1], tri[2]);
|
||||
}
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
fn render_border(
|
||||
painter: &egui::Painter,
|
||||
shape: &CountryShape,
|
||||
projection: &dyn MapProjection,
|
||||
rect: egui::Rect,
|
||||
color: egui::Color32,
|
||||
) {
|
||||
if shape.outer_ring.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let pts: Vec<egui::Pos2> = shape
|
||||
.outer_ring
|
||||
.iter()
|
||||
.map(|&[lon, lat]| projection.project(lon, lat, rect))
|
||||
.collect();
|
||||
painter.add(egui::Shape::closed_line(pts, egui::Stroke::new(0.8, color)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoJSON parsing + tessellation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_geojson(bytes: &[u8]) -> Vec<CountryShape> {
|
||||
let json: Value = serde_json::from_slice(bytes).expect("invalid world.geojson");
|
||||
let Some(features) = json["features"].as_array() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut shapes = Vec::new();
|
||||
for feature in features {
|
||||
let name = feature["properties"]["NAME"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let geom = &feature["geometry"];
|
||||
match geom["type"].as_str().unwrap_or("") {
|
||||
"Polygon" => {
|
||||
if let Some(s) = tessellate(&geom["coordinates"], name) {
|
||||
shapes.push(s);
|
||||
}
|
||||
}
|
||||
"MultiPolygon" => {
|
||||
if let Some(polys) = geom["coordinates"].as_array() {
|
||||
for poly in polys {
|
||||
if let Some(s) = tessellate(poly, name.clone()) {
|
||||
shapes.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
shapes
|
||||
}
|
||||
|
||||
fn tessellate(coordinates: &Value, name: String) -> Option<CountryShape> {
|
||||
let rings = coordinates.as_array()?;
|
||||
if rings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Capture the outer ring for hit-testing and border drawing.
|
||||
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()
|
||||
.filter_map(|pt| {
|
||||
let c = pt.as_array()?;
|
||||
Some([c.first()?.as_f64()?, c.get(1)?.as_f64()?])
|
||||
})
|
||||
.collect();
|
||||
|
||||
if outer_ring.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Build flat vertex + hole data for earcutr.
|
||||
let mut data: Vec<f64> = Vec::new();
|
||||
let mut hole_indices: Vec<usize> = Vec::new();
|
||||
|
||||
for (i, ring) in rings.iter().enumerate() {
|
||||
if i > 0 {
|
||||
hole_indices.push(data.len() / 2);
|
||||
}
|
||||
let pts = ring.as_array()?;
|
||||
let n = pts.len().saturating_sub(1);
|
||||
for pt in &pts[..n] {
|
||||
let c = pt.as_array()?;
|
||||
data.push(c.first()?.as_f64()?);
|
||||
data.push(c.get(1)?.as_f64()?);
|
||||
}
|
||||
}
|
||||
|
||||
let tri_indices = earcutr::earcut(&data, &hole_indices, 2).ok()?;
|
||||
if tri_indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Some(CountryShape { name, vertices, indices, outer_ring })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Geometry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ray-casting point-in-polygon test (2D, Cartesian).
|
||||
fn point_in_polygon(p: [f64; 2], ring: &[[f64; 2]]) -> bool {
|
||||
let [px, py] = p;
|
||||
let n = ring.len();
|
||||
let mut inside = false;
|
||||
let mut j = n.wrapping_sub(1);
|
||||
for i in 0..n {
|
||||
let [xi, yi] = ring[i];
|
||||
let [xj, yj] = ring[j];
|
||||
if (yi > py) != (yj > py) {
|
||||
let x_int = xi + (py - yi) * (xj - xi) / (yj - yi);
|
||||
if px < x_int {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
fn brightened(c: egui::Color32, amount: u8) -> egui::Color32 {
|
||||
egui::Color32::from_rgb(
|
||||
c.r().saturating_add(amount),
|
||||
c.g().saturating_add(amount),
|
||||
c.b().saturating_add(amount),
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalise a country name for loose matching: ASCII letters only, lowercased.
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_alphabetic())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
Loading…
Reference in a new issue