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
+134
View File
@@ -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 = "/";
};
};
};
};
};
})
]);
}
+1
View File
@@ -149,6 +149,7 @@ in {
deploy = pkgs.callPackage ./deploy { inherit inputs; };
element-web = pkgs.callPackage ./element-web {};
shellplot = pkgs.callPackage ./shellplot {};
which-country-rs = pkgs.callPackage ./which-country-rs {};
onlinepubs2man = pkgs.callPackage ./onlinepubs2man {};
migrator = pkgs.callPackage ./migrator { inherit self; };
"parse-uri" = pkgs.callPackage ./parse-uri {};
+1939
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
+21
View File
@@ -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.
+83
View File
@@ -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
+25
View File
@@ -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";
};
}
+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;
}
}
}