all lists on lists.proxmox.com
 help / color / mirror / Atom feed
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





  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 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