From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 2070B1FF142 for ; Fri, 22 May 2026 15:30:38 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A8E58BF71; Fri, 22 May 2026 15:30:36 +0200 (CEST) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Fri, 22 May 2026 15:30:32 +0200 Subject: Re: [PATCH yew-widget-toolkit v3 2/3] widget: charts: add interactive Map with zoom/pan and clustering To: "Dominik Csapak" , Message-Id: X-Mailer: aerc 0.20.0 References: <20260522083412.1223719-1-d.csapak@proxmox.com> <20260522083412.1223719-3-d.csapak@proxmox.com> In-Reply-To: <20260522083412.1223719-3-d.csapak@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1779456613605 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.113 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 66SJY5UANJLMRYOD3VTCM2I4LCCPSQ7I X-Message-ID-Hash: 66SJY5UANJLMRYOD3VTCM2I4LCCPSQ7I X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: On Fri May 22, 2026 at 10:33 AM CEST, Dominik Csapak wrote: > This exposes a generict Map that takes a SVG element (intended to be > the background map) and draws typed Points (MapPoint) > 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 > --- > 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/m= ap_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) -> 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) -> Html { > + render_info_default(args) > + } > + > + /// Render the tooltip for a cluster of points. > + /// > + /// Uses [render_tooltip_default] by default. > + fn render_tooltip(args: &PointsRenderArgs) -> 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_de= fault, > + 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, Gest= urePinchZoomEvent}; > +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 { > + /// The coordinates of the point on a [Map]. > + pub coordinates: Coordinates, > + pub data: T, > +} > + > +impl MapPoint { > + /// 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 d= raws > +/// [MapPoint]s on top of the given SVG element. > +/// > +/// Can handle touch and mouse input. > +#[widget(pwt=3Dcrate,comp=3DMapComp, @element)] > +#[builder] > +#[derive(Properties, PartialEq, Clone)] > +pub struct Map { > + /// 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>, > + > + #[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 MapComp { > + fn create_tooltip(&self, args: &PointsRenderArgs) -> 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) -> 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) { > + // simple algorithm to find overlapping points and cluster them = together > + > + let points =3D &ctx.props().points; > + let mut indices: Vec =3D (0..ctx.props().points.len()).co= llect(); > + > + let effective_radius =3D ctx.props().info_point_radius / self.fi= t_scale; > + let mut clusters =3D Vec::new(); > + while let Some(index) =3D indices.pop() { > + let base =3D &points[index]; > + let mut overlapping =3D Vec::new(); > + let mut non_overlapping =3D Vec::new(); > + let base_coordinates =3D self.zoom.map_point(base.coordinate= s); > + > + for compare_index in indices.into_iter() { > + let p =3D &points[compare_index]; > + let point_coordinates =3D self.zoom.map_point(p.coordina= tes); > + let dx =3D base_coordinates.x - point_coordinates.x; > + let dy =3D 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 =3D non_overlapping; > + overlapping.insert(0, index); > + > + let mut x_center =3D 0.0; > + let mut y_center =3D 0.0; > + for index in overlapping.iter() { > + let coordinates =3D points[*index].coordinates; > + x_center +=3D coordinates.x; > + y_center +=3D coordinates.y; > + } > + x_center /=3D overlapping.len() as f64; > + y_center /=3D 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) =3D 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 =3D clusters > + .iter() > + .position(|indices| *indices =3D=3D self.clusters[index]= ); > + } > + self.clusters =3D clusters; > + } > +} > + -->8 snip 8<-- > + fn view(&self, ctx: &Context) -> Html { > + let props =3D ctx.props(); > + let link =3D ctx.link(); > + let width =3D props.width; > + let height =3D props.height; > + > + let effective_radius =3D props.info_point_radius / self.fit_scal= e; > + > + let zoom_level =3D self.zoom.get_zoom_level(); > + let is_zoomed =3D zoom_level !=3D 1.0; > + let fully_zoomed =3D zoom_level >=3D props.max_zoom_level; > + > + let svg =3D Canvas::new() > + .onwheel({ > + let link =3D 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) =3D (event.delta_y(), event.client= _x(), event.client_y()); > + let action =3D 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) =3D> Some("grabbing"), > + (true, false) =3D> Some("grab"), > + (false, _) =3D> 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 =3D Group::new(); > + let mut tooltip =3D None; > + let mut info =3D None; > + -->8 snip 8<-- > + /// Changes the scale and pan so that all points of the list are inc= luded including the padding > + pub fn zoom_to_points(&mut self, points: impl IntoIterator, padding: f64) { > + let points =3D points.into_iter(); > + > + let mut x_min =3D f64::INFINITY; > + let mut x_max =3D f64::NEG_INFINITY; > + let mut y_min =3D f64::INFINITY; > + let mut y_max =3D f64::NEG_INFINITY; > + > + let mut got_points =3D false; nit: could do something like this instead that feels a little nicer to me, but no hard feelings: let mut points =3D points.into_iter().peekable(); if points.peek().is_none() { return; } > + > + for Coordinates { x, y } in points { > + got_points =3D true; > + if x > x_max { > + x_max =3D x; > + } > + if y > y_max { > + y_max =3D y; > + } > + if x < x_min { > + x_min =3D x; > + } > + if y < y_min { > + y_min =3D y; > + } > + } > + > + if !got_points { > + return; > + } > + > + let width =3D x_max - x_min + 2.0 * padding; > + let height =3D y_max - y_min + 2.0 * padding; > + > + let mid_point =3D 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 / se= lf.height)); > + } -->8 snip 8<--