From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 8B26B1FF142 for ; Tue, 05 May 2026 09:32:14 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7397817EFE; Tue, 5 May 2026 09:32:14 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Subject: [PATCH yew-widget-toolkit v2 3/3] widget: charts: add WorldMap with GeoJSON rendering Date: Tue, 5 May 2026 09:31:54 +0200 Message-ID: <20260505073203.398548-4-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260505073203.398548-1-d.csapak@proxmox.com> References: <20260505073203.398548-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.000 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs] Message-ID-Hash: PNJ73XAXIT6DMXNGLRXNEH5UBMOLPWRR X-Message-ID-Hash: PNJ73XAXIT6DMXNGLRXNEH5UBMOLPWRR X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This builds on the new Map widget by taking in GeoJSON data, projecting the lines/polygons to the svgs coordinate system and providing that to the Map. In addition it exposes WorldPoint, which is the same as MapPoint, but instead of Coordinates it uses Location which can be simply converted by projecting it to the internal coordinate system. Signed-off-by: Dominik Csapak --- Cargo.toml | 1 + src/widget/charts/mod.rs | 3 + src/widget/charts/world_map.rs | 219 +++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/widget/charts/world_map.rs diff --git a/Cargo.toml b/Cargo.toml index 125867e..6c24f64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,5 +70,6 @@ wasm-bindgen-futures = "0.4" url = "2.1" percent-encoding = "2.1" gettext = "0.4" +geojson = "0.24" pwt-macros = { version = "0.5.2", path = "pwt-macros" } diff --git a/src/widget/charts/mod.rs b/src/widget/charts/mod.rs index f5cd26b..3c95150 100644 --- a/src/widget/charts/mod.rs +++ b/src/widget/charts/mod.rs @@ -8,3 +8,6 @@ pub use map::{ mod pie; pub use pie::{LegendPosition, PieChart}; + +mod world_map; +pub use world_map::{Location, WorldMap, WorldPoint}; diff --git a/src/widget/charts/world_map.rs b/src/widget/charts/world_map.rs new file mode 100644 index 0000000..23ff28e --- /dev/null +++ b/src/widget/charts/world_map.rs @@ -0,0 +1,219 @@ +use std::marker::PhantomData; +use std::rc::Rc; + +use geojson::{GeoJson, Geometry}; +use yew::prelude::*; + +use crate::prelude::*; +use crate::widget::canvas::Path; +use crate::widget::charts::{Coordinates, Map, MapPoint, MapPointData}; +use pwt_macros::{builder, widget}; + +// the constant size of the svg viewport. also use to project lon/lat to svg coordinates) +const WIDTH: f64 = 3600.0; // use 10 units per longitude +const WIDTH_RATIO: f64 = 1.65; // use a ratio that looks a bit more like regular maps +const HEIGHT: f64 = WIDTH / WIDTH_RATIO; + +/// Represents a Location in the world using the geographic coordinate system. +#[derive(Clone, Copy, PartialEq)] +pub struct Location { + pub longitude: f64, + pub latitude: f64, +} + +impl Location { + /// Create a new location from longitude and latitude + pub fn new(longitude: f64, latitude: f64) -> Self { + Self { + longitude, + latitude, + } + } +} + +impl From for Coordinates { + fn from(value: Location) -> Self { + project(value) + } +} + +/// Holds a location and arbitrary data that implements [MapPointData] +/// +/// Can be converted into a [MapPoint] (it's location will be projected to +/// a coordinate system useful for [WorldMap]) +#[derive(Clone, PartialEq)] +pub struct WorldPoint { + pub location: Location, + pub data: T, +} + +impl From> for MapPoint { + fn from(value: WorldPoint) -> Self { + MapPoint { + coordinates: value.location.into(), + data: value.data, + } + } +} + +/// A world map using GeoJSON data to draw SVG lines and polygons. +#[widget(pwt=crate,comp=WorldMapComp, @element)] +#[builder] +#[derive(Properties, PartialEq, Clone)] +pub struct WorldMap { + #[prop_or_default] + /// A list of points to highlight on the map. + points: Vec>, + + map_data: Rc, +} + +impl WorldMap { + /// Creates a new WorldMap, takes the necessary GeoJson as Rc to not copy data unnecessarily + /// around + pub fn new(map_data: Rc) -> Self { + yew::props!(Self { map_data }) + } + + /// Set the points of the map. Converts the List into a list of [MapPoint]. + pub fn set_points(&mut self, points: impl Into>>) { + self.points = points + .into() + .into_iter() + .map(|point| point.into()) + .collect(); + } + + /// Builder style method to set the points of the map. Converts the List into a list of [MapPoint]. + pub fn points(mut self, points: impl Into>>) -> Self { + self.set_points(points); + self + } +} + +pub struct WorldMapComp { + path: String, + _phantom_data: PhantomData, +} + +fn calculate_path(geojson: &GeoJson) -> String { + let mut paths = Vec::new(); + match geojson { + GeoJson::Geometry(geometry) => { + paths.append(&mut parse_geometry(geometry)); + } + GeoJson::Feature(feature) => { + if let Some(geometry) = &feature.geometry { + let mut new_paths = parse_geometry(geometry); + paths.append(&mut new_paths); + } + } + GeoJson::FeatureCollection(feature_collection) => { + for f in feature_collection { + if let Some(geometry) = &f.geometry { + let mut new_paths = parse_geometry(geometry); + paths.append(&mut new_paths); + } + } + } + } + paths.join(" ") +} + +impl yew::Component for WorldMapComp { + type Message = (); + type Properties = WorldMap; + + fn create(ctx: &Context) -> Self { + let path = calculate_path(&ctx.props().map_data); + Self { + path, + _phantom_data: PhantomData::, + } + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + + Map::new( + Path::new() + .d(self.path.clone()) + .style("vector-effect", "non-scaling-stroke") + .style("stroke-width", "0.2px") + .style("fill", "var(--pwt-color-neutral)") + .style("stroke", "var(--pwt-color-on-neutral)"), + ) + .with_std_props(&props.std_props) + .listeners(&props.listeners) + .style("background-color", "var(--pwt-color-surface)") + .width(WIDTH) + .height(HEIGHT) + .points(props.points.clone()) + .into() + } + + fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { + let props = ctx.props(); + if props.map_data != old_props.map_data { + self.path = calculate_path(&props.map_data); + } + true + } +} + +// equirectangular projection +// +// assume each degree takes the same amount of space in both dimensions +fn project(location: Location) -> Coordinates { + Coordinates { + x: (location.longitude + 180.0) * (WIDTH / 360.0), + y: (90.0 - location.latitude) * (HEIGHT / 180.0), + } +} + +fn ring_to_path(coordinates: &[Vec]) -> String { + let mut path = line_to_path(coordinates); + path.push('Z'); + path +} + +fn line_to_path(coordinates: &[Vec]) -> String { + let mut path = String::new(); + let mut prefix = "M"; + for list in coordinates { + if list.len() < 2 { + continue; + } + let Coordinates { x, y } = project(Location::new(list[0], list[1])); + path.push_str(&format!("{prefix}{:.2},{:.2}", x, y)); + prefix = "L"; + } + path +} + +fn parse_geometry(geometry: &Geometry) -> Vec { + let mut paths = Vec::new(); + + match &geometry.value { + geojson::Value::Polygon(polygon) => { + paths.append(&mut polygon.iter().map(|ring| ring_to_path(ring)).collect()); + } + geojson::Value::MultiPolygon(items) => { + for poly in items { + paths.append(&mut poly.iter().map(|ring| ring_to_path(ring)).collect()) + } + } + geojson::Value::LineString(line) => paths.push(line_to_path(line)), + geojson::Value::MultiLineString(line) => { + paths.append(&mut line.iter().map(|ring| line_to_path(ring)).collect()) + } + geojson::Value::GeometryCollection(items) => { + for geom in items { + paths.append(&mut parse_geometry(geom)); + } + } + geojson::Value::Point(_) => {} + geojson::Value::MultiPoint(_) => {} + } + paths +} -- 2.47.3