public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
@ 2026-05-04 12:44 Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
                   ` (8 more replies)
  0 siblings, 9 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

This series let's users add location info to remotes (longitude,latitude) and
makes it possible to show them on a map in a custom view.

This map is interactive, so it let's you zoom/pan/etc. (with touch controls too)

It also adds new status info per remote to the resources status api call
so we can show some sensible info on the map (success/warn/error).

For now this is very basic only, but we can extend that in the future by e.g.,
adding a health call for each remote and including this info here.

The source map data is from naturalearthdata.com (data is in the public domain)
and is converted to geojson with 'ogr2ogr' (small script is included)

I used the lowest resolution data which results in a ~350K json (~130K zipped)
which should work in most scenarios (e.g. cpu perf on mid-tier mobiles)

naming/location of api data and configs is open for debate, I'm not really
sure if I found the right places, but they seemed to work out ok.

proxmox-yew-widget-toolkit:

Dominik Csapak (3):
  js-helper: add client-to-svg-coordinate conversion helper
  widget: charts: add interactive Map with zoom/pan and clustering
  widget: charts: add WorldMap with GeoJSON rendering

 Cargo.toml                         |   1 +
 js-helper-module.js                |   7 +
 src/lib.rs                         |   5 +
 src/widget/charts/map/map_point.rs | 116 +++++++
 src/widget/charts/map/mod.rs       | 537 +++++++++++++++++++++++++++++
 src/widget/charts/map/zoom_info.rs | 192 +++++++++++
 src/widget/charts/mod.rs           |   9 +
 src/widget/charts/world_map.rs     | 219 ++++++++++++
 8 files changed, 1086 insertions(+)
 create mode 100644 src/widget/charts/map/map_point.rs
 create mode 100644 src/widget/charts/map/mod.rs
 create mode 100644 src/widget/charts/map/zoom_info.rs
 create mode 100644 src/widget/charts/world_map.rs


proxmox-yew-widget-toolkit-assets:

Dominik Csapak (1):
  charts: add necessary classes for Map

 scss/_charts.scss | 63 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 63 insertions(+)


proxmox-datacenter-manager:

Dominik Csapak (4):
  lib/api/ui: add location property to remote config
  lib/api: add new 'remote-list' info to the resource status
  ui: add world map geojson data and update script
  ui: views: add map component

 lib/pdm-api-types/src/remotes.rs  |  22 ++-
 lib/pdm-api-types/src/resource.rs |  49 ++++++
 lib/pdm-api-types/src/views.rs    |   2 +
 server/src/api/pbs/mod.rs         |   2 +
 server/src/api/pve/mod.rs         |   2 +
 server/src/api/remotes/mod.rs     |   9 +
 server/src/api/resources.rs       |  38 +++-
 ui/Cargo.toml                     |   1 +
 ui/Makefile                       |   6 +-
 ui/index.html                     |   1 +
 ui/src/dashboard/map.rs           | 281 ++++++++++++++++++++++++++++++
 ui/src/dashboard/mod.rs           |   3 +
 ui/src/dashboard/view.rs          |  10 +-
 ui/src/dashboard/view/row_view.rs |   1 +
 ui/src/remotes/edit_remote.rs     |  34 +++-
 ui/update-world-map.sh            |  15 ++
 ui/world-map.json                 |   9 +
 17 files changed, 468 insertions(+), 17 deletions(-)
 create mode 100644 ui/src/dashboard/map.rs
 create mode 100755 ui/update-world-map.sh
 create mode 100644 ui/world-map.json


Summary over all repositories:
  26 files changed, 1617 insertions(+), 17 deletions(-)

-- 
Generated by git-murpp 0.8.1




^ permalink raw reply	[flat|nested] 13+ messages in thread

* [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
                   ` (7 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

Doing this in rust would mean we have to expose additional web-sys
features or doing the binding ourselves. Also since error handling for
the necessary code would be longer than the actual two line javascript
code, just have a short helper here.

This will be used in the upcoming Map widget to convert from pointer
position to svg coordinates.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 js-helper-module.js | 7 +++++++
 src/lib.rs          | 5 +++++
 2 files changed, 12 insertions(+)

diff --git a/js-helper-module.js b/js-helper-module.js
index 838c987..53291f3 100644
--- a/js-helper-module.js
+++ b/js-helper-module.js
@@ -31,6 +31,12 @@ function toggle_popover(popover) {
     popover.togglePopover();
 }
 
+function client_to_svg_coords(svg, x, y) {
+    const pt = new DOMPoint(x, y);
+    const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
+    return [svgPt.x, svgPt.y];
+}
+
 export {
     test_alert,
     show_dialog,
@@ -39,4 +45,5 @@ export {
     hide_popover,
     show_popover,
     toggle_popover,
+    client_to_svg_coords,
 };
diff --git a/src/lib.rs b/src/lib.rs
index d3ac6ea..bd51b0d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -297,6 +297,7 @@ extern "C" {
     pub fn show_popover(popover: web_sys::Node);
     pub fn hide_popover(popover: web_sys::Node);
     pub fn toggle_popover(popover: web_sys::Node);
+    pub fn client_to_svg_coords(svg: &web_sys::Node, x: f64, y: f64) -> Vec<f64>;
 }
 
 // Create wrapper which panics if called from target_arch!=wasm32
@@ -329,6 +330,10 @@ mod panic_wrapper {
     pub fn toggle_popover(_popover: web_sys::Node) {
         unreachable!()
     }
+    /// Calculate the svg coordinates from viewport ones
+    pub fn client_to_svg_coords(_svg: &web_sys::Node, _x: f64, _y: f64) -> Vec<f64> {
+        unreachable!()
+    }
 }
 
 // some helpers
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
                   ` (6 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

This exposes a generict Map<T> that takes a SVG element (intended to be
the background map) and draws typed Points (MapPoint<T: MapPointData>)
over it. These can implement various render functions for clustered
points.

The Map handles:

* Interaction (zooming, panning, toggling info cards on the points,
  tooltip, etc.), it also supports touch input (pinch zooming and
  panning).
* Rendering of background and points
* Clustering of points depending on the zoom level (combine points that
  are too close)

Shows an interaction panel in the top right to zoom out/in and show the
whole map.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/widget/charts/map/map_point.rs | 116 +++++++
 src/widget/charts/map/mod.rs       | 537 +++++++++++++++++++++++++++++
 src/widget/charts/map/zoom_info.rs | 192 +++++++++++
 src/widget/charts/mod.rs           |   6 +
 4 files changed, 851 insertions(+)
 create mode 100644 src/widget/charts/map/map_point.rs
 create mode 100644 src/widget/charts/map/mod.rs
 create mode 100644 src/widget/charts/map/zoom_info.rs

diff --git a/src/widget/charts/map/map_point.rs b/src/widget/charts/map/map_point.rs
new file mode 100644
index 0000000..d5bfedd
--- /dev/null
+++ b/src/widget/charts/map/map_point.rs
@@ -0,0 +1,116 @@
+use crate::prelude::*;
+use crate::widget::canvas::{Circle, Group};
+use crate::widget::charts::map::{Coordinates, MapPoint};
+use crate::widget::{container::span, Column};
+
+/// Contains the points rendered together on the map due to clustering,
+/// and suggested properties such as the radius (calculated from clustering)
+/// as well if the cluster is selected and where it's center is.
+#[derive(Clone)]
+pub struct PointsRenderArgs<'a, T: MapPointData> {
+    pub points: &'a [&'a MapPoint<T>],
+    pub center: Coordinates,
+    pub selected: bool,
+    pub suggested_radius: f64,
+}
+
+/// The default renderer for a map cluster.
+pub fn render_point_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Group {
+    Group::new()
+        .class("pwt-map-location")
+        .with_child(
+            Circle::new()
+                .style(
+                    "--pwt-location-radius",
+                    format!("{:.3}px", args.suggested_radius),
+                )
+                .cx(args.center.x)
+                .cy(args.center.y),
+        )
+        .with_optional_child(
+            args.selected.then_some(
+                Circle::new()
+                    .class("pwt-map-location-animated")
+                    .style(
+                        "--pwt-location-radius",
+                        format!("{:.3}px", args.suggested_radius),
+                    )
+                    .cx(args.center.x)
+                    .cy(args.center.y),
+            ),
+        )
+}
+
+/// The default info renderer for a map cluster.
+pub fn render_info_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Html {
+    Column::new()
+        .gap(1)
+        .children(
+            args.points
+                .iter()
+                .map(|point| span(point.data.render_title()).into()),
+        )
+        .into()
+}
+
+/// The default tooltip renderer for a map cluster.
+pub fn render_tooltip_default<T: MapPointData>(args: &PointsRenderArgs<T>) -> Html {
+    let count = args.points.len();
+    if count > 3 {
+        Column::new()
+            .gap(1)
+            .with_child(tr!(
+                "{0} and {1} more",
+                args.points[0].data.render_title(),
+                count - 1
+            ))
+            .into()
+    } else {
+        Column::new()
+            .gap(1)
+            .children(
+                args.points
+                    .iter()
+                    .map(|point| span(point.data.render_title()).into()),
+            )
+            .into()
+    }
+}
+
+pub trait MapPointData: PartialEq + Clone {
+    /// Get the title of the map point
+    fn render_title(&self) -> AttrValue;
+
+    /// Render the map icon for a cluster of points.
+    ///
+    /// Uses [render_point_default] by default.
+    fn render_point(args: &PointsRenderArgs<Self>) -> Group {
+        render_point_default(args)
+    }
+
+    /// Render the info box for a cluster of points.
+    ///
+    /// Uses [render_info_default] by default.
+    fn render_info(args: &PointsRenderArgs<Self>) -> Html {
+        render_info_default(args)
+    }
+
+    /// Render the tooltip for a cluster of points.
+    ///
+    /// Uses [render_tooltip_default] by default.
+    fn render_tooltip(args: &PointsRenderArgs<Self>) -> Html {
+        render_tooltip_default(args)
+    }
+}
+
+impl MapPointData for AttrValue {
+    fn render_title(&self) -> AttrValue {
+        self.clone()
+    }
+}
+
+impl MapPointData for String {
+    fn render_title(&self) -> AttrValue {
+        self.clone().into()
+    }
+}
diff --git a/src/widget/charts/map/mod.rs b/src/widget/charts/map/mod.rs
new file mode 100644
index 0000000..04e59c2
--- /dev/null
+++ b/src/widget/charts/map/mod.rs
@@ -0,0 +1,537 @@
+mod map_point;
+pub use map_point::{
+    MapPointData, PointsRenderArgs, render_info_default, render_point_default,
+    render_tooltip_default,
+};
+
+mod zoom_info;
+use zoom_info::ZoomInfo;
+
+use std::marker::PhantomData;
+
+use crate::dom::align::{AlignOptions, align_to, align_to_xy};
+use crate::prelude::*;
+use crate::touch::{GestureDetector, GestureDragEvent, GesturePhase, GesturePinchZoomEvent};
+use crate::widget::canvas::{Canvas, Circle, Group};
+use crate::widget::charts::map::zoom_info::ZoomAction;
+use crate::widget::{Button, Card, Container, Row, SizeObserver, Tooltip};
+use crate::{client_to_svg_coords, css};
+use pwt_macros::{builder, widget};
+
+// the maximum level of zoom that is allowed
+const ZOOM_LEVEL_MAX: f64 = 30.0;
+const RADIUS: f64 = 8.0;
+
+/// x and y coordinates to represent a position
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Coordinates {
+    pub x: f64,
+    pub y: f64,
+}
+
+/// Represents a point on the map.
+#[derive(Clone, PartialEq)]
+pub struct MapPoint<T> {
+    /// The coordinates of the point on a [Map].
+    pub coordinates: Coordinates,
+    pub data: T,
+}
+
+impl<T> MapPoint<T> {
+    /// Create a new [MapPoint] with the given data and coordinates.
+    pub fn new(coordinates: Coordinates, data: T) -> Self {
+        Self { coordinates, data }
+    }
+}
+
+/// An interactive Map that handles interaction (zooming, panning) and draws
+/// [MapPoint]s on top of the given SVG element.
+///
+/// Can handle touch and mouse input.
+#[widget(pwt=crate,comp=MapComp<T>, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct Map<T: MapPointData + 'static = AttrValue> {
+    /// The map as an svg element
+    map: Html,
+
+    #[prop_or(1000.0)]
+    #[builder]
+    /// The width of the shown map. Used for coordinates and scaling.
+    width: f64,
+
+    #[prop_or(1000.0)]
+    #[builder]
+    /// The height of the shown map. Used for coordinates and scaling.
+    height: f64,
+
+    #[prop_or_default]
+    #[builder]
+    /// A list of points to highlight on the map.
+    points: Vec<MapPoint<T>>,
+}
+
+impl<T: MapPointData> Map<T> {
+    /// Creates a new interactive map with the given background SVG map element
+    pub fn new(map: impl Into<Html>) -> Self {
+        yew::props!(Self { map: map.into() })
+    }
+}
+
+pub enum Msg {
+    WheelZoom(ZoomAction, i32, i32),
+    ButtonZoom(ZoomAction),
+    PinchZoom(GesturePinchZoomEvent),
+    Resize(f64, f64),
+    Tooltip(Option<(usize, i32, i32)>),
+    ToggleInfo(usize),
+    Drag(GestureDragEvent),
+}
+
+#[derive(PartialEq, Clone)]
+// cache to hold the clustering of the points, can change when the zoom or the fit scale changes
+struct Cluster {
+    center: Coordinates,
+    indices: Vec<usize>,
+}
+
+pub struct MapComp<T: Clone + PartialEq> {
+    zoom: ZoomInfo,
+    pinch_start_scale: f64,
+    pinch_last_center: Coordinates,
+    fit_scale: f64,
+    svg_ref: NodeRef,
+    tooltip: Option<(usize, i32, i32)>,
+    tooltip_ref: NodeRef,
+    info_anchor_ref: NodeRef,
+    info_ref: NodeRef,
+    info_visible: Option<usize>,
+    grab_start: Option<(f64, f64)>,
+    clusters: Vec<Cluster>,
+    _phantom_data: PhantomData<T>,
+}
+
+impl<T: MapPointData + 'static> MapComp<T> {
+    fn create_tooltip(&self, args: &PointsRenderArgs<T>) -> Html {
+        Container::new()
+            .attribute("role", "tooltip")
+            .attribute("aria-live", "polite")
+            .attribute("data-show", Some(""))
+            .class("pwt-tooltip")
+            .class("pwt-tooltip-rich")
+            .with_child(T::render_tooltip(args))
+            .into_html_with_ref(self.tooltip_ref.clone())
+    }
+
+    fn create_info(&self, args: &PointsRenderArgs<T>) -> Html {
+        Card::new()
+            .class("pwt-map-info")
+            .with_child(T::render_info(args))
+            .into_html_with_ref(self.info_ref.clone())
+    }
+
+    fn cluster_points(&mut self, ctx: &Context<Self>) {
+        // simple algorithm to find overlapping points and cluster them together
+
+        let points = &ctx.props().points;
+        let mut indices: Vec<usize> = (0..ctx.props().points.len()).collect();
+
+        let effective_radius = (RADIUS) / self.fit_scale;
+        let mut clusters = Vec::new();
+        while let Some(index) = indices.pop() {
+            let base = &points[index];
+            let mut overlapping = Vec::new();
+            let mut non_overlapping = Vec::new();
+            let base_coordinates = self.zoom.map_point(base.coordinates);
+
+            for compare_index in indices.into_iter() {
+                let p = &points[compare_index];
+                let point_coordinates = self.zoom.map_point(p.coordinates);
+                let dx = base_coordinates.x - point_coordinates.x;
+                let dy = base_coordinates.y - point_coordinates.y;
+                if dx * dx + dy * dy < (2.0 * effective_radius).powi(2) {
+                    overlapping.push(compare_index);
+                } else {
+                    non_overlapping.push(compare_index);
+                }
+            }
+            indices = non_overlapping;
+            overlapping.insert(0, index);
+
+            let mut x_center = 0.0;
+            let mut y_center = 0.0;
+            for index in overlapping.iter() {
+                let coordinates = points[*index].coordinates;
+                x_center += coordinates.x;
+                y_center += coordinates.y;
+            }
+            x_center /= overlapping.len() as f64;
+            y_center /= overlapping.len() as f64;
+
+            clusters.push(Cluster {
+                center: Coordinates {
+                    x: x_center,
+                    y: y_center,
+                },
+                indices: overlapping,
+            });
+        }
+
+        if let Some(index) = self.info_visible {
+            // either finds the correct new index to show, or resets the info if
+            // the cluster does not exist anymore
+            self.info_visible = clusters
+                .iter()
+                .position(|indices| *indices == self.clusters[index]);
+        }
+        self.clusters = clusters;
+    }
+}
+
+impl<T: MapPointData + 'static> yew::Component for MapComp<T> {
+    type Message = Msg;
+    type Properties = Map<T>;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let props = ctx.props();
+        let mut zoom = ZoomInfo::new(props.width, props.height, ZOOM_LEVEL_MAX);
+        zoom.zoom_to_points(
+            ctx.props().points.iter().map(|poi| poi.coordinates),
+            2.0 * RADIUS,
+        );
+
+        let mut this = Self {
+            zoom,
+            pinch_start_scale: 1.0,
+            pinch_last_center: Coordinates { x: 0.0, y: 0.0 },
+            svg_ref: NodeRef::default(),
+            fit_scale: 1.0,
+            tooltip: None,
+            tooltip_ref: NodeRef::default(),
+            info_anchor_ref: NodeRef::default(),
+            info_ref: NodeRef::default(),
+            info_visible: None,
+            grab_start: None,
+            clusters: Vec::new(),
+            _phantom_data: PhantomData::<T>,
+        };
+
+        this.cluster_points(ctx);
+
+        this
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        let props = ctx.props();
+        let width = props.width;
+        let height = props.height;
+        match msg {
+            Msg::WheelZoom(change, x, y) => {
+                let Some(svg) = self.svg_ref.get() else {
+                    return false;
+                };
+                let coords = client_to_svg_coords(&svg, x as f64, y as f64);
+                if self.zoom.update_zoom(change, coords[0], coords[1]) {
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::ButtonZoom(change) => {
+                if self.zoom.update_zoom(change, width / 2.0, height / 2.0) {
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::PinchZoom(event) => {
+                let Some(svg) = self.svg_ref.get() else {
+                    return false;
+                };
+                let coords = client_to_svg_coords(
+                    &svg,
+                    (event.point0.x + event.point1.x) as f64 / 2.0,
+                    (event.point0.y + event.point1.y) as f64 / 2.0,
+                );
+                let x = coords[0];
+                let y = coords[1];
+                match event.phase {
+                    GesturePhase::Start => {
+                        self.pinch_start_scale = self.zoom.get_zoom_level();
+                        self.pinch_last_center = Coordinates { x, y };
+                    }
+                    GesturePhase::Update => {
+                        self.zoom
+                            .move_pan(x - self.pinch_last_center.x, y - self.pinch_last_center.y);
+                        self.pinch_last_center = Coordinates { x, y };
+                        self.zoom.update_zoom(
+                            zoom_info::ZoomAction::Scale(self.pinch_start_scale * event.scale),
+                            x,
+                            y,
+                        );
+                    }
+                    GesturePhase::End => return false,
+                }
+            }
+            Msg::Drag(event) => match event.phase {
+                GesturePhase::Start => {
+                    self.grab_start = Some((event.x() as f64, event.y() as f64));
+                }
+                GesturePhase::Update => {
+                    if let Some((start_x, start_y)) = self.grab_start {
+                        let x = event.x() as f64;
+                        let y = event.y() as f64;
+                        self.zoom.move_pan(
+                            (x - start_x) / self.fit_scale,
+                            (y - start_y) / self.fit_scale,
+                        );
+                        self.grab_start = Some((x, y));
+                    }
+                }
+                GesturePhase::End => {
+                    self.grab_start = None;
+                    return false;
+                }
+            },
+            Msg::Resize(real_width, real_height) => {
+                if real_width > 0.0 && real_height > 0.0 {
+                    // use the smaller scale so the whole map fits
+                    self.fit_scale = (real_width / width).min(real_height / height);
+                    self.cluster_points(ctx);
+                }
+            }
+            Msg::Tooltip(index) => {
+                if index.is_none() & self.tooltip.is_none() {
+                    return false;
+                }
+                self.tooltip = index;
+            }
+            Msg::ToggleInfo(index) => {
+                if self.info_visible == Some(index) {
+                    self.info_visible = None;
+                } else {
+                    self.info_visible = Some(index);
+                    if let Some(cluster) = self.clusters.get(index) {
+                        self.zoom.center_point(cluster.center);
+                        self.tooltip = None;
+                    }
+                }
+            }
+        }
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let link = ctx.link();
+        let width = props.width;
+        let height = props.height;
+
+        let effective_radius = RADIUS / self.fit_scale;
+
+        let zoom_level = self.zoom.get_zoom_level();
+        let is_zoomed = zoom_level != 1.0;
+        let fully_zoomed = zoom_level >= ZOOM_LEVEL_MAX;
+
+        let svg = Canvas::new()
+            .onwheel({
+                let link = link.clone();
+                move |event: WheelEvent| {
+                    // don't scroll the remaining page
+                    event.prevent_default();
+                    // ignore delta mode as we zoom in/out in 10% steps later anyway, only the
+                    // direction is relevant here
+                    let (delta, x, y) = (event.delta_y(), event.client_x(), event.client_y());
+                    let action = if delta < 0.0 {
+                        ZoomAction::In
+                    } else {
+                        ZoomAction::Out
+                    };
+                    link.send_message(Msg::WheelZoom(action, x, y));
+                }
+            })
+            .style(
+                "cursor",
+                match (is_zoomed, self.grab_start.is_some()) {
+                    (true, true) => Some("grabbing"),
+                    (true, false) => Some("grab"),
+                    (false, _) => None,
+                },
+            )
+            .class("pwt-map")
+            .attribute("viewBox", format!("0 0 {width} {height}"))
+            .with_child(
+                Group::new()
+                    .with_child(props.map.clone())
+                    .style("transform", self.zoom.get_transform()),
+            );
+
+        let mut points = Group::new();
+        let mut tooltip = None;
+        let mut info = None;
+
+        for (index, cluster) in self.clusters.iter().enumerate() {
+            let len = cluster.indices.len() as f64;
+            let radius_factor = if len > 1.0 {
+                // grow logarithmically but not too big
+                1.0 + len.log2() * 0.3
+            } else {
+                1.0
+            };
+
+            let points_of_interest: Vec<_> = cluster
+                .indices
+                .iter()
+                .map(|index| &ctx.props().points[*index])
+                .collect();
+
+            let center = self.zoom.map_point(cluster.center);
+
+            // don't render points, tooltips and info boxes that are out of bounds
+            if self.zoom.is_out_of_bounds(center) {
+                continue;
+            }
+
+            let args = PointsRenderArgs {
+                points: &points_of_interest,
+                center,
+                selected: self.info_visible == Some(index),
+                suggested_radius: effective_radius * radius_factor,
+            };
+
+            let point = T::render_point(&args)
+                .onpointermove(link.callback(move |event: PointerEvent| {
+                    let x = event.client_x();
+                    let y = event.client_y();
+                    Msg::Tooltip(Some((index, x, y)))
+                }))
+                .onpointerleave(link.callback(move |_| Msg::Tooltip(None)))
+                .onclick(link.callback(move |_| Msg::ToggleInfo(index)));
+            if self.info_visible == Some(index) {
+                points.add_child(point);
+                // ref for info-box
+                points.add_child(
+                    Circle::new()
+                        .cx(center.x)
+                        .cy(center.y)
+                        .r(0)
+                        .into_html_with_ref(self.info_anchor_ref.clone()),
+                );
+                info = Some(self.create_info(&args));
+            } else {
+                points.add_child(point);
+            }
+
+            match &self.tooltip {
+                Some((tooltip_idx, _, _)) if *tooltip_idx == index => {
+                    tooltip = Some(self.create_tooltip(&args));
+                }
+                _ => {}
+            }
+        }
+
+        Container::new()
+            .with_std_props(&props.std_props)
+            .listeners(&props.listeners)
+            .class(css::Display::Block)
+            .with_child(SizeObserver::new(
+                Container::new()
+                    .class(css::Display::Flex)
+                    .class(css::JustifyContent::Center)
+                    .width("100%")
+                    .height("100%")
+                    .with_child(
+                        GestureDetector::new(
+                            svg.with_child(points)
+                                .into_html_with_ref(self.svg_ref.clone()),
+                        )
+                        .on_drag(link.callback(Msg::Drag))
+                        .on_pinch_zoom(link.callback(Msg::PinchZoom)),
+                    ),
+                {
+                    let link = link.clone();
+                    move |(_, _, width, height)| {
+                        link.send_message(Msg::Resize(width, height));
+                    }
+                },
+            ))
+            .with_optional_child(tooltip)
+            .with_optional_child(info)
+            .with_child(
+                Row::new()
+                    .gap(1)
+                    .class("pwt-map-interaction-panel")
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-arrows-alt")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(!is_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::Reset))),
+                        )
+                        .tip(tr!("Show whole map")),
+                    )
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-minus")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(!is_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::Out))),
+                        )
+                        .tip(tr!("Zoom out")),
+                    )
+                    .with_child(
+                        Tooltip::new(
+                            Button::new_icon("fa fa-plus")
+                                .class(css::ColorScheme::Primary)
+                                .disabled(fully_zoomed)
+                                .on_activate(link.callback(|_| Msg::ButtonZoom(ZoomAction::In))),
+                        )
+                        .tip(tr!("Zoom in")),
+                    ),
+            )
+            .into()
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
+        let props = ctx.props();
+        let mut need_clustering = false;
+        if props.width != old_props.width || props.height != old_props.height {
+            self.zoom = ZoomInfo::new(props.width, props.height, ZOOM_LEVEL_MAX);
+            need_clustering = true;
+        }
+        if props.points != old_props.points {
+            need_clustering = true;
+        }
+
+        if need_clustering {
+            self.cluster_points(ctx);
+        }
+        true
+    }
+
+    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
+        if let &Some((_, x, y)) = &self.tooltip
+            && let Some(el) = self.tooltip_ref.get()
+        {
+            let _ = align_to_xy(
+                el,
+                (x as f64 + 10.0, y as f64 + 10.0),
+                crate::dom::align::Point::TopStart,
+            );
+        }
+        if let (Some(_), Some(anchor), Some(el)) = (
+            self.info_visible,
+            self.info_anchor_ref.get(),
+            self.info_ref.get(),
+        ) {
+            let _ = align_to(
+                anchor,
+                el,
+                Some(
+                    AlignOptions::new(
+                        crate::dom::align::Point::Top,
+                        crate::dom::align::Point::Bottom,
+                        crate::dom::align::GrowDirection::None,
+                    )
+                    .offset(0.0, RADIUS * 2.0),
+                ),
+            );
+        }
+    }
+}
diff --git a/src/widget/charts/map/zoom_info.rs b/src/widget/charts/map/zoom_info.rs
new file mode 100644
index 0000000..5a22637
--- /dev/null
+++ b/src/widget/charts/map/zoom_info.rs
@@ -0,0 +1,192 @@
+use crate::widget::charts::map::Coordinates;
+
+const ZOOMING_RATIO: f64 = 1.1;
+
+/// Represents a zooming state
+pub struct ZoomInfo {
+    pan: (f64, f64),
+    user_scale: f64,
+    width: f64,
+    height: f64,
+    max_zoom_level: f64,
+}
+
+/// The zoom action a user can take.
+pub enum ZoomAction {
+    /// Zooms in by a fixed ratio
+    In,
+    /// Zooms out by a fixed ratio
+    Out,
+    /// Resets the zoom level to 1.0
+    Reset,
+    /// Tries to zoom to this level
+    Scale(f64),
+}
+
+impl ZoomInfo {
+    /// Creates a new ZoomInfo instance.
+    pub fn new(width: f64, height: f64, max_zoom_level: f64) -> Self {
+        Self {
+            pan: (0.0, 0.0),
+            user_scale: 1.0,
+            width,
+            height,
+            max_zoom_level,
+        }
+    }
+
+    /// Updates the zoom to or from the given center point.
+    /// Returns true if the scale changed, false otherwise.
+    pub fn update_zoom(&mut self, change: ZoomAction, center_x: f64, center_y: f64) -> bool {
+        let point_x = (center_x - self.pan.0) / self.user_scale;
+        let point_y = (center_y - self.pan.1) / self.user_scale;
+
+        let old_scale = self.user_scale;
+        self.update_scale(change);
+        if old_scale == self.user_scale {
+            return false;
+        }
+
+        self.update_pan(
+            center_x - point_x * self.user_scale,
+            center_y - point_y * self.user_scale,
+        );
+
+        true
+    }
+
+    fn update_pan(&mut self, x: f64, y: f64) {
+        self.pan = (
+            x.max(self.width - self.width * self.user_scale).min(0.0),
+            y.max(self.height - self.height * self.user_scale).min(0.0),
+        );
+    }
+
+    /// Pans the view around by the given diff
+    pub fn move_pan(&mut self, diff_x: f64, diff_y: f64) {
+        self.update_pan(self.pan.0 + diff_x, self.pan.1 + diff_y);
+    }
+
+    fn update_scale(&mut self, change: ZoomAction) {
+        self.user_scale = match change {
+            ZoomAction::In => self.user_scale * ZOOMING_RATIO,
+            ZoomAction::Out => self.user_scale / ZOOMING_RATIO,
+            ZoomAction::Reset => 1.0,
+            ZoomAction::Scale(scale) => scale,
+        }
+        .clamp(1.0, self.max_zoom_level);
+    }
+
+    /// Returns the transform string that pans and scales appropriately.
+    pub fn get_transform(&self) -> String {
+        format!(
+            "translate({:.3}px, {:.3}px) scale({:.3})",
+            self.pan.0, self.pan.1, self.user_scale
+        )
+    }
+
+    /// Maps a coordinate of the original size, to the current zoomed/panned view.
+    pub fn map_point(&self, coordinates: Coordinates) -> Coordinates {
+        let x = coordinates.x * self.user_scale + self.pan.0;
+        let y = coordinates.y * self.user_scale + self.pan.1;
+        Coordinates { x, y }
+    }
+
+    fn zoomed_size(&self) -> (f64, f64) {
+        (self.width / self.user_scale, self.height / self.user_scale)
+    }
+
+    /// Centers the view at the given point
+    pub fn center_point(&mut self, coordinates: Coordinates) {
+        let (width, height) = self.zoomed_size();
+        self.update_pan(
+            -(coordinates.x - width / 2.0) * self.user_scale,
+            -(coordinates.y - height / 2.0) * self.user_scale,
+        );
+    }
+
+    fn zoom_to_point(&mut self, coordinates: Coordinates, width: f64) {
+        self.update_scale(ZoomAction::Scale(self.width / width));
+        self.center_point(coordinates);
+    }
+
+    /// Changes the scale and pan so that all points of the list are included including the padding
+    pub fn zoom_to_points(&mut self, points: impl IntoIterator<Item = Coordinates>, padding: f64) {
+        let points = points.into_iter();
+
+        let mut x_min = f64::INFINITY;
+        let mut x_max = f64::NEG_INFINITY;
+        let mut y_min = f64::INFINITY;
+        let mut y_max = f64::NEG_INFINITY;
+
+        let mut got_points = false;
+
+        for Coordinates { x, y } in points {
+            got_points = true;
+            if x > x_max {
+                x_max = x;
+            }
+            if y > y_max {
+                y_max = y;
+            }
+            if x < x_min {
+                x_min = x;
+            }
+            if y < y_min {
+                y_min = y;
+            }
+        }
+
+        if !got_points {
+            return;
+        }
+
+        let width = x_max - x_min + 2.0 * padding;
+        let height = y_max - y_min + 2.0 * padding;
+
+        let mid_point = Coordinates {
+            x: (x_min + x_max) / 2.0,
+            y: (y_min + y_max) / 2.0,
+        };
+
+        self.zoom_to_point(mid_point, width.max(height * self.width / self.height));
+    }
+
+    /// tests if the given point is visible in the current
+    pub fn is_out_of_bounds(&self, coordinates: Coordinates) -> bool {
+        !(0.0..=self.width).contains(&coordinates.x)
+            || !(0.0..=self.height).contains(&coordinates.y)
+    }
+
+    /// Returns the current zoom level
+    pub fn get_zoom_level(&self) -> f64 {
+        self.user_scale
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::ZoomInfo;
+    use crate::widget::charts::{map::zoom_info::ZoomAction, Coordinates};
+
+    #[test]
+    fn test_zooming() {
+        let mut zoom = ZoomInfo::new(200.0, 200.0, 100.0);
+        let coordinate = Coordinates { x: 100.0, y: 100.0 };
+        assert_eq!(zoom.map_point(coordinate), coordinate);
+        zoom.update_zoom(ZoomAction::In, 50.0, 50.0);
+        assert_eq!(
+            zoom.map_point(coordinate),
+            Coordinates { x: 105.0, y: 105.0 }
+        );
+
+        assert_eq!(zoom.get_zoom_level(), 1.1);
+        zoom.update_zoom(ZoomAction::Scale(10.0), 0.0, 0.0);
+
+        assert_eq!(zoom.get_zoom_level(), 10.0);
+
+        zoom.zoom_to_point(Coordinates { x: 50.0, y: 50.0 }, 100.0);
+
+        assert_eq!(zoom.get_zoom_level(), 2.0);
+    }
+}
diff --git a/src/widget/charts/mod.rs b/src/widget/charts/mod.rs
index 42491f7..f5cd26b 100644
--- a/src/widget/charts/mod.rs
+++ b/src/widget/charts/mod.rs
@@ -1,4 +1,10 @@
 //! Chart components
 
+mod map;
+pub use map::{
+    render_info_default, render_point_default, render_tooltip_default, Coordinates, Map, MapPoint,
+    MapPointData, PointsRenderArgs,
+};
+
 mod pie;
 pub use pie::{LegendPosition, PieChart};
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map Dominik Csapak
                   ` (5 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

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





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (2 preceding siblings ...)
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config Dominik Csapak
                   ` (4 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

these classes are used by the new Map<T> widget.

The map contains several widget that need some styling:
* map info: we need to set the z-index and reset transition in case this
  is set somewhere.

* the map itself: needs to disable touch actions and set it's width and
  height to 100%

* the map-location: is a circle with a color specified via the
  'pwt-location-color' variable (so it's easier to set in a nested
  element)

* the interaction-panel: is placed on the top right

* the location-pulse: a small animation that represents loading and
  highlights a selected element.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 scss/_charts.scss | 63 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 63 insertions(+)

diff --git a/scss/_charts.scss b/scss/_charts.scss
index 0f5e87e..ad51b0e 100644
--- a/scss/_charts.scss
+++ b/scss/_charts.scss
@@ -12,3 +12,66 @@
 .pwt-legend-color {
     color: var(--pwt-legend-color)
 }
+
+/// Class `pwt-map-info`
+///
+/// This is used for the info panel when a point is clicked on a map
+.pwt-map-info {
+    transition: 0s;
+    z-index: 100;
+}
+
+/// Class `pwt-map`
+///
+/// This is used for the base SVG element of the map
+.pwt-map {
+    width: 100%;
+    height: 100%;
+    touch-action: none;
+    display: block;
+}
+
+/// Class `pwt-location`
+///
+/// This is used for a location displayed on a `Map`
+/// It uses the `--pwt-location-color` so it can be easily
+/// overwritten via css.'
+.pwt-map-location {
+    --pwt-location-color: var(--pwt-color-primary);
+    cursor: pointer;
+
+    & > circle {
+        fill: color-mix(in lab, var(--pwt-location-color), transparent 80%);
+        stroke: var(--pwt-location-color);
+        stroke-width: 2px;
+        r: calc(var(--pwt-location-radius) - 2px);
+        vector-effect: non-scaling-stroke;
+    }
+}
+
+/// Class `pwt-map-interaction-panel`
+///
+/// Is used for positioning the panel containing the interactive elements of
+/// a map
+.pwt-map-interaction-panel {
+    position: absolute;
+    inset: var(--pwt-spacer-2) var(--pwt-spacer-2) auto auto;
+}
+
+/// Class `pwt-location-animated`
+///
+/// This is used for animating a selected Location
+.pwt-map-location-animated {
+    animation: pwt-location-pulse 1.2s infinite;
+}
+
+@keyframes pwt-location-pulse {
+    from {
+        r: var(--pwt-location-radius);
+        opacity:100%;
+    }
+    to {
+        r: calc(var(--pwt-location-radius) * 1.5);
+        opacity:0%;
+    }
+}
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (3 preceding siblings ...)
  2026-05-04 12:44 ` [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
                   ` (3 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

this will be used to show the remote on a map in a custom view.
Let's the user simply enter the longitude and latitude in the remote
edit window.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/remotes.rs | 22 ++++++++++++++++++++-
 server/src/api/pbs/mod.rs        |  2 ++
 server/src/api/pve/mod.rs        |  2 ++
 server/src/api/remotes/mod.rs    |  9 +++++++++
 ui/src/remotes/edit_remote.rs    | 34 +++++++++++++++++++++++++++-----
 5 files changed, 63 insertions(+), 6 deletions(-)

diff --git a/lib/pdm-api-types/src/remotes.rs b/lib/pdm-api-types/src/remotes.rs
index b226d190..d879fcfb 100644
--- a/lib/pdm-api-types/src/remotes.rs
+++ b/lib/pdm-api-types/src/remotes.rs
@@ -4,7 +4,7 @@ use http::Uri;
 use serde::{Deserialize, Serialize};
 
 use proxmox_schema::property_string::PropertyString;
-use proxmox_schema::{api, ApiType, Schema, StringSchema, Updater};
+use proxmox_schema::{api, ApiType, Schema, StringSchema, Updater, UpdaterType};
 use proxmox_section_config::typed::ApiSectionDataEntry;
 use proxmox_section_config::{SectionConfig, SectionConfigPlugin};
 
@@ -66,6 +66,17 @@ impl RemoteType {
 serde_plain::derive_display_from_serialize!(RemoteType);
 serde_plain::derive_fromstr_from_deserialize!(RemoteType);
 
+#[api]
+#[derive(Clone, Debug, Deserialize, Serialize, UpdaterType, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// A location using the geographic coordinate system.
+pub struct RemoteLocation {
+    /// The latitude of the remote location.
+    pub latitude: f64,
+    /// The longitude of the remote location.
+    pub longitude: f64,
+}
+
 #[api(
     properties: {
         "id": { schema: REMOTE_ID_SCHEMA },
@@ -81,6 +92,10 @@ serde_plain::derive_fromstr_from_deserialize!(RemoteType);
             type: String,
             optional: true,
         },
+        "location": {
+            type: String,
+            optional: true,
+        },
     },
 )]
 /// The information required to connect to a remote instance.
@@ -120,6 +135,11 @@ pub struct Remote {
         skip_serializing_if = "Option::is_none"
     )]
     pub web_url: Option<Uri>,
+
+    /// The remotes physical location
+    #[updater(serde(skip_serializing_if = "Option::is_none"))]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub location: Option<PropertyString<RemoteLocation>>,
 }
 
 impl ApiSectionDataEntry for Remote {
diff --git a/server/src/api/pbs/mod.rs b/server/src/api/pbs/mod.rs
index 32e6bf84..50161488 100644
--- a/server/src/api/pbs/mod.rs
+++ b/server/src/api/pbs/mod.rs
@@ -275,6 +275,7 @@ pub async fn scan_remote_pbs(
         authid: authid.clone(),
         token,
         web_url: None,
+        location: None,
     };
 
     let _client = connect_or_login(&remote)
@@ -327,6 +328,7 @@ pub async fn list_realm_remote_pbs(
         authid: "root@pam".parse()?,
         token: String::new(),
         web_url: None,
+        location: None,
     };
 
     let client = connection::make_pbs_client(&remote)?;
diff --git a/server/src/api/pve/mod.rs b/server/src/api/pve/mod.rs
index 20892f38..96aaf80d 100644
--- a/server/src/api/pve/mod.rs
+++ b/server/src/api/pve/mod.rs
@@ -460,6 +460,7 @@ pub async fn scan_remote_pve(
         authid: authid.clone(),
         token,
         web_url: None,
+        location: None,
     };
 
     let client = connect_or_login(&remote)
@@ -551,6 +552,7 @@ pub async fn list_realm_remote_pve(
         authid: "root@pam".parse()?,
         token: String::new(),
         web_url: None,
+        location: None,
     };
 
     let client = connection::make_pve_client(&remote)?;
diff --git a/server/src/api/remotes/mod.rs b/server/src/api/remotes/mod.rs
index a91ec97d..69a4e3af 100644
--- a/server/src/api/remotes/mod.rs
+++ b/server/src/api/remotes/mod.rs
@@ -340,6 +340,8 @@ pub async fn add_remote(mut entry: Remote, create_token: Option<String>) -> Resu
 pub enum DeletableProperty {
     /// Delete the web-url property.
     WebUrl,
+    /// Delete the location property.
+    Location,
 }
 
 // FIXME: Support `OneOf` in schema so we can use a derived Updater for all product types?
@@ -390,6 +392,9 @@ pub fn update_remote(
                 DeletableProperty::WebUrl => {
                     entry.web_url = None;
                 }
+                DeletableProperty::Location => {
+                    entry.location = None;
+                }
             }
         }
     }
@@ -408,6 +413,10 @@ pub fn update_remote(
         entry.web_url = updater.web_url;
     }
 
+    if updater.location.is_some() {
+        entry.location = updater.location;
+    }
+
     pdm_config::remotes::save_config(remotes)?;
 
     Ok(())
diff --git a/ui/src/remotes/edit_remote.rs b/ui/src/remotes/edit_remote.rs
index 925d11ad..5db1b81f 100644
--- a/ui/src/remotes/edit_remote.rs
+++ b/ui/src/remotes/edit_remote.rs
@@ -7,10 +7,12 @@ use yew::virtual_dom::{VComp, VNode};
 
 use pwt::css::FlexFit;
 use pwt::prelude::*;
-use pwt::widget::form::{DisplayField, Field, FormContext, InputType};
+use pwt::widget::form::{DisplayField, Field, FormContext, InputType, Number};
 use pwt::widget::{Container, InputPanel};
 
-use proxmox_yew_comp::form::delete_empty_values;
+use proxmox_yew_comp::form::{
+    delete_empty_values, flatten_property_string, property_string_from_parts,
+};
 use proxmox_yew_comp::percent_encoding::percent_encode_component;
 use proxmox_yew_comp::{EditWindow, SchemaValidation};
 
@@ -21,6 +23,8 @@ use super::NodeUrlList;
 
 use pwt_macros::builder;
 
+use pdm_api_types::remotes::RemoteLocation;
+
 #[derive(PartialEq, Properties)]
 #[builder]
 pub struct EditRemote {
@@ -42,7 +46,13 @@ impl EditRemote {
 pub struct PdmEditRemote {}
 
 async fn load_remote(url: AttrValue) -> Result<ApiResponseData<Value>, Error> {
-    proxmox_yew_comp::http_get_full(&*url, None).await
+    let mut res = proxmox_yew_comp::http_get_full(&*url, None).await;
+
+    if let Ok(data) = res.as_mut() {
+        flatten_property_string::<RemoteLocation>(&mut data.data, "coordinates")?;
+    }
+
+    res
 }
 
 impl Component for PdmEditRemote {
@@ -76,9 +86,15 @@ impl Component for PdmEditRemote {
                 move |form_ctx: FormContext| {
                     let url = url.clone();
                     async move {
-                        let data = form_ctx.get_submit_data();
+                        let mut data = form_ctx.get_submit_data();
+
+                        property_string_from_parts::<RemoteLocation>(
+                            &mut data,
+                            "coordinates",
+                            true,
+                        )?;
 
-                        let data = delete_empty_values(&data, &["web-url"], true);
+                        let data = delete_empty_values(&data, &["web-url", "coordinates"], true);
 
                         proxmox_yew_comp::http_put(&url, Some(data)).await
                     }
@@ -120,6 +136,14 @@ fn edit_remote_input_panel(_form_ctx: &FormContext, remote_id: &str) -> Html {
                 .name("web-url")
                 .placeholder(tr!("Use first endpoint.")),
         )
+        .with_field(
+            tr!("Location Latitude"),
+            Number::new().name("_lat").min(-90.0).max(90.0),
+        )
+        .with_field(
+            tr!("Location Longitude"),
+            Number::new().name("_long").min(-180.0).max(180.0),
+        )
         .with_custom_child(
             Container::new()
                 .key("nodes-title")
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (4 preceding siblings ...)
  2026-05-04 12:44 ` [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:44 ` [PATCH datacenter-manager 4/4] ui: views: add map component Dominik Csapak
                   ` (2 subsequent siblings)
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

this will be used to show more status information for each remote
on the views. Put it there since we have many infos to aggregate a
status and we have to iterate over the remotes anyway here.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/resource.rs | 49 +++++++++++++++++++++++++++++++
 server/src/api/resources.rs       | 38 ++++++++++++++++++++----
 2 files changed, 82 insertions(+), 5 deletions(-)

diff --git a/lib/pdm-api-types/src/resource.rs b/lib/pdm-api-types/src/resource.rs
index 895d1244..b2af4e7c 100644
--- a/lib/pdm-api-types/src/resource.rs
+++ b/lib/pdm-api-types/src/resource.rs
@@ -689,6 +689,12 @@ pub struct CpuStatistics {
             items: {
                 type: FailedRemote,
             },
+        },
+        "remote-list": {
+            type: Array,
+            items: {
+                type: RemoteInfo,
+            },
         }
     }
 )]
@@ -729,6 +735,49 @@ pub struct ResourcesStatus {
     /// List of the failed remotes including type and error
     #[serde(default, skip_serializing_if = "Vec::is_empty")]
     pub failed_remotes_list: Vec<FailedRemote>,
+
+    /// List of remote info
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    #[serde(rename = "remote-list")]
+    pub remote_list: Vec<RemoteInfo>,
+}
+
+#[api]
+#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
+/// Basic remote status
+pub enum RemoteStatus {
+    #[default]
+    /// Remote is healthy and reachable
+    Good,
+    /// Remote has at least one (non-fatal) issue
+    Warning,
+    /// Remote can't be reached or has a fatal error
+    Error,
+    /// Unknown status of a remote
+    Unknown,
+}
+
+#[api(
+    properties: {
+        messages: {
+            type: Array,
+            items: {
+                description: "A warning or error message",
+                type: String,
+            },
+        },
+    },
+)]
+#[derive(Clone, PartialEq, Serialize, Deserialize, Default)]
+/// Basic information about a remote
+pub struct RemoteInfo {
+    /// The name of the remote
+    pub name: String,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    /// The error or warning messages when the state is not good.
+    pub messages: Vec<String>,
+    /// The overall status of the remote
+    pub status: RemoteStatus,
 }
 
 #[api]
diff --git a/server/src/api/resources.rs b/server/src/api/resources.rs
index 04628a81..5dfab1d4 100644
--- a/server/src/api/resources.rs
+++ b/server/src/api/resources.rs
@@ -13,8 +13,8 @@ use pdm_api_types::remotes::{Remote, RemoteType};
 use pdm_api_types::resource::{
     FailedRemote, NetworkFabricResource, NetworkZoneResource, PbsDatastoreResource,
     PbsNodeResource, PveLxcResource, PveNetworkResource, PveNodeResource, PveQemuResource,
-    PveStorageResource, RemoteResources, Resource, ResourceType, ResourcesStatus, SdnStatus,
-    TopEntities, PBS_DATASTORE_HIGH_USAGE_THRESHOLD,
+    PveStorageResource, RemoteInfo, RemoteResources, RemoteStatus, Resource, ResourceType,
+    ResourcesStatus, SdnStatus, TopEntities, PBS_DATASTORE_HIGH_USAGE_THRESHOLD,
 };
 use pdm_api_types::subscription::{
     NodeSubscriptionInfo, RemoteSubscriptionState, RemoteSubscriptions, SubscriptionLevel,
@@ -470,16 +470,27 @@ pub async fn get_status(
     let mut counts = ResourcesStatus::default();
     let mut pve_cpu_allocated = 0.0;
     for remote_with_resources in remotes_with_resources {
-        if let Some(err) = remote_with_resources.error {
+        if let Some(err) = &remote_with_resources.error {
             counts.failed_remotes += 1;
             counts.failed_remotes_list.push(FailedRemote {
-                name: remote_with_resources.remote_name,
+                name: remote_with_resources.remote_name.clone(),
                 error: err.to_string(),
                 remote_type: remote_with_resources.remote.ty,
             });
         } else {
             counts.remotes += 1;
         }
+
+        let mut remote_status = if remote_with_resources.error.is_some() {
+            RemoteStatus::Error
+        } else {
+            RemoteStatus::Good
+        };
+        let mut remote_messages = match remote_with_resources.error {
+            Some(error) => vec![error],
+            None => Vec::new(),
+        };
+
         let mut seen_storages = HashSet::new();
         for resource in remote_with_resources.resources {
             match resource {
@@ -516,7 +527,13 @@ pub async fn get_status(
                 Resource::PveNode(r) => {
                     match r.status.as_str() {
                         "online" => counts.pve_nodes.online += 1,
-                        "offline" => counts.pve_nodes.offline += 1,
+                        "offline" => {
+                            if remote_status == RemoteStatus::Good {
+                                remote_status = RemoteStatus::Warning;
+                            }
+                            remote_messages.push(format!("Node '{}' is offline", r.node));
+                            counts.pve_nodes.offline += 1
+                        }
                         _ => counts.pve_nodes.unknown += 1,
                     }
                     counts.pve_cpu_stats.used += r.cpu * r.maxcpu;
@@ -533,6 +550,11 @@ pub async fn get_status(
                                 counts.sdn_zones.available += 1;
                             }
                             SdnStatus::Error => {
+                                if remote_status == RemoteStatus::Good {
+                                    remote_status = RemoteStatus::Warning;
+                                }
+                                remote_messages
+                                    .push(format!("SDN zone '{}' has an error", zone.id));
                                 counts.sdn_zones.error += 1;
                             }
                             SdnStatus::Pending => {
@@ -586,6 +608,12 @@ pub async fn get_status(
                 }
             }
         }
+
+        counts.remote_list.push(RemoteInfo {
+            name: remote_with_resources.remote_name,
+            status: remote_status,
+            messages: remote_messages,
+        });
     }
 
     counts.pve_cpu_stats.allocated = Some(pve_cpu_allocated);
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* [PATCH datacenter-manager 4/4] ui: views: add map component
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (5 preceding siblings ...)
  2026-05-04 12:44 ` [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
@ 2026-05-04 12:44 ` Dominik Csapak
  2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
  2026-05-05  7:38 ` superseded: " Dominik Csapak
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:44 UTC (permalink / raw)
  To: pdm-devel

this uses the WorldMap from yew widget toolkit together with the added
world-map.json to show a world map on a custom view. This shows the
remotes which have a location in the config and their status.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 lib/pdm-api-types/src/views.rs    |   2 +
 ui/Cargo.toml                     |   1 +
 ui/src/dashboard/map.rs           | 281 ++++++++++++++++++++++++++++++
 ui/src/dashboard/mod.rs           |   3 +
 ui/src/dashboard/view.rs          |  10 +-
 ui/src/dashboard/view/row_view.rs |   1 +
 6 files changed, 294 insertions(+), 4 deletions(-)
 create mode 100644 ui/src/dashboard/map.rs

diff --git a/lib/pdm-api-types/src/views.rs b/lib/pdm-api-types/src/views.rs
index c1885828..d300d74e 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -312,6 +312,8 @@ pub enum WidgetType {
         #[serde(skip_serializing_if = "Option::is_none")]
         remote_type: Option<RemoteType>,
     },
+    #[serde(rename_all = "kebab-case")]
+    Map,
 }
 
 #[derive(Serialize, Deserialize, PartialEq, Clone, Copy)]
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 460e247e..752e685d 100644
--- a/ui/Cargo.toml
+++ b/ui/Cargo.toml
@@ -11,6 +11,7 @@ resolver = "2"
 [dependencies]
 anyhow = "1.0"
 futures = "0.3"
+geojson = "0.24"
 gloo-net = "0.4"
 gloo-timers = "0.3"
 gloo-utils = "0.2"
diff --git a/ui/src/dashboard/map.rs b/ui/src/dashboard/map.rs
new file mode 100644
index 00000000..9d7c24bd
--- /dev/null
+++ b/ui/src/dashboard/map.rs
@@ -0,0 +1,281 @@
+use std::collections::HashMap;
+use std::rc::Rc;
+
+use anyhow::Error;
+use geojson::GeoJson;
+
+use proxmox_yew_comp::Status;
+use pwt::css;
+use pwt::prelude::*;
+use pwt::state::SharedStateObserver;
+use pwt::state::{Loader, SharedState};
+use pwt::widget::canvas::Group;
+use pwt::widget::charts::{
+    render_point_default, Location, MapPointData, PointsRenderArgs, WorldMap, WorldPoint,
+};
+use pwt::widget::container::span;
+use pwt::widget::Container;
+use pwt::widget::{error_message, ActionIcon, Column, Fa, Panel, Row};
+use pwt_macros::{builder, widget};
+use yew::virtual_dom::{VComp, VNode};
+
+use crate::dashboard::loading_column;
+use crate::{navigate_to, LoadResult, RemoteList};
+
+use pdm_api_types::resource::{RemoteInfo, RemoteStatus, ResourcesStatus};
+
+#[widget(comp=DashboardMapComp, @element)]
+#[builder]
+#[derive(Properties, PartialEq, Clone)]
+pub struct DashboardMap {
+    data: SharedState<LoadResult<ResourcesStatus, Error>>,
+}
+
+impl DashboardMap {
+    pub fn new(data: SharedState<LoadResult<ResourcesStatus, Error>>) -> Self {
+        yew::props!(Self { data })
+    }
+}
+
+pub enum Msg {
+    MapLoaded,
+    DataChanged,
+    RemoteListChanged(RemoteList),
+}
+
+pub struct DashboardMapComp {
+    loader: Loader<GeoJson>,
+    points: Vec<WorldPoint<PoiInfo>>,
+    remote_list: RemoteList,
+    _remote_list_ctx_handle: ContextHandle<RemoteList>,
+    _data_observer: SharedStateObserver<LoadResult<ResourcesStatus, Error>>,
+}
+
+impl DashboardMapComp {
+    fn calculate_points(ctx: &Context<Self>, remote_list: &RemoteList) -> Vec<WorldPoint<PoiInfo>> {
+        let read_guard = ctx.props().data.read();
+
+        let mut info_map = HashMap::new();
+        if let Some(data) = &read_guard.data {
+            for remote in &data.remote_list {
+                info_map.insert(remote.name.clone(), remote);
+            }
+        };
+
+        remote_list
+            .iter()
+            .filter_map(|remote| {
+                remote.location.as_ref().map(|location| {
+                    let location = Location::new(location.longitude, location.latitude);
+                    let info = match info_map.get(&remote.id) {
+                        Some(&info) => info.clone(),
+                        None => RemoteInfo {
+                            name: remote.id.clone(),
+                            messages: Vec::new(),
+                            status: RemoteStatus::Unknown,
+                        },
+                    };
+                    WorldPoint {
+                        location,
+                        data: PoiInfo::new(info),
+                    }
+                })
+            })
+            .collect()
+    }
+}
+
+impl yew::Component for DashboardMapComp {
+    type Message = Msg;
+    type Properties = DashboardMap;
+
+    fn create(ctx: &Context<Self>) -> Self {
+        let loader = Loader::new()
+            .loader((
+                |url: AttrValue| async move {
+                    let json = gloo_net::http::Request::get(&url).send().await?;
+                    let geo_json = GeoJson::from_json_value(json.json().await?)?;
+                    Ok(geo_json)
+                },
+                "/world-map.json",
+            ))
+            .on_change(ctx.link().callback(|_| Msg::MapLoaded));
+        loader.load();
+
+        let _data_observer = ctx
+            .props()
+            .data
+            .add_listener(ctx.link().callback(|_| Msg::DataChanged));
+
+        let (remote_list, _remote_list_ctx_handle) = ctx
+            .link()
+            .context(ctx.link().callback(Msg::RemoteListChanged))
+            .expect("no remote list context");
+        let points = Self::calculate_points(ctx, &remote_list); // todo
+
+        Self {
+            loader,
+            remote_list,
+            points,
+            _remote_list_ctx_handle,
+            _data_observer,
+        }
+    }
+
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
+        match msg {
+            Msg::MapLoaded => {}
+            Msg::DataChanged => {
+                self.points = Self::calculate_points(ctx, &self.remote_list);
+            }
+            Msg::RemoteListChanged(remote_list) => {
+                self.points = Self::calculate_points(ctx, &remote_list);
+                self.remote_list = remote_list;
+            }
+        }
+        true
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+        let loader = self.loader.read();
+
+        let geojson = match &loader.data {
+            Some(Ok(geojson)) => Rc::clone(geojson),
+            Some(Err(err)) => return error_message(&err.to_string()).into(),
+            _ => return loading_column().into(),
+        };
+
+        WorldMap::new(geojson)
+            .with_std_props(&props.std_props)
+            .listeners(&props.listeners)
+            .points(self.points.clone())
+            .into()
+    }
+}
+
+#[derive(Clone, PartialEq, Properties)]
+struct PoiInfo {
+    info: RemoteInfo,
+}
+
+impl std::ops::Deref for PoiInfo {
+    type Target = RemoteInfo;
+
+    fn deref(&self) -> &Self::Target {
+        &self.info
+    }
+}
+
+impl PoiInfo {
+    fn new(info: RemoteInfo) -> Self {
+        yew::props!(Self { info })
+    }
+}
+
+impl From<PoiInfo> for VNode {
+    fn from(val: PoiInfo) -> Self {
+        let comp = VComp::new::<PoiInfoComp>(Rc::new(val), None);
+        VNode::from(comp)
+    }
+}
+
+struct PoiInfoComp {}
+
+impl Component for PoiInfoComp {
+    type Message = ();
+    type Properties = PoiInfo;
+
+    fn create(_ctx: &Context<Self>) -> Self {
+        Self {}
+    }
+
+    fn view(&self, ctx: &Context<Self>) -> Html {
+        let props = ctx.props();
+
+        let link = ctx.link().clone();
+        let remote_name = props.info.name.clone();
+        let (status, status_icon) = match props.info.status {
+            RemoteStatus::Good => (tr!("Good"), Fa::from(Status::Success)),
+            RemoteStatus::Warning => (tr!("Warning"), Fa::from(Status::Warning)),
+            RemoteStatus::Error => (tr!("Error"), Fa::from(Status::Error)),
+            RemoteStatus::Unknown => (tr!("Unknown"), Fa::from(Status::Unknown)),
+        };
+        Column::new()
+            .width(300)
+            .max_height(300)
+            .class(css::JustifyContent::Stretch)
+            .gap(2)
+            .padding(1)
+            .with_child(
+                Row::new()
+                    .gap(1)
+                    .class(css::AlignItems::Center)
+                    .with_child(status_icon)
+                    .with_child(span(&status))
+                    .with_flex_spacer()
+                    .with_child(span(&props.info.name))
+                    .with_child(
+                        ActionIcon::new("fa fa-chevron-right")
+                            .on_activate(move |_| navigate_to(&link, &remote_name, None)),
+                    ),
+            )
+            .with_optional_child((!props.info.messages.is_empty()).then_some(
+                Container::new().children(props.info.messages.iter().map(|err| {
+                    span(err)
+                        .padding_bottom(1)
+                        .class(css::Overflow::Auto)
+                        .into()
+                })),
+            ))
+            .into()
+    }
+}
+
+impl MapPointData for PoiInfo {
+    fn render_title(&self) -> AttrValue {
+        self.info.name.clone().into()
+    }
+
+    fn render_point(args: &PointsRenderArgs<Self>) -> Group {
+        let mut worst = RemoteStatus::Good;
+
+        for poi in args.points {
+            match (&poi.data.status, &worst) {
+                (RemoteStatus::Error, _) => worst = RemoteStatus::Error,
+                (RemoteStatus::Warning, RemoteStatus::Good | RemoteStatus::Unknown) => {
+                    worst = RemoteStatus::Warning
+                }
+                (RemoteStatus::Unknown, RemoteStatus::Good) => worst = RemoteStatus::Unknown,
+                _ => {}
+            }
+        }
+
+        let mut args = args.clone();
+        let txt = match worst {
+            RemoteStatus::Good => "success",
+            RemoteStatus::Warning => "warning",
+            RemoteStatus::Error => "error",
+            RemoteStatus::Unknown => {
+                // animate the not yet loaded remotes
+                args.selected = true;
+                "primary"
+            }
+        };
+        render_point_default(&args).style(
+            "--pwt-location-color",
+            format!("var(--pwt-color-{txt}) ! important"),
+        )
+    }
+
+    fn render_info(args: &PointsRenderArgs<Self>) -> Html {
+        Column::new()
+            .children(args.points.iter().map(|&point| point.data.clone().into()))
+            .into()
+    }
+}
+
+/// Creates a dashboard panel with a world map
+pub fn create_map_panel(status: SharedState<LoadResult<ResourcesStatus, Error>>) -> Panel {
+    Panel::new().with_child(DashboardMap::new(status).flex(1.0))
+}
diff --git a/ui/src/dashboard/mod.rs b/ui/src/dashboard/mod.rs
index 194000c2..fdf08c43 100644
--- a/ui/src/dashboard/mod.rs
+++ b/ui/src/dashboard/mod.rs
@@ -20,6 +20,9 @@ pub use gauge_panel::create_gauge_panel;
 mod guest_panel;
 pub use guest_panel::create_guest_panel;
 
+mod map;
+pub use map::create_map_panel;
+
 mod node_status_panel;
 use node_status_panel::create_node_panel;
 
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index 81810664..de2a59fe 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -22,10 +22,10 @@ use crate::dashboard::refresh_config_edit::{
 use crate::dashboard::subscription_info::create_subscriptions_dialog;
 use crate::dashboard::tasks::get_task_options;
 use crate::dashboard::{
-    create_gauge_panel, create_guest_panel, create_node_panel, create_pbs_datastores_panel,
-    create_refresh_config_edit_window, create_remote_panel, create_resource_tree, create_sdn_panel,
-    create_subscription_panel, create_task_summary_panel, create_top_entities_panel,
-    DashboardStatusRow,
+    create_gauge_panel, create_guest_panel, create_map_panel, create_node_panel,
+    create_pbs_datastores_panel, create_refresh_config_edit_window, create_remote_panel,
+    create_resource_tree, create_sdn_panel, create_subscription_panel, create_task_summary_panel,
+    create_top_entities_panel, DashboardStatusRow,
 };
 use crate::remotes::AddWizard;
 use crate::widget::RedrawController;
@@ -171,6 +171,7 @@ fn render_widget(
             resource,
             remote_type,
         } => create_gauge_panel(*resource, *remote_type, status),
+        WidgetType::Map => create_map_panel(status),
     };
 
     if let Some(title) = &item.title {
@@ -273,6 +274,7 @@ fn required_api_calls(layout: &ViewLayout) -> (bool, bool, bool) {
                         | WidgetType::Remotes { .. }
                         | WidgetType::Sdn
                         | WidgetType::PbsDatastores
+                        | WidgetType::Map
                         | WidgetType::NodeResourceGauge { .. } => {
                             status = true;
                         }
diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs
index 3c5428ae..a9696317 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -667,6 +667,7 @@ fn create_menu(ctx: &yew::Context<RowViewComp>, new_coords: Position) -> Menu {
             ),
         )
         .with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn)))
+        .with_item(MenuItem::new(tr!("Map")).on_select(create_callback(WidgetType::Map)))
         .with_item(
             MenuItem::new(tr!("Resource Tree"))
                 .on_select(create_callback(WidgetType::ResourceTree)),
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 13+ messages in thread

* Re: [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (6 preceding siblings ...)
  2026-05-04 12:44 ` [PATCH datacenter-manager 4/4] ui: views: add map component Dominik Csapak
@ 2026-05-04 12:49 ` Dominik Csapak
  2026-05-05  8:28   ` Thomas Lamprecht
  2026-05-05  7:38 ` superseded: " Dominik Csapak
  8 siblings, 1 reply; 13+ messages in thread
From: Dominik Csapak @ 2026-05-04 12:49 UTC (permalink / raw)
  To: pdm-devel

as expected, the mail with the geojson (~350k) is stuck in the 
moderation queue. (3/4 of datacenter manager)

alternatively i can offer a local git repo to pull the diff from.

I think i have to rebase the series anyway due to the recent inclusion
of my unknown widgets patch, still waiting on a first look/review
before sending another version though.

On 5/4/26 2:44 PM, Dominik Csapak wrote:
> This series let's users add location info to remotes (longitude,latitude) and
> makes it possible to show them on a map in a custom view.
> 
> This map is interactive, so it let's you zoom/pan/etc. (with touch controls too)
> 
> It also adds new status info per remote to the resources status api call
> so we can show some sensible info on the map (success/warn/error).
> 
> For now this is very basic only, but we can extend that in the future by e.g.,
> adding a health call for each remote and including this info here.
> 
> The source map data is from naturalearthdata.com (data is in the public domain)
> and is converted to geojson with 'ogr2ogr' (small script is included)
> 
> I used the lowest resolution data which results in a ~350K json (~130K zipped)
> which should work in most scenarios (e.g. cpu perf on mid-tier mobiles)
> 
> naming/location of api data and configs is open for debate, I'm not really
> sure if I found the right places, but they seemed to work out ok.
> 
> proxmox-yew-widget-toolkit:
> 
> Dominik Csapak (3):
>    js-helper: add client-to-svg-coordinate conversion helper
>    widget: charts: add interactive Map with zoom/pan and clustering
>    widget: charts: add WorldMap with GeoJSON rendering
> 
>   Cargo.toml                         |   1 +
>   js-helper-module.js                |   7 +
>   src/lib.rs                         |   5 +
>   src/widget/charts/map/map_point.rs | 116 +++++++
>   src/widget/charts/map/mod.rs       | 537 +++++++++++++++++++++++++++++
>   src/widget/charts/map/zoom_info.rs | 192 +++++++++++
>   src/widget/charts/mod.rs           |   9 +
>   src/widget/charts/world_map.rs     | 219 ++++++++++++
>   8 files changed, 1086 insertions(+)
>   create mode 100644 src/widget/charts/map/map_point.rs
>   create mode 100644 src/widget/charts/map/mod.rs
>   create mode 100644 src/widget/charts/map/zoom_info.rs
>   create mode 100644 src/widget/charts/world_map.rs
> 
> 
> proxmox-yew-widget-toolkit-assets:
> 
> Dominik Csapak (1):
>    charts: add necessary classes for Map
> 
>   scss/_charts.scss | 63 +++++++++++++++++++++++++++++++++++++++++++++++
>   1 file changed, 63 insertions(+)
> 
> 
> proxmox-datacenter-manager:
> 
> Dominik Csapak (4):
>    lib/api/ui: add location property to remote config
>    lib/api: add new 'remote-list' info to the resource status
>    ui: add world map geojson data and update script
>    ui: views: add map component
> 
>   lib/pdm-api-types/src/remotes.rs  |  22 ++-
>   lib/pdm-api-types/src/resource.rs |  49 ++++++
>   lib/pdm-api-types/src/views.rs    |   2 +
>   server/src/api/pbs/mod.rs         |   2 +
>   server/src/api/pve/mod.rs         |   2 +
>   server/src/api/remotes/mod.rs     |   9 +
>   server/src/api/resources.rs       |  38 +++-
>   ui/Cargo.toml                     |   1 +
>   ui/Makefile                       |   6 +-
>   ui/index.html                     |   1 +
>   ui/src/dashboard/map.rs           | 281 ++++++++++++++++++++++++++++++
>   ui/src/dashboard/mod.rs           |   3 +
>   ui/src/dashboard/view.rs          |  10 +-
>   ui/src/dashboard/view/row_view.rs |   1 +
>   ui/src/remotes/edit_remote.rs     |  34 +++-
>   ui/update-world-map.sh            |  15 ++
>   ui/world-map.json                 |   9 +
>   17 files changed, 468 insertions(+), 17 deletions(-)
>   create mode 100644 ui/src/dashboard/map.rs
>   create mode 100755 ui/update-world-map.sh
>   create mode 100644 ui/world-map.json
> 
> 
> Summary over all repositories:
>    26 files changed, 1617 insertions(+), 17 deletions(-)
> 





^ permalink raw reply	[flat|nested] 13+ messages in thread

* superseded: [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
  2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
                   ` (7 preceding siblings ...)
  2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
@ 2026-05-05  7:38 ` Dominik Csapak
  8 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05  7:38 UTC (permalink / raw)
  To: pdm-devel

superseded by v2:

https://lore.proxmox.com/pdm-devel/20260505073203.398548-1-d.csapak@proxmox.com/T/#t





^ permalink raw reply	[flat|nested] 13+ messages in thread

* Re: [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
  2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
@ 2026-05-05  8:28   ` Thomas Lamprecht
  2026-05-05  8:39     ` Dominik Csapak
  0 siblings, 1 reply; 13+ messages in thread
From: Thomas Lamprecht @ 2026-05-05  8:28 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

Am 04.05.26 um 14:47 schrieb Dominik Csapak:
> as expected, the mail with the geojson (~350k) is stuck in the moderation queue. (3/4 of datacenter manager)
> 
> alternatively i can offer a local git repo to pull the diff from.
> 
> I think i have to rebase the series anyway due to the recent inclusion
> of my unknown widgets patch, still waiting on a first look/review
> before sending another version though.

Would be good to keep that in a different repo and package that we can then
depend on, let's not bloat the main git repos with such things for this.




^ permalink raw reply	[flat|nested] 13+ messages in thread

* Re: [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
  2026-05-05  8:28   ` Thomas Lamprecht
@ 2026-05-05  8:39     ` Dominik Csapak
  2026-05-05  8:51       ` Thomas Lamprecht
  0 siblings, 1 reply; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05  8:39 UTC (permalink / raw)
  To: Thomas Lamprecht, pdm-devel



On 5/5/26 10:26 AM, Thomas Lamprecht wrote:
> Am 04.05.26 um 14:47 schrieb Dominik Csapak:
>> as expected, the mail with the geojson (~350k) is stuck in the moderation queue. (3/4 of datacenter manager)
>>
>> alternatively i can offer a local git repo to pull the diff from.
>>
>> I think i have to rebase the series anyway due to the recent inclusion
>> of my unknown widgets patch, still waiting on a first look/review
>> before sending another version though.
> 
> Would be good to keep that in a different repo and package that we can then
> depend on, let's not bloat the main git repos with such things for this.


yes sounds good, does something like 'proxmox-geojson-data' sound
alright?

would either simply move the update script from v2 there and
have some packaging around that, or include the upstream
github repo as a submodule?

(not sure what's better, but the data should not need frequent updates)




^ permalink raw reply	[flat|nested] 13+ messages in thread

* Re: [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views
  2026-05-05  8:39     ` Dominik Csapak
@ 2026-05-05  8:51       ` Thomas Lamprecht
  0 siblings, 0 replies; 13+ messages in thread
From: Thomas Lamprecht @ 2026-05-05  8:51 UTC (permalink / raw)
  To: Dominik Csapak, pdm-devel

Am 05.05.26 um 10:37 schrieb Dominik Csapak:
> yes sounds good, does something like 'proxmox-geojson-data' sound
> alright?

That tracks and I do not got any better idea, so fine by me.

> would either simply move the update script from v2 there and
> have some packaging around that, or include the upstream
> github repo as a submodule?

Probably as git subtree as that way the data is in the repo itself
and we can still update it relatively easily using git directly.
We also need to ensure d/copyright and license is correct here, just
mentioning as your series did not touch that at all FWICT from the
cover letters diffstatt




^ permalink raw reply	[flat|nested] 13+ messages in thread

end of thread, other threads:[~2026-05-05  8:51 UTC | newest]

Thread overview: 13+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 4/4] ui: views: add map component Dominik Csapak
2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-05  8:28   ` Thomas Lamprecht
2026-05-05  8:39     ` Dominik Csapak
2026-05-05  8:51       ` Thomas Lamprecht
2026-05-05  7:38 ` superseded: " Dominik Csapak

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