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<--
next prev parent 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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox