feat: +njalla module
This commit is contained in:
@@ -0,0 +1,134 @@
|
|||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
lib,
|
||||||
|
config,
|
||||||
|
...
|
||||||
|
}: let
|
||||||
|
cfg = config.hectic.hardware.njalla;
|
||||||
|
in {
|
||||||
|
options.hectic.hardware.njalla = {
|
||||||
|
enable = lib.mkEnableOption "Enable njalla hardware configurations";
|
||||||
|
ipv4 = lib.mkOption {
|
||||||
|
type = lib.types.strMatching "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
|
||||||
|
example = "185.193.126.103";
|
||||||
|
description = ''
|
||||||
|
Njalla IPv4 address assigned to the host.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ipv4PrefixLength = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 24;
|
||||||
|
example = 24;
|
||||||
|
description = ''
|
||||||
|
Njalla IPv4 prefix length.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ipv4Gateway = lib.mkOption {
|
||||||
|
type = lib.types.strMatching "^([0-9]{1,3}\\.){3}[0-9]{1,3}$";
|
||||||
|
default = "185.193.126.1";
|
||||||
|
example = "185.193.126.1";
|
||||||
|
description = ''
|
||||||
|
Njalla IPv4 gateway.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ipv6 = lib.mkOption {
|
||||||
|
type = lib.types.strMatching "^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$";
|
||||||
|
example = "2a0a:3840:1337:126:0:b9c1:7e67:1337";
|
||||||
|
description = ''
|
||||||
|
Njalla IPv6 address assigned to the host.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ipv6PrefixLength = lib.mkOption {
|
||||||
|
type = lib.types.int;
|
||||||
|
default = 64;
|
||||||
|
example = 64;
|
||||||
|
description = ''
|
||||||
|
Njalla IPv6 prefix length.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
ipv6Gateway = lib.mkOption {
|
||||||
|
type = lib.types.strMatching "^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$";
|
||||||
|
default = "2a0a:3840:1337:126::1";
|
||||||
|
example = "2a0a:3840:1337:126::1";
|
||||||
|
description = ''
|
||||||
|
Njalla IPv6 gateway.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
networkMatchConfigName = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "eth0";
|
||||||
|
example = "eth0";
|
||||||
|
description = ''
|
||||||
|
Njalla container network interface name.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
device = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "/dev/vda";
|
||||||
|
example = "/dev/disk/by-id/virtio-root";
|
||||||
|
description = ''
|
||||||
|
Njalla installation disk for disko/nixos-anywhere.
|
||||||
|
|
||||||
|
`/dev/vda` is the default block device visible on the inspected Njalla
|
||||||
|
host. Prefer a stable `/dev/disk/by-id/...` path when available.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
enableDisko = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Whether to provide a disko layout for nixos-anywhere installs.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||||
|
{
|
||||||
|
boot.isContainer = true;
|
||||||
|
|
||||||
|
networking.useDHCP = false;
|
||||||
|
networking.useNetworkd = true;
|
||||||
|
systemd.network.enable = true;
|
||||||
|
systemd.network.networks."30-wan" = {
|
||||||
|
matchConfig.Name = cfg.networkMatchConfigName;
|
||||||
|
networkConfig.DHCP = "no";
|
||||||
|
address = [
|
||||||
|
"${cfg.ipv4}/${toString cfg.ipv4PrefixLength}"
|
||||||
|
"${cfg.ipv6}/${toString cfg.ipv6PrefixLength}"
|
||||||
|
];
|
||||||
|
routes = [
|
||||||
|
{ Gateway = cfg.ipv4Gateway; }
|
||||||
|
{ Gateway = cfg.ipv6Gateway; }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
networking.nameservers = [ "1.1.1.1" "8.8.8.8" ];
|
||||||
|
}
|
||||||
|
(lib.mkIf cfg.enableDisko {
|
||||||
|
boot.loader.grub.device = cfg.device;
|
||||||
|
|
||||||
|
disko.devices.disk.main = {
|
||||||
|
type = "disk";
|
||||||
|
device = cfg.device;
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
boot = {
|
||||||
|
size = "1M";
|
||||||
|
type = "EF02";
|
||||||
|
priority = 1;
|
||||||
|
};
|
||||||
|
root = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "ext4";
|
||||||
|
mountpoint = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ in {
|
|||||||
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
deploy = pkgs.callPackage ./deploy { inherit inputs; };
|
||||||
element-web = pkgs.callPackage ./element-web {};
|
element-web = pkgs.callPackage ./element-web {};
|
||||||
shellplot = pkgs.callPackage ./shellplot {};
|
shellplot = pkgs.callPackage ./shellplot {};
|
||||||
|
which-country-rs = pkgs.callPackage ./which-country-rs {};
|
||||||
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
|
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
|
||||||
migrator = pkgs.callPackage ./migrator { inherit self; };
|
migrator = pkgs.callPackage ./migrator { inherit self; };
|
||||||
"parse-uri" = pkgs.callPackage ./parse-uri {};
|
"parse-uri" = pkgs.callPackage ./parse-uri {};
|
||||||
|
|||||||
Generated
+1939
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "which-country-rs"
|
||||||
|
version = "1.0.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "CLI tool that detects your country by IP and renders an ASCII map"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/krisfur/which-country-rs"
|
||||||
|
keywords = ["cli", "geolocation", "ascii", "map"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["geoip"]
|
||||||
|
geoip = ["reqwest"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.13", features = ["blocking", "json"], optional = true }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Krzysztof Furman
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# which-country-rs
|
||||||
|
|
||||||
|
A CLI tool that detects your country from your IP address and renders an ASCII map zoomed into it, showing neighbouring borders and country codes.
|
||||||
|
|
||||||
|
## Example output
|
||||||
|
|
||||||
|
```bash
|
||||||
|
which-country-rs -c DE
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
You appear to be in: Germany (DE)
|
||||||
|
|
||||||
|
·········· ·· ······ · ·
|
||||||
|
· NO · ·· · ···
|
||||||
|
· · ···· ··· ···········
|
||||||
|
· ····· ·· ············
|
||||||
|
····· ········ · ·· ···EE ·
|
||||||
|
· ······ ······ ·· · LV····· ·····
|
||||||
|
·· ··· ·DK ·· ··· ···· ··········· ···
|
||||||
|
···· ··· ··· · · ··· ···· ··· LT ···· ···
|
||||||
|
· ····· ···· ··· ############ ··················· ···
|
||||||
|
IE ·· ····GB · ·····##### ## ## ···· BY
|
||||||
|
· ·· ·· ··· ·NL # ## ··
|
||||||
|
··· ····· ·· ····· ## # PL ·············
|
||||||
|
····················BE ··# DE #######····· ····
|
||||||
|
· ······· ···L## ### CZ ····· ······
|
||||||
|
······· #### ###·········SK ······· ··
|
||||||
|
····· ·###### ###### AT ········ ···············
|
||||||
|
·· FR ··· CH ··#·············· HU ·· MD··
|
||||||
|
· · ····· · ····SI··· ········· RO ····
|
||||||
|
· · · ···HR········ RS···· ···
|
||||||
|
········ ·· · ········ IT ··· ·····BA ·· ··············
|
||||||
|
· ·· ········· ·········· ·· · · ···ME··XK···· ··
|
||||||
|
····· ··· · ·· · ··· ···· ·· ··MK·· ·BG ·····
|
||||||
|
· ·· ····· ··· ····· ···· ·AL············ ······
|
||||||
|
· ·· ES · · · ·· ····· ···· ···· ······
|
||||||
|
|
||||||
|
Coordinates: 51.15°N, 10.55°E
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
# Auto-detect from IP
|
||||||
|
which-country-rs
|
||||||
|
|
||||||
|
# Specify a country code
|
||||||
|
which-country-rs -c JP
|
||||||
|
|
||||||
|
# Specify coordinates (supports negative values for south/west)
|
||||||
|
which-country-rs --lat 40 --lon -74
|
||||||
|
|
||||||
|
# Custom map size
|
||||||
|
which-country-rs -W 120 -H 40
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Flag | Description | Default |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `-W, --width` | Map width in characters | 80 |
|
||||||
|
| `-H, --height` | Map height in characters | 24 |
|
||||||
|
| `-c, --country` | ISO 3166-1 alpha-2 country code (skips IP lookup) | |
|
||||||
|
| `--lat` | Latitude (requires `--lon`) | |
|
||||||
|
| `--lon` | Longitude (requires `--lat`) | |
|
||||||
|
| `-V, --version` | Print version | |
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
To build without IP geolocation support (drops the `reqwest` dependency):
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo build --release --no-default-features
|
||||||
|
```
|
||||||
|
|
||||||
|
## Map data
|
||||||
|
|
||||||
|
Country borders from [Natural Earth](https://www.naturalearthdata.com/) 110m admin-0 countries (public domain).
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
|||||||
|
{ lib, pkgs, ... }: let
|
||||||
|
src = ./.;
|
||||||
|
in
|
||||||
|
pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = "which-country-rs";
|
||||||
|
version = "1.0.0";
|
||||||
|
|
||||||
|
inherit src;
|
||||||
|
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
|
||||||
|
# NOTE(yukkop): upstream uses edition 2024, so do not pass the repo's
|
||||||
|
# Rust 1.81 commonArgs here.
|
||||||
|
# NOTE(yukkop): keep the Nix package offline-by-default; upstream's
|
||||||
|
# default geoip feature calls a plaintext HTTP endpoint.
|
||||||
|
cargoBuildFlags = ["--no-default-features"];
|
||||||
|
cargoTestFlags = ["--no-default-features"];
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "CLI tool that tells you which country contains coordinates";
|
||||||
|
homepage = "https://github.com/krisfur/which-country-rs";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "which-country-rs";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user