* [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets v2 0/8] add a new map widget for custom views
@ 2026-05-05 7:31 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
` (7 more replies)
0 siblings, 8 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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.
NOTE: the world-map update script does not include the full json file
now so it fits on the mailing list. please run ./update-world-map.sh
to generate it after applying the patches for testing.
When applying this world map should be checked in (maybe squashed into
the commit with the script)
changes from v1:
* don't include world-map.json file in commit
* fixed an issue with the wrong property name for the remote location in the ui
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 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 | 3 +
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 | 30 +++-
ui/update-world-map.sh | 26 +++
16 files changed, 467 insertions(+), 17 deletions(-)
create mode 100644 ui/src/dashboard/map.rs
create mode 100755 ui/update-world-map.sh
Summary over all repositories:
25 files changed, 1616 insertions(+), 17 deletions(-)
--
Generated by git-murpp 0.8.1
^ permalink raw reply [flat|nested] 13+ messages in thread
* [PATCH yew-widget-toolkit v2 1/3] js-helper: add client-to-svg-coordinate conversion helper
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 ` 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
` (6 subsequent siblings)
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 v2 2/3] widget: charts: add interactive Map with zoom/pan and clustering
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 ` Dominik Csapak
2026-05-05 7:31 ` [PATCH yew-widget-toolkit v2 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
` (5 subsequent siblings)
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 v2 3/3] widget: charts: add WorldMap with GeoJSON rendering
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
2026-05-05 7:31 ` [PATCH yew-widget-toolkit-assets v2 1/1] charts: add necessary classes for Map Dominik Csapak
` (4 subsequent siblings)
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 v2 1/1] charts: add necessary classes for Map
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
` (2 preceding siblings ...)
2026-05-05 7:31 ` [PATCH yew-widget-toolkit v2 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
@ 2026-05-05 7:31 ` Dominik Csapak
2026-05-05 7:31 ` [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config Dominik Csapak
` (3 subsequent siblings)
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 v2 1/4] lib/api/ui: add location property to remote config
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
` (3 preceding siblings ...)
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 ` Dominik Csapak
2026-05-05 8:26 ` Thomas Lamprecht
2026-05-05 7:31 ` [PATCH datacenter-manager v2 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
` (2 subsequent siblings)
7 siblings, 1 reply; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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>
---
changes from v1:
* use correct 'location' property for gui edit windows of remotes
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 | 30 +++++++++++++++++++++++++-----
5 files changed, 59 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..99bc6c1a 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, "location")?;
+ }
+
+ res
}
impl Component for PdmEditRemote {
@@ -76,9 +86,11 @@ 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, "location", true)?;
- let data = delete_empty_values(&data, &["web-url"], true);
+ let data = delete_empty_values(&data, &["web-url", "location"], true);
proxmox_yew_comp::http_put(&url, Some(data)).await
}
@@ -120,6 +132,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 v2 2/4] lib/api: add new 'remote-list' info to the resource status
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
` (4 preceding siblings ...)
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 7:31 ` 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
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 v2 3/4] ui: add world map geojson update script
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
` (5 preceding siblings ...)
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 ` Dominik Csapak
2026-05-05 7:31 ` [PATCH datacenter-manager v2 4/4] ui: views: add map component Dominik Csapak
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 UTC (permalink / raw)
To: pdm-devel
the data source is natural earth[0] which is in the public domain. It's
also maintained on github[1] which is used for the update script.
include a small script to convert the shapefile to the necessary geojson
file with 'ogr2ogr' from the `gdal-bin` package.
It's not added as a dev-dependency, since it should not update often.
(last update of this data was in 2018 so ~6 years ago)
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
changes from v1:
* don't include the map data itself
* use github to download the files and generate the world-map.json file
ui/Makefile | 6 ++++--
ui/index.html | 1 +
ui/update-world-map.sh | 26 ++++++++++++++++++++++++++
3 files changed, 31 insertions(+), 2 deletions(-)
create mode 100755 ui/update-world-map.sh
diff --git a/ui/Makefile b/ui/Makefile
index 403f6d55..6562ae5e 100644
--- a/ui/Makefile
+++ b/ui/Makefile
@@ -51,7 +51,7 @@ dist/pdm-ui_bg.wasm.gz: dist/pdm-ui_bg.wasm
dist/%.css: css/%.scss dist
rust-grass $< $@
-install: $(COMPILED_OUTPUT) index.hbs
+install: $(COMPILED_OUTPUT) index.hbs world-map.json
install -dm0755 $(DESTDIR)$(UIDIR)
install -dm0755 $(DESTDIR)$(UIDIR)/js
@@ -76,6 +76,8 @@ install: $(COMPILED_OUTPUT) index.hbs
install -m0644 dist/material-yew-style.css $(DESTDIR)$(UIDIR)
install -m0644 dist/desktop-yew-style.css $(DESTDIR)$(UIDIR)
+ install -m0644 world-map.json $(DESTDIR)$(UIDIR)
+
.PHONY: submodule
submodule:
test -f "pwt-assets/README.md" || git submodule update --init
@@ -83,7 +85,7 @@ submodule:
$(BUILDDIR): submodule
rm -rf $@ $@.tmp
mkdir -p $@.tmp/ui
- cp -a debian/ src/ pwt-assets/ images/ css/ index.hbs Makefile Cargo.toml $@.tmp/ui
+ cp -a debian/ src/ pwt-assets/ images/ css/ index.hbs world-map.json Makefile Cargo.toml $@.tmp/ui
cp -a ../Cargo.toml ../lib $@.tmp/
echo "git clone git://git.proxmox.com/git/$(PACKAGE).git\\ngit checkout $$(git rev-parse HEAD)" \
> $@.tmp/ui/debian/SOURCE
diff --git a/ui/index.html b/ui/index.html
index 6b81f7d8..48256a67 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -12,6 +12,7 @@
<link data-trunk href="pwt-assets/assets/fonts" rel="copy-dir"/>
<link data-trunk href="images" rel="copy-dir"/>
+ <link data-trunk href="world-map.json" rel="copy-file"/>
<style>
/* Avoid flickering (default background in firefox is always white)*/
diff --git a/ui/update-world-map.sh b/ui/update-world-map.sh
new file mode 100755
index 00000000..a0820715
--- /dev/null
+++ b/ui/update-world-map.sh
@@ -0,0 +1,26 @@
+#!/bin/sh
+
+# Shapefile (public domain) source is:
+#
+# https://www.naturalearthdata.com/http//www.naturalearthdata.com/download/110m/cultural/ne_110m_admin_0_countries.zip
+# and
+# https://github.com/nvkelso/natural-earth-vector
+
+set -e
+
+NAME="ne_110m_admin_0_countries"
+SHP_SOURCE="https://github.com/nvkelso/natural-earth-vector/raw/refs/heads/master/110m_cultural/${NAME}.shp"
+SHX_SOURCE="https://github.com/nvkelso/natural-earth-vector/raw/refs/heads/master/110m_cultural/${NAME}.shx"
+SQL_COMMAND="SELECT 'land' AS kind, ST_Union(geometry) AS geometry FROM ${NAME}
+UNION ALL
+SELECT 'borders' AS kind, ST_Collect(ST_Boundary(geometry)) AS geometry FROM ${NAME}"
+
+TMPDIR="world-map.tmp"
+
+rm -rv ${TMPDIR} || true
+mkdir ${TMPDIR}
+wget ${SHP_SOURCE} -O ${TMPDIR}/${NAME}.shp
+wget ${SHX_SOURCE} -O ${TMPDIR}/${NAME}.shx
+ogr2ogr -f "GeoJSON" world-map.json ${TMPDIR}/${NAME}.shp -dialect sqlite -sql "${SQL_COMMAND}" -lco COORDINATE_PRECISION=4 -nlt MULTILINESTRING
+rm -rv ${TMPDIR}
+
--
2.47.3
^ permalink raw reply related [flat|nested] 13+ messages in thread
* [PATCH datacenter-manager v2 4/4] ui: views: add map component
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
` (6 preceding siblings ...)
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 ` Dominik Csapak
7 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 7:31 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 | 3 +
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, 295 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 3e215d06..1bab0170 100644
--- a/lib/pdm-api-types/src/views.rs
+++ b/lib/pdm-api-types/src/views.rs
@@ -333,6 +333,9 @@ pub enum WidgetType {
#[serde(skip_serializing_if = "Option::is_none")]
remote_type: Option<RemoteType>,
},
+ /// A simple map
+ #[serde(rename_all = "kebab-case")]
+ Map,
#[serde(untagged)]
#[serde(rename_all = "kebab-case")]
/// Catches all widgets for unknown types.
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index 90b9e1e4..00139749 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 bdf92bf6..63d4e5ef 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -24,10 +24,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;
@@ -173,6 +173,7 @@ fn render_widget(
resource,
remote_type,
} => create_gauge_panel(*resource, *remote_type, status),
+ WidgetType::Map => create_map_panel(status),
WidgetType::UnknownWidget { widget_type, .. } => create_unknown_widget_panel(widget_type),
};
@@ -276,6 +277,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 v2 1/4] lib/api/ui: add location property to remote config
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
0 siblings, 1 reply; 13+ messages in thread
From: Thomas Lamprecht @ 2026-05-05 8:26 UTC (permalink / raw)
To: Dominik Csapak, pdm-devel
Am 05.05.26 um 09:31 schrieb Dominik Csapak:
> 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.
Would be nicer and more fitting to have this on the remote side, i.e. the
node configs as metadata or similar format-string field.
And one location per remote won't cut it for PVE. We know of clusters that
are distributed over different location with a few km distance in between
and a dark fiber channel for near LAN latency to upheld cluster communication
requirements.
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config
2026-05-05 8:26 ` Thomas Lamprecht
@ 2026-05-05 8:36 ` Dominik Csapak
2026-05-05 8:44 ` Thomas Lamprecht
0 siblings, 1 reply; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 8:36 UTC (permalink / raw)
To: Thomas Lamprecht, pdm-devel
On 5/5/26 10:24 AM, Thomas Lamprecht wrote:
> Am 05.05.26 um 09:31 schrieb Dominik Csapak:
>> 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.
>
> Would be nicer and more fitting to have this on the remote side, i.e. the
> node configs as metadata or similar format-string field.
>
> And one location per remote won't cut it for PVE. We know of clusters that
> are distributed over different location with a few km distance in between
> and a dark fiber channel for near LAN latency to upheld cluster communication
> requirements.
do you mean that the user configures that on the pve and pbs side per node?
if yes, sure that is doable. I didn't go this way to not exclude
users with older clusters (e.g. 8.4) to use that feature.
also it's metadata that the pve nodes themselves won't really use
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config
2026-05-05 8:36 ` Dominik Csapak
@ 2026-05-05 8:44 ` Thomas Lamprecht
2026-05-05 8:46 ` Dominik Csapak
0 siblings, 1 reply; 13+ messages in thread
From: Thomas Lamprecht @ 2026-05-05 8:44 UTC (permalink / raw)
To: Dominik Csapak, pdm-devel
Am 05.05.26 um 10:35 schrieb Dominik Csapak:
>
>
> On 5/5/26 10:24 AM, Thomas Lamprecht wrote:
>> Am 05.05.26 um 09:31 schrieb Dominik Csapak:
>>> 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.
>>
>> Would be nicer and more fitting to have this on the remote side, i.e. the
>> node configs as metadata or similar format-string field.
>>
>> And one location per remote won't cut it for PVE. We know of clusters that
>> are distributed over different location with a few km distance in between
>> and a dark fiber channel for near LAN latency to upheld cluster communication
>> requirements.
>
> do you mean that the user configures that on the pve and pbs side per node?
exactly what I meant.
> if yes, sure that is doable. I didn't go this way to not exclude
> users with older clusters (e.g. 8.4) to use that feature.
> also it's metadata that the pve nodes themselves won't really use
If you really want to provide those old releases this features we can port
it back, that said, EOL is nearing for those (bit over ~3 months left), and
especially for this non-essential feature I'd just not bother with that.
And why should we not show this on the PVE nodes themselves? And independent
of that it's in anyway a property of a PVE/ node and tied to that physical
location, not a property of a PDM remote config entry, which should IMO not
care about these things, at least not as primary source of such information
(overrides can be OK, but IMO not really justified here).
^ permalink raw reply [flat|nested] 13+ messages in thread
* Re: [PATCH datacenter-manager v2 1/4] lib/api/ui: add location property to remote config
2026-05-05 8:44 ` Thomas Lamprecht
@ 2026-05-05 8:46 ` Dominik Csapak
0 siblings, 0 replies; 13+ messages in thread
From: Dominik Csapak @ 2026-05-05 8:46 UTC (permalink / raw)
To: Thomas Lamprecht, pdm-devel
On 5/5/26 10:42 AM, Thomas Lamprecht wrote:
> Am 05.05.26 um 10:35 schrieb Dominik Csapak:
>>
>>
>> On 5/5/26 10:24 AM, Thomas Lamprecht wrote:
>>> Am 05.05.26 um 09:31 schrieb Dominik Csapak:
>>>> 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.
>>>
>>> Would be nicer and more fitting to have this on the remote side, i.e. the
>>> node configs as metadata or similar format-string field.
>>>
>>> And one location per remote won't cut it for PVE. We know of clusters that
>>> are distributed over different location with a few km distance in between
>>> and a dark fiber channel for near LAN latency to upheld cluster communication
>>> requirements.
>>
>> do you mean that the user configures that on the pve and pbs side per node?
>
> exactly what I meant.
>> if yes, sure that is doable. I didn't go this way to not exclude
>> users with older clusters (e.g. 8.4) to use that feature.
>> also it's metadata that the pve nodes themselves won't really use
>
> If you really want to provide those old releases this features we can port
> it back, that said, EOL is nearing for those (bit over ~3 months left), and
> especially for this non-essential feature I'd just not bother with that.
understood
>
> And why should we not show this on the PVE nodes themselves? And independent
> of that it's in anyway a property of a PVE/ node and tied to that physical
> location, not a property of a PDM remote config entry, which should IMO not
> care about these things, at least not as primary source of such information
> (overrides can be OK, but IMO not really justified here).
yeah, you're right, i'll make it part of the node config for pve/pbs
in a v3, thanks for the feedback!
^ permalink raw reply [flat|nested] 13+ messages in thread
end of thread, other threads:[~2026-05-05 8:47 UTC | newest]
Thread overview: 13+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH yew-widget-toolkit v2 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
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
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.