diff --git a/Cargo.lock b/Cargo.lock index 1734c68..d6d66a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "toml", "toml_edit", "wax", + "xxhash-rust", ] [[package]] @@ -736,6 +737,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "xxhash-rust" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927da81e25be1e1a2901d59b81b37dd2efd1fc9c9345a55007f09bf5a2d3ee03" + [[package]] name = "zerocopy" version = "0.7.32" diff --git a/Cargo.toml b/Cargo.toml index ad675ef..48c53b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ toml = "0.8.12" toml_edit = "0.22.12" wax = "0.6.0" +xxhash-rust = { version = "0.8.10", features = ["xxh3"] } + [[bin]] name = "cimengine" path = "src/main.rs" diff --git a/src/build.rs b/src/build.rs new file mode 100644 index 0000000..04e2072 --- /dev/null +++ b/src/build.rs @@ -0,0 +1,60 @@ +use std::{fs, path::Path}; + +use geojson::{Feature, FeatureCollection}; +use serde_json::json; +use wax::Glob; + +use crate::{ + types::CountryData, + utils::{get_country, read_config}, +}; + +pub fn build() { + let config = read_config(); + + for processing_item in config.processing { + let mut features: Vec = vec![]; + + let out_folder = Path::new(&processing_item.output_folder); + + let mut countries: Vec = vec![]; + + for country_id in &config.main.layers { + let mut country = get_country(country_id.to_owned()); + + // TODO: Add tags support + + features.append(&mut country.geo.features); + countries.push(country); + } + + // TODO: Add country_rewrite support + // TODO: let countries = vec![rewrite_info]; + + // TODO: Add nature support + + let feature_collection = FeatureCollection { + bbox: None, + features, + foreign_members: None, + } + .to_string(); + + let countries = serde_json::to_string(&serde_json::Map::from_iter( + countries + .iter() + .map(|country| (country.id.clone(), json!(country.config))), + )) + .unwrap(); + + fs::create_dir_all(out_folder).unwrap(); + + fs::write(out_folder.join("geo.geojson"), feature_collection).unwrap(); + fs::write(out_folder.join("countries.json"), countries).unwrap(); + + if let Some(public) = processing_item.public { + let public = serde_json::to_string(&public).unwrap(); + fs::write(out_folder.join("public.json"), public).unwrap(); + } + } +} diff --git a/src/build/mod.rs b/src/build/mod.rs deleted file mode 100644 index 353be42..0000000 --- a/src/build/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub fn build() {} diff --git a/src/main.rs b/src/main.rs index eecd53e..10b9283 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod build; mod init; mod new; mod types; +mod utils; use types::Commands; diff --git a/src/new.rs b/src/new.rs index 6cef4f9..44dd799 100644 --- a/src/new.rs +++ b/src/new.rs @@ -1,7 +1,10 @@ use std::{fs, path::Path}; use toml_edit::{value, DocumentMut, Value}; -use crate::types::{Config, Country, NewCommands}; +use crate::{ + types::{CountryConfig, NewCommands}, + utils::{hash_hex_color, read_config}, +}; pub fn new(cmd: NewCommands) { match cmd { @@ -12,39 +15,37 @@ pub fn new(cmd: NewCommands) { foundation_date, flag, about, + fill, + stroke, } => { let name = name.unwrap_or_default(); let description = description.unwrap_or_default(); let foundation_date = foundation_date.unwrap_or_default(); let flag = flag.unwrap_or_default(); - let about = about; + let fill = fill.unwrap_or_else(|| hash_hex_color(id.clone() + "_fill")); + let stroke = stroke.unwrap_or_else(|| hash_hex_color(id.clone() + "_stroke")); - let country = Country { + let country = CountryConfig { name: name.clone(), description, foundation_date, flag, about, - tags: Some(vec![]), + fill, + stroke, + tags: None, }; let config = fs::read_to_string("config.toml").unwrap(); // Validate config - let c = toml::from_str::(&config); - - match c { - Ok(c) => c, - Err(err) => { - panic!("Invalid config: {}", err); - } - }; + read_config(); // Get actual config let mut config = config.parse::().unwrap(); // Add country to layers - let layers = config["country"]["layers"].clone().into_value().unwrap(); + let layers = config["main"]["layers"].clone().into_value().unwrap(); let layers = if let Value::Array(mut layers) = layers { layers.push(&id); @@ -53,7 +54,7 @@ pub fn new(cmd: NewCommands) { panic!("layers is not an array"); }; - config["country"]["layers"] = value(layers); + config["main"]["layers"] = value(layers); fs::write("config.toml", config.to_string()).unwrap(); diff --git a/src/templates/config.toml b/src/templates/config.toml index 76da029..a12faa3 100644 --- a/src/templates/config.toml +++ b/src/templates/config.toml @@ -1,18 +1,16 @@ -[country] +[main] # Order matters when building countries. This affects the processing of area intersections layers = ["sample_country_id"] [[processing]] -output_folder = "out" -countries_file = "countries.json" -geo_file = "geo.geojson" +output_folder = "./out/map" -# generate_colors = false # show_markers = false # Information for public repository in cimengine. See: https://github.com/CIMEngine/MapList # [processing.public] # name = "Sample Map" +# description = "This is a sample map" # geo = "https://example.com/geo.geojson" # countries = "https://example.com/countries.json" @@ -22,9 +20,14 @@ geo_file = "geo.geojson" # include = [] # [] = not filtered # exclude = ["test", "test2"] +# Rewrite properties of all countries. All fields are optional. # [processing.countries_rewrite] # name = "name" # color = "#000000" +# description = "description" +# foundation_date = "2024-01-01" +# flag = "https://example.com/flag.png" +# about = "https://example.com/about.html" # Nature layers [[nature]] diff --git a/src/templates/country.toml b/src/templates/country.toml index d02190e..1beb7a9 100644 --- a/src/templates/country.toml +++ b/src/templates/country.toml @@ -2,4 +2,8 @@ name = "Sample Country" description = "This is a sample country" foundation_date = "2024-01-01" flag = "https://example.com/flag.png" -about = "https://example.com/about.html" +fill = "#000000" +stroke = "#000000" + +# about = "https://example.com/about.html" +# tags = ["test", "test2"] diff --git a/src/types.rs b/src/types.rs index 3f07025..a216775 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,8 @@ use clap::{Parser, Subcommand}; +use geo::{BoundingRect, Geometry, MultiPolygon, Point, Polygon}; +use geojson::{Feature, FeatureCollection, Value}; use serde::{Deserialize, Serialize}; +use serde_json::json; #[derive(Debug, Parser)] #[command(name = "cimengine", bin_name = "cimengine")] @@ -37,71 +40,156 @@ pub enum NewCommands { /// Create new country Country { id: String, - #[clap(short, long)] + #[clap(long)] name: Option, - #[clap(short, long)] + #[clap(long)] description: Option, - #[clap(short, long)] + #[clap(long)] foundation_date: Option, #[clap(long)] flag: Option, - #[clap(short, long)] + #[clap(long)] about: Option, + #[clap(long)] + fill: Option, + #[clap(long)] + stroke: Option, }, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Config { - country: CountryConfig, - processing: Vec, + pub main: MainConfig, + pub processing: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct CountryConfig { - layers: Vec, +pub struct MainConfig { + pub layers: Vec, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ProcessingConfig { - generate_colors: Option, - show_markers: Option, - output_folder: String, - countries_file: String, - geo_file: String, + pub show_markers: Option, + pub output_folder: String, - tags: Option, - countries_rewrite: Option, - public: Option, + pub tags: Option, + pub countries_rewrite: Option, + pub public: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ProcessingTagsConfig { - include: Option>, - exclude: Option>, + pub include: Option>, + pub exclude: Option>, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CountryRewriteConfig { - name: Option, - description: Option, - foundation_date: Option, - flag: Option, - about: Option, + pub name: Option, + pub description: Option, + pub foundation_date: Option, + pub flag: Option, + pub about: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct PublicConfig { - name: String, - geo: String, - countries: String, + pub name: String, + pub description: String, + pub geo: String, + pub countries: String, } #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Country { +pub struct CountryConfig { pub name: String, pub description: String, pub foundation_date: String, pub flag: String, + pub fill: String, + pub stroke: String, pub about: Option, pub tags: Option>, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CountryData { + pub id: String, + pub config: CountryConfig, + pub geo: FeatureCollection, +} + +pub struct Marker { + pub coordinates: Point, + pub title: String, + pub description: String, + pub ty: MarkerType, +} + +impl Marker { + pub fn to_feature(&self) -> geojson::Feature { + geojson::Feature { + geometry: Some(geojson::Geometry::new(Value::Point(vec![ + self.coordinates.x(), + self.coordinates.y(), + ]))), + properties: Some( + serde_json::Map::from_iter([ + ("title".to_owned(), json!(self.title)), + ("description".to_owned(), json!(self.description)), + ("marker-type".to_owned(), json!(self.ty.to_str())), + ]) + .into(), + ), + + bbox: None, + id: None, + foreign_members: None, + } + } +} + +pub enum MarkerType { + Capital, + City, + Landmark, +} + +impl MarkerType { + pub fn to_str(&self) -> &'static str { + match self { + MarkerType::Capital => "capital", + MarkerType::City => "city", + MarkerType::Landmark => "landmark-0", + } + } +} + +pub enum Territory { + Polygon(Polygon), + MultiPolygon(MultiPolygon), +} + +impl Territory { + pub fn to_feature(&self) -> geojson::Feature { + match self { + Territory::Polygon(p) => geojson::Feature { + geometry: Some(geojson::Geometry::new(p.into())), + properties: None, + + bbox: None, + id: None, + foreign_members: None, + }, + Territory::MultiPolygon(mp) => geojson::Feature { + geometry: Some(geojson::Geometry::new(mp.into())), + properties: None, + + bbox: None, + id: None, + foreign_members: None, + }, + } + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f6f21f3 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,134 @@ +use std::{collections::HashMap, fs, path::Path}; + +use geo::{BooleanOps, Geometry, MultiPolygon}; +use geojson::{Feature, FeatureCollection, GeoJson}; +use serde_json::json; + +use crate::types::{Config, CountryConfig, CountryData, Marker, MarkerType, Territory}; + +pub fn read_config() -> Config { + let c = toml::from_str::(&fs::read_to_string("config.toml").unwrap()); + + match c { + Ok(c) => c, + Err(err) => panic!("Invalid config: {}", err), + } +} + +pub fn get_country(id: String) -> CountryData { + let country_folder = Path::new(".").join("countries").join(&id); + + let config = toml::from_str::( + &fs::read_to_string(country_folder.join("country.toml")).unwrap(), + ); + + let config = match config { + Ok(c) => c, + Err(err) => panic!("Invalid config: {}", err), + }; + + let geo_str = fs::read_to_string(country_folder.join("country.geojson")).unwrap(); + let geo: GeoJson = geo_str.parse().unwrap(); + + let geo = match geo { + GeoJson::FeatureCollection(coll) => coll, + _ => panic!("Invalid geojson, expected FeatureCollection"), + }; + + CountryData { + id: id.clone(), + config: config.clone(), + geo: dissolve_territory(geo, id, config), + } +} + +pub fn dissolve_territory( + geo: FeatureCollection, + id: String, + config: CountryConfig, +) -> FeatureCollection { + let mut markers: Vec = vec![]; + let mut territories: Vec = vec![]; + + geo.features.iter().for_each(|f| { + let properties = f.properties.clone().unwrap(); + + let geometry: Geometry = f.geometry.clone().unwrap().try_into().unwrap(); + + match geometry { + Geometry::Point(p) => { + let ty = match properties + .get("type") + .expect("Missing marker type") + .to_string() + .trim_matches('"') + { + "capital" | "capital-city" => MarkerType::Capital, + "city" => MarkerType::City, + "landmark" => MarkerType::Landmark, + + t => panic!("Invalid marker type: {}", t), + }; + + markers.push(Marker { + coordinates: p, + title: properties + .get("title") + .expect("Missing marker title") + .to_string(), + description: properties + .get("description") + .unwrap_or(&json!("")) + .to_string(), + ty, + }) + } + + Geometry::MultiPolygon(mp) => territories.push(Territory::MultiPolygon(mp)), + + Geometry::Polygon(p) => territories.push(Territory::Polygon(p)), + + _ => panic!("Unexpected geometry type"), + } + }); + + let territories = territories.iter(); + + let dissolved = territories.fold(MultiPolygon::new(vec![]), |a, b| match b { + Territory::Polygon(p) => a.union(&MultiPolygon::new(vec![p.clone()])), + Territory::MultiPolygon(mp) => a.union(mp), + }); + + // combine markers and dissolved + let mut features: Vec = markers.iter().map(|m| m.to_feature()).collect(); + + features.push(geojson::Feature { + geometry: Some(geojson::Geometry::new((&dissolved).into())), + properties: Some( + serde_json::Map::from_iter([ + ("id".to_owned(), json!(id)), + ("type".to_owned(), json!("country")), + ("fill".to_owned(), json!(config.fill)), + ("stroke".to_owned(), json!(config.stroke)), + ("tags".to_owned(), json!(config.tags)), + ]) + .into(), + ), + + bbox: None, + id: None, + foreign_members: None, + }); + + FeatureCollection { + features, + bbox: None, + foreign_members: None, + } +} + +pub fn hash_hex_color(s: String) -> String { + let hex_str = format!("{:x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes())); + + format!("#{:6}", hex_str.chars().take(6).collect::()) +}