public inbox for pdm-devel@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal