From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [PATCH yew-widget-toolkit 2/3] widget: charts: add interactive Map with zoom/pan and clustering
Date: Mon, 4 May 2026 14:44:49 +0200 [thread overview]
Message-ID: <20260504124515.2956574-3-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260504124515.2956574-1-d.csapak@proxmox.com>
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
next prev parent reply other threads:[~2026-05-04 12:45 UTC|newest]
Thread overview: 13+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-05-04 12:44 [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 1/3] js-helper: add client-to-svg-coordinate conversion helper Dominik Csapak
2026-05-04 12:44 ` Dominik Csapak [this message]
2026-05-04 12:44 ` [PATCH yew-widget-toolkit 3/3] widget: charts: add WorldMap with GeoJSON rendering Dominik Csapak
2026-05-04 12:44 ` [PATCH yew-widget-toolkit-assets 1/1] charts: add necessary classes for Map Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 1/4] lib/api/ui: add location property to remote config Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 2/4] lib/api: add new 'remote-list' info to the resource status Dominik Csapak
2026-05-04 12:44 ` [PATCH datacenter-manager 4/4] ui: views: add map component Dominik Csapak
2026-05-04 12:49 ` [PATCH datacenter-manager/yew-widget-toolkit/yew-widget-toolkit-assets 0/8] add a new map widget for custom views Dominik Csapak
2026-05-05 8:28 ` Thomas Lamprecht
2026-05-05 8:39 ` Dominik Csapak
2026-05-05 8:51 ` Thomas Lamprecht
2026-05-05 7:38 ` superseded: " Dominik Csapak
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=20260504124515.2956574-3-d.csapak@proxmox.com \
--to=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