public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
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	[thread overview]
Message-ID: <20260505073203.398548-4-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260505073203.398548-1-d.csapak@proxmox.com>

This builds on the new Map<T> 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 <d.csapak@proxmox.com>
---
 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<Location> 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<T: MapPointData> {
+    pub location: Location,
+    pub data: T,
+}
+
+impl<T: MapPointData> From<WorldPoint<T>> for MapPoint<T> {
+    fn from(value: WorldPoint<T>) -> 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<T>, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct WorldMap<T: MapPointData + 'static> {
+    #[prop_or_default]
+    /// A list of points to highlight on the map.
+    points: Vec<MapPoint<T>>,
+
+    map_data: Rc<GeoJson>,
+}
+
+impl<T: MapPointData> WorldMap<T> {
+    /// Creates a new WorldMap, takes the necessary GeoJson as Rc to not copy data unnecessarily
+    /// around
+    pub fn new(map_data: Rc<GeoJson>) -> 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<Vec<WorldPoint<T>>>) {
+        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<Vec<WorldPoint<T>>>) -> Self {
+        self.set_points(points);
+        self
+    }
+}
+
+pub struct WorldMapComp<T: MapPointData> {
+    path: String,
+    _phantom_data: PhantomData<T>,
+}
+
+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<T: MapPointData + 'static> yew::Component for WorldMapComp<T> {
+    type Message = ();
+    type Properties = WorldMap<T>;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let path = calculate_path(&ctx.props().map_data);
+        Self {
+            path,
+            _phantom_data: PhantomData::<T>,
+        }
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> 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<Self>, 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<f64>]) -> String {
+    let mut path = line_to_path(coordinates);
+    path.push('Z');
+    path
+}
+
+fn line_to_path(coordinates: &[Vec<f64>]) -> 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<String> {
+    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





  parent reply	other threads:[~2026-05-05  7:32 UTC|newest]

Thread overview: 13+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-05  7:31 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets v2 0/8] add a new map widget for custom views Dominik Csapak
2026-05-05  7:31 ` [PATCH yew-widget-toolkit v2 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-05  7:31 ` [PATCH yew-widget-toolkit v2 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-05  7:31 ` Dominik Csapak [this message]
2026-05-05  7:31 ` [PATCH yew-widget-toolkit-assets v2 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-05  7:31 ` [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config Dominik Csapak
2026-05-05  8:26   ` Thomas Lamprecht
2026-05-05  8:36     ` Dominik Csapak
2026-05-05  8:44       ` Thomas Lamprecht
2026-05-05  8:46         ` Dominik Csapak
2026-05-05  7:31 ` [PATCH datacenter-manager v2 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-05  7:31 ` [PATCH datacenter-manager v2 3/4] ui: add world map geojson update script Dominik Csapak
2026-05-05  7:31 ` [PATCH datacenter-manager v2 4/4] ui: views: add map component Dominik Csapak

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260505073203.398548-4-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal