feat: +njalla module

This commit is contained in:
2026-07-13 23:37:14 +00:00
parent 7e8c6884db
commit ef7d1b29f4
12 changed files with 2825 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct FeatureCollection {
features: Vec<Feature>,
}
#[derive(Debug, Deserialize)]
struct Feature {
properties: Properties,
geometry: Geometry,
}
#[derive(Debug, Deserialize)]
struct Properties {
#[serde(rename = "ISO_A2_EH")]
iso_a2: String,
#[serde(rename = "NAME")]
name: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum Geometry {
Polygon {
coordinates: Vec<Vec<[f64; 2]>>,
},
MultiPolygon {
coordinates: Vec<Vec<Vec<[f64; 2]>>>,
},
}
#[allow(dead_code)]
pub struct Country {
pub iso_a2: String,
pub name: String,
/// Each polygon is a list of rings; ring 0 = outer, rest = holes
pub polygons: Vec<Vec<Vec<[f64; 2]>>>,
pub bbox: (f64, f64, f64, f64), // (min_lon, min_lat, max_lon, max_lat)
pub label_pos: (f64, f64), // (lon, lat) — centroid of largest polygon
}
/// Signed area of a ring (positive = CCW).
fn ring_signed_area(ring: &[[f64; 2]]) -> f64 {
let n = ring.len();
if n < 3 {
return 0.0;
}
let mut area = 0.0;
let mut j = n - 1;
for i in 0..n {
area += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
j = i;
}
area / 2.0
}
/// Ray-casting point-in-ring test.
fn point_in_ring(lon: f64, lat: f64, ring: &[[f64; 2]]) -> bool {
let mut inside = false;
let n = ring.len();
if n < 3 {
return false;
}
let mut j = n - 1;
for i in 0..n {
let xi = ring[i][0];
let yi = ring[i][1];
let xj = ring[j][0];
let yj = ring[j][1];
if ((yi > lat) != (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
inside = !inside;
}
j = i;
}
inside
}
/// Find horizontal interior spans at a given latitude.
/// Returns sorted pairs of (enter_lon, exit_lon).
fn horizontal_spans(lat: f64, ring: &[[f64; 2]]) -> Vec<(f64, f64)> {
let n = ring.len();
if n < 3 {
return Vec::new();
}
let mut crossings = Vec::new();
let mut j = n - 1;
for i in 0..n {
let yi = ring[i][1];
let yj = ring[j][1];
if (yi > lat) != (yj > lat) {
let xi = ring[i][0];
let xj = ring[j][0];
crossings.push((xj - xi) * (lat - yi) / (yj - yi) + xi);
}
j = i;
}
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap());
crossings.chunks_exact(2).map(|p| (p[0], p[1])).collect()
}
/// Find vertical interior spans at a given longitude.
/// Returns sorted pairs of (enter_lat, exit_lat).
fn vertical_spans(lon: f64, ring: &[[f64; 2]]) -> Vec<(f64, f64)> {
let n = ring.len();
if n < 3 {
return Vec::new();
}
let mut crossings = Vec::new();
let mut j = n - 1;
for i in 0..n {
let xi = ring[i][0];
let xj = ring[j][0];
if (xi > lon) != (xj > lon) {
let yi = ring[i][1];
let yj = ring[j][1];
crossings.push((yj - yi) * (lon - xi) / (xj - xi) + yi);
}
j = i;
}
crossings.sort_by(|a, b| a.partial_cmp(b).unwrap());
crossings.chunks_exact(2).map(|p| (p[0], p[1])).collect()
}
/// Find a good interior label point for a ring.
/// Scans a grid of candidate points and picks the one that maximizes
/// min(half_width, half_height) — the "most interior" point.
fn ring_label_point(ring: &[[f64; 2]]) -> (f64, f64) {
let min_lon = ring.iter().map(|c| c[0]).fold(f64::MAX, f64::min);
let max_lon = ring.iter().map(|c| c[0]).fold(f64::MIN, f64::max);
let min_lat = ring.iter().map(|c| c[1]).fold(f64::MAX, f64::min);
let max_lat = ring.iter().map(|c| c[1]).fold(f64::MIN, f64::max);
let steps = 24;
let mut best = ((min_lon + max_lon) / 2.0, (min_lat + max_lat) / 2.0);
let mut best_score = 0.0f64;
for row in 1..steps {
let lat = min_lat + (max_lat - min_lat) * row as f64 / steps as f64;
let h_spans = horizontal_spans(lat, ring);
for &(span_left, span_right) in &h_spans {
let mid_lon = (span_left + span_right) / 2.0;
let half_w = (span_right - span_left) / 2.0;
// Measure vertical extent at this longitude
let v_spans = vertical_spans(mid_lon, ring);
for &(span_bot, span_top) in &v_spans {
if lat >= span_bot && lat <= span_top {
let half_h = ((lat - span_bot).min(span_top - lat)).min(half_w);
let score = half_w.min(half_h);
if score > best_score {
best_score = score;
best = (mid_lon, lat);
}
break;
}
}
}
}
best
}
pub fn load_countries(geojson: &str) -> Vec<Country> {
let fc: FeatureCollection = serde_json::from_str(geojson).expect("Failed to parse GeoJSON");
fc.features
.into_iter()
.map(|f| {
let polygons = match f.geometry {
Geometry::Polygon { coordinates } => vec![coordinates],
Geometry::MultiPolygon { coordinates } => coordinates,
};
let mut min_lon = f64::MAX;
let mut min_lat = f64::MAX;
let mut max_lon = f64::MIN;
let mut max_lat = f64::MIN;
for poly in &polygons {
for ring in poly {
for coord in ring {
let lon = coord[0];
let lat = coord[1];
if lon < min_lon {
min_lon = lon;
}
if lon > max_lon {
max_lon = lon;
}
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
}
}
}
// Label inside the largest polygon
let label_pos = polygons
.iter()
.filter(|p| !p.is_empty() && p[0].len() >= 3)
.max_by(|a, b| {
ring_signed_area(&a[0])
.abs()
.partial_cmp(&ring_signed_area(&b[0]).abs())
.unwrap()
})
.map(|p| ring_label_point(&p[0]))
.unwrap_or(((min_lon + max_lon) / 2.0, (min_lat + max_lat) / 2.0));
Country {
iso_a2: f.properties.iso_a2,
name: f.properties.name,
polygons,
bbox: (min_lon, min_lat, max_lon, max_lat),
label_pos,
}
})
.collect()
}
/// Check if a point is inside a polygon (outer ring minus holes).
fn point_in_polygon(lon: f64, lat: f64, rings: &[Vec<[f64; 2]>]) -> bool {
if rings.is_empty() || !point_in_ring(lon, lat, &rings[0]) {
return false;
}
// Must be outside all holes
!rings[1..].iter().any(|hole| point_in_ring(lon, lat, hole))
}
/// Check if a point falls inside a country.
pub fn point_in_country(lon: f64, lat: f64, country: &Country) -> bool {
let (min_lon, min_lat, max_lon, max_lat) = country.bbox;
if lon < min_lon || lon > max_lon || lat < min_lat || lat > max_lat {
return false;
}
country
.polygons
.iter()
.any(|poly| point_in_polygon(lon, lat, poly))
}
/// Find which country contains the given point, with a nearest-country
/// fallback for when low-res coastlines cause a near miss.
pub fn find_country(lon: f64, lat: f64, countries: &[Country]) -> Option<usize> {
// Exact hit
if let Some(idx) = countries.iter().position(|c| point_in_country(lon, lat, c)) {
return Some(idx);
}
// Search in expanding rings up to ~1 degree
for &offset in &[0.25, 0.5, 1.0] {
for &(dlon, dlat) in &[
(offset, 0.0),
(-offset, 0.0),
(0.0, offset),
(0.0, -offset),
(offset, offset),
(offset, -offset),
(-offset, offset),
(-offset, -offset),
] {
if let Some(idx) = countries
.iter()
.position(|c| point_in_country(lon + dlon, lat + dlat, c))
{
return Some(idx);
}
}
}
None
}
+44
View File
@@ -0,0 +1,44 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(tag = "status")]
pub enum GeoIpResponse {
#[serde(rename = "success")]
Success {
country: String,
#[serde(rename = "countryCode")]
country_code: String,
lat: f64,
lon: f64,
},
#[serde(rename = "fail")]
Fail { message: String },
}
pub struct GeoIpResult {
pub country: String,
pub country_code: String,
pub lat: f64,
pub lon: f64,
}
pub fn lookup() -> Result<GeoIpResult, String> {
let url = "http://ip-api.com/json/?fields=status,message,countryCode,country,lat,lon";
let resp = reqwest::blocking::get(url).map_err(|e| format!("HTTP request failed: {e}"))?;
let data: GeoIpResponse = resp.json().map_err(|e| format!("Failed to parse response: {e}"))?;
match data {
GeoIpResponse::Success {
country,
country_code,
lat,
lon,
} => Ok(GeoIpResult {
country,
country_code,
lat,
lon,
}),
GeoIpResponse::Fail { message } => Err(format!("GeoIP lookup failed: {message}")),
}
}
+99
View File
@@ -0,0 +1,99 @@
mod geo;
#[cfg(feature = "geoip")]
mod geoip;
mod render;
use clap::Parser;
#[derive(Parser)]
#[command(name = "which-country-rs", version, about = "Detect your country by IP and render an ASCII world map")]
struct Args {
/// Map width in characters
#[arg(short = 'W', long, default_value_t = 80)]
width: usize,
/// Map height in characters
#[arg(short = 'H', long, default_value_t = 24)]
height: usize,
/// Country code to display (e.g. "US", "FR", "JP") — skips IP lookup
#[arg(short, long)]
country: Option<String>,
/// Latitude (requires --lon too) — skips IP lookup, derives country from coordinates
#[arg(long, requires = "lon", allow_hyphen_values = true)]
lat: Option<f64>,
/// Longitude (requires --lat too)
#[arg(long, requires = "lat", allow_hyphen_values = true)]
lon: Option<f64>,
}
static GEOJSON: &str = include_str!("../data/countries.geojson");
fn main() {
let args = Args::parse();
let countries = geo::load_countries(GEOJSON);
let (country_name, country_code, lat, lon) = if let Some(code) = &args.country {
// Direct country code — find it in the data
let code_upper = code.to_uppercase();
let c = countries
.iter()
.find(|c| c.iso_a2 == code_upper)
.unwrap_or_else(|| {
eprintln!("Unknown country code: {code_upper}");
std::process::exit(1);
});
let (label_lon, label_lat) = c.label_pos;
(c.name.clone(), code_upper, label_lat, label_lon)
} else if let (Some(lat), Some(lon)) = (args.lat, args.lon) {
// Coordinates provided — find which country contains the point
let idx = geo::find_country(lon, lat, &countries)
.unwrap_or_else(|| {
eprintln!("No country found at {lat}, {lon} (ocean?)");
std::process::exit(1);
});
(
countries[idx].name.clone(),
countries[idx].iso_a2.clone(),
lat,
lon,
)
} else {
// IP geolocation
#[cfg(feature = "geoip")]
{
eprint!("Looking up your location... ");
match geoip::lookup() {
Ok(loc) => {
eprintln!("done.");
(loc.country, loc.country_code, loc.lat, loc.lon)
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
}
#[cfg(not(feature = "geoip"))]
{
eprintln!("IP geolocation requires the 'geoip' feature. Use --country or --lat/--lon instead.");
std::process::exit(1);
}
};
let map = render::render_map(&countries, &country_code, args.width, args.height);
println!("You appear to be in: {country_name} ({country_code})");
println!();
println!("{map}");
println!();
println!(
"Coordinates: {:.2}\u{00b0}{}, {:.2}\u{00b0}{}",
lat.abs(),
if lat >= 0.0 { "N" } else { "S" },
lon.abs(),
if lon >= 0.0 { "E" } else { "W" },
);
}
+184
View File
@@ -0,0 +1,184 @@
use crate::geo::Country;
/// Render a zoomed-in ASCII map centered on the target country, showing borders and labels.
pub fn render_map(
countries: &[Country],
target_code: &str,
width: usize,
height: usize,
) -> String {
// Find the target country and compute its bbox
let target_idx = countries
.iter()
.position(|c| c.iso_a2 == target_code)
.expect("Target country not found in GeoJSON");
let (min_lon, min_lat, max_lon, max_lat) = countries[target_idx].bbox;
// Add generous padding so the surrounding continent is visible
let lon_span = (max_lon - min_lon).max(4.0);
let lat_span = (max_lat - min_lat).max(4.0);
let pad_lon = lon_span * 1.0;
let pad_lat = lat_span * 1.0;
let view_min_lon = (min_lon - pad_lon).max(-180.0);
let view_max_lon = (max_lon + pad_lon).min(180.0);
let view_min_lat = (min_lat - pad_lat).max(-90.0);
let view_max_lat = (max_lat + pad_lat).min(90.0);
// Adjust aspect ratio: terminal chars are ~2x taller than wide
let lon_range = view_max_lon - view_min_lon;
let lat_range = view_max_lat - view_min_lat;
let char_aspect = 2.0;
let (final_lon_range, final_lat_range);
let desired_lon = lat_range * (width as f64) / (height as f64) / char_aspect;
let desired_lat = lon_range * (height as f64) / (width as f64) * char_aspect;
if desired_lon > lon_range {
final_lon_range = desired_lon;
final_lat_range = lat_range;
} else {
final_lon_range = lon_range;
final_lat_range = desired_lat;
}
let center_lon = (view_min_lon + view_max_lon) / 2.0;
let center_lat = (view_min_lat + view_max_lat) / 2.0;
let vp_min_lon = (center_lon - final_lon_range / 2.0).max(-180.0);
let vp_max_lat = (center_lat + final_lat_range / 2.0).min(90.0);
let lon_per_col = final_lon_range / width as f64;
let lat_per_row = final_lat_range / height as f64;
// Grid of characters
let mut grid = vec![vec![' '; width]; height];
// Rasterize polygon edges onto the grid
for (i, country) in countries.iter().enumerate() {
let (c_min_lon, c_min_lat, c_max_lon, c_max_lat) = country.bbox;
if c_max_lon < vp_min_lon
|| c_min_lon > vp_min_lon + final_lon_range
|| c_max_lat < vp_max_lat - final_lat_range
|| c_min_lat > vp_max_lat
{
continue;
}
let border_ch = if i == target_idx { '#' } else { '\u{00b7}' };
for poly in &country.polygons {
for ring in poly {
if ring.len() < 2 {
continue;
}
for edge in ring.windows(2) {
rasterize_edge(
edge[0][0],
edge[0][1],
edge[1][0],
edge[1][1],
vp_min_lon,
vp_max_lat,
lon_per_col,
lat_per_row,
width,
height,
border_ch,
i == target_idx,
&mut grid,
);
}
}
}
}
// Place country code labels at bbox center
for (i, country) in countries.iter().enumerate() {
if country.iso_a2 == "-99" {
continue;
}
let (label_lon, label_lat) = country.label_pos;
let col = ((label_lon - vp_min_lon) / lon_per_col) as isize;
let row = ((vp_max_lat - label_lat) / lat_per_row) as isize;
let label = &country.iso_a2;
let start_col = col - (label.len() as isize / 2);
for (j, ch) in label.chars().enumerate() {
let c = start_col + j as isize;
if c >= 0 && (c as usize) < width && row >= 0 && (row as usize) < height {
let r = row as usize;
let c = c as usize;
let existing = grid[r][c];
// Target label always writes; neighbor labels only on empty or neighbor border
if i == target_idx || existing == ' ' || existing == '\u{00b7}' {
grid[r][c] = ch;
}
}
}
}
// Render grid to string
grid.iter()
.map(|row| row.iter().collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
/// Rasterize a line segment onto the grid using Bresenham's algorithm.
fn rasterize_edge(
lon0: f64,
lat0: f64,
lon1: f64,
lat1: f64,
vp_min_lon: f64,
vp_max_lat: f64,
lon_per_col: f64,
lat_per_row: f64,
width: usize,
height: usize,
ch: char,
is_target: bool,
grid: &mut [Vec<char>],
) {
let c0 = ((lon0 - vp_min_lon) / lon_per_col) as i32;
let r0 = ((vp_max_lat - lat0) / lat_per_row) as i32;
let c1 = ((lon1 - vp_min_lon) / lon_per_col) as i32;
let r1 = ((vp_max_lat - lat1) / lat_per_row) as i32;
let mut x = c0;
let mut y = r0;
let dx = (c1 - c0).abs();
let dy = -(r1 - r0).abs();
let sx = if c0 < c1 { 1 } else { -1 };
let sy = if r0 < r1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
if x >= 0 && (x as usize) < width && y >= 0 && (y as usize) < height {
let cell = &mut grid[y as usize][x as usize];
// Target borders overwrite neighbor borders; neighbor borders only on empty
if is_target || *cell == ' ' {
*cell = ch;
}
}
if x == c1 && y == r1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}