all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Dominik Csapak" <d.csapak@proxmox.com>, <pdm-devel@lists.proxmox.com>
Subject: Re: [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering
Date: Fri, 22 May 2026 15:30:32 +0200	[thread overview]
Message-ID: <DIP8NLB32WAK.30JBAS1PMTFVU@proxmox.com> (raw)
In-Reply-To: <20260522083412.1223719-3-d.csapak@proxmox.com>

On Fri May 22, 2026 at 10:33 AM CEST, Dominik Csapak wrote:
> 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       | 553 +++++++++++++++++++++++++++++
>  src/widget/charts/map/zoom_info.rs | 193 ++++++++++
>  src/widget/charts/mod.rs           |   6 +
>  4 files changed, 868 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};
> +

-->8 snip 8<--

> +
> +pub trait MapPointData: PartialEq + Clone {

nit: i think this public trait could use a doc comment.

> +    /// 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..c55a163
> --- /dev/null
> +++ b/src/widget/charts/map/mod.rs
> @@ -0,0 +1,553 @@
> +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;

nit: should be first dependency if we follow our usual order

> +
> +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};
> +
> +/// 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

nit: SVG is an abbreviation here and should be capitalized, also missing
period here

> +    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>>,
> +
> +    #[prop_or(30.0)]
> +    #[builder]
> +    /// The maximum zoom level that is allowed.
> +    max_zoom_level: f64,
> +
> +    #[prop_or(8.0)]
> +    #[builder]
> +    /// The radius for info points.
> +    info_point_radius: f64,
> +}
> +

-->8 snip 8<--

> +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 = ctx.props().info_point_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;

imo this loop could be merged into the above one, making this a little
bit more efficient.

generally this could probably be made more efficient, but i didn't
really find a performance issue and this code is legible and decoupled
enough that we can easily clean it up if it really becomes a problem
down the line.

> +
> +            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;
> +    }
> +}
> +

-->8 snip 8<--

> +    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 = props.info_point_radius / self.fit_scale;
> +
> +        let zoom_level = self.zoom.get_zoom_level();
> +        let is_zoomed = zoom_level != 1.0;
> +        let fully_zoomed = zoom_level >= props.max_zoom_level;
> +
> +        let svg = Canvas::new()
> +            .onwheel({
> +                let link = link.clone();
> +                move |event: WheelEvent| {
> +                    // don't scroll the remaining page when scrolling in map
> +                    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,

should this last one be `Some("gab")`? for me the cursor does not reset
from grabbing back to grab if let go (it does eventually when i hover
over something else in the map).

> +                },
> +            )
> +            .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;
> +

-->8 snip 8<--

> +    /// 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;

nit: could do something like this instead that feels a little nicer to
me, but no hard feelings:

    let mut points = points.into_iter().peekable();

    if points.peek().is_none() {
        return;
    }

> +
> +        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));
> +    }

-->8 snip 8<--




  reply	other threads:[~2026-05-22 13:30 UTC|newest]

Thread overview: 19+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-22  8:33 [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Dominik Csapak
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz [this message]
2026-05-22  8:33 ` [PATCH yew-widget-toolkit v3 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-22  8:34 ` [PATCH yew-widget-toolkit-assets v3 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-22  8:34 ` [PATCH proxmox-geojson-data v3 1/1] initial commit Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  8:34 ` [PATCH datacenter-manager v3 1/6] server: pbs client: add node_config method Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 2/6] lib/api: add 'location-info' api call with cached information Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  8:34 ` [PATCH datacenter-manager v3 3/6] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 4/6] server: serve geojson worldmap Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 5/6] ui: views: refactor required api call info into struct Dominik Csapak
2026-05-22  8:34 ` [PATCH datacenter-manager v3 6/6] ui: views: add map component Dominik Csapak
2026-05-22 13:30   ` Shannon Sterz
2026-05-22  9:38 ` [PATCH datacenter-manager/proxmox-geojson-data/yew-widget-toolkit/yew-widget-toolkit-assets v3 00/11] add a new map widget for custom views Thomas Lamprecht
2026-05-22 13:33 ` Shannon Sterz
2026-05-24  2:31 ` applied: " Thomas Lamprecht

Reply instructions:

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

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

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

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

  git send-email \
    --in-reply-to=DIP8NLB32WAK.30JBAS1PMTFVU@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

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

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is 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.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal