From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 460391FF142 for ; Tue, 05 May 2026 09:32:18 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id A13731808B; Tue, 5 May 2026 09:32:15 +0200 (CEST) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Subject: [PATCH yew-widget-toolkit v2 2/3] widget: charts: add interactive Map with zoom/pan and clustering Date: Tue, 5 May 2026 09:31:53 +0200 Message-ID: <20260505073203.398548-3-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260505073203.398548-1-d.csapak@proxmox.com> References: <20260505073203.398548-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.050 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [mod.rs] Message-ID-Hash: 3LA54UHJLCM7KY6J453HVW6QJZJKUM7V X-Message-ID-Hash: 3LA54UHJLCM7KY6J453HVW6QJZJKUM7V X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This exposes a generict Map that takes a SVG element (intended to be the background map) and draws typed Points (MapPoint) over it. These can implement various render functions for clustered points. The Map handles: * Interaction (zooming, panning, toggling info cards on the points, tooltip, etc.), it also supports touch input (pinch zooming and panning). * Rendering of background and points * Clustering of points depending on the zoom level (combine points that are too close) Shows an interaction panel in the top right to zoom out/in and show the whole map. Signed-off-by: Dominik Csapak --- src/widget/charts/map/map_point.rs | 116 +++++++ src/widget/charts/map/mod.rs | 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], + pub center: Coordinates, + pub selected: bool, + pub suggested_radius: f64, +} + +/// The default renderer for a map cluster. +pub fn render_point_default(args: &PointsRenderArgs) -> 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(args: &PointsRenderArgs) -> 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(args: &PointsRenderArgs) -> 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) -> Group { + render_point_default(args) + } + + /// Render the info box for a cluster of points. + /// + /// Uses [render_info_default] by default. + fn render_info(args: &PointsRenderArgs) -> Html { + render_info_default(args) + } + + /// Render the tooltip for a cluster of points. + /// + /// Uses [render_tooltip_default] by default. + fn render_tooltip(args: &PointsRenderArgs) -> Html { + render_tooltip_default(args) + } +} + +impl MapPointData for AttrValue { + fn render_title(&self) -> AttrValue { + self.clone() + } +} + +impl MapPointData for String { + fn render_title(&self) -> AttrValue { + self.clone().into() + } +} diff --git a/src/widget/charts/map/mod.rs b/src/widget/charts/map/mod.rs new file mode 100644 index 0000000..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 { + /// The coordinates of the point on a [Map]. + pub coordinates: Coordinates, + pub data: T, +} + +impl MapPoint { + /// Create a new [MapPoint] with the given data and coordinates. + pub fn new(coordinates: Coordinates, data: T) -> Self { + Self { coordinates, data } + } +} + +/// An interactive Map that handles interaction (zooming, panning) and draws +/// [MapPoint]s on top of the given SVG element. +/// +/// Can handle touch and mouse input. +#[widget(pwt=crate,comp=MapComp, @element)] +#[builder] +#[derive(Properties, PartialEq, Clone)] +pub struct Map { + /// 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>, +} + +impl Map { + /// Creates a new interactive map with the given background SVG map element + pub fn new(map: impl Into) -> 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, +} + +pub struct MapComp { + 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, + grab_start: Option<(f64, f64)>, + clusters: Vec, + _phantom_data: PhantomData, +} + +impl MapComp { + fn create_tooltip(&self, args: &PointsRenderArgs) -> Html { + Container::new() + .attribute("role", "tooltip") + .attribute("aria-live", "polite") + .attribute("data-show", Some("")) + .class("pwt-tooltip") + .class("pwt-tooltip-rich") + .with_child(T::render_tooltip(args)) + .into_html_with_ref(self.tooltip_ref.clone()) + } + + fn create_info(&self, args: &PointsRenderArgs) -> Html { + Card::new() + .class("pwt-map-info") + .with_child(T::render_info(args)) + .into_html_with_ref(self.info_ref.clone()) + } + + fn cluster_points(&mut self, ctx: &Context) { + // simple algorithm to find overlapping points and cluster them together + + let points = &ctx.props().points; + let mut indices: Vec = (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 yew::Component for MapComp { + type Message = Msg; + type Properties = Map; + + fn create(ctx: &Context) -> 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::, + }; + + this.cluster_points(ctx); + + this + } + + fn update(&mut self, ctx: &Context, 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) -> 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, 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, _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, 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