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 484CA1FF13E for ; Fri, 20 Mar 2026 17:08:37 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id B55311C290; Fri, 20 Mar 2026 17:08:53 +0100 (CET) From: Dominik Csapak To: yew-devel@lists.proxmox.com Subject: [RFC PATCH yew-widget-toolkit 3/3] add pie chart widget Date: Fri, 20 Mar 2026 17:04:00 +0100 Message-ID: <20260320160816.4113364-4-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260320160816.4113364-1-d.csapak@proxmox.com> References: <20260320160816.4113364-1-d.csapak@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.041 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 3JPHSBRNDMIQP4S7DMZB675IOQT32G7V X-Message-ID-Hash: 3JPHSBRNDMIQP4S7DMZB675IOQT32G7V 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: Yew framework devel list at Proxmox List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: and introduce the 'charts' module for that. This widget has the following features: * use as 'gauge' with a single value * use as 'pie' or 'donut' chart when using multiple values * customizable colors,tooltip and legend position * start and end angles can be customized (within reasonable limits) * segments can be hidden via the legend * can show text in the middle (most useful for gauge charts) Signed-off-by: Dominik Csapak --- src/widget/charts/mod.rs | 4 + src/widget/charts/pie.rs | 547 +++++++++++++++++++++++++++++++++++++++ src/widget/mod.rs | 2 + 3 files changed, 553 insertions(+) create mode 100644 src/widget/charts/mod.rs create mode 100644 src/widget/charts/pie.rs diff --git a/src/widget/charts/mod.rs b/src/widget/charts/mod.rs new file mode 100644 index 0000000..42491f7 --- /dev/null +++ b/src/widget/charts/mod.rs @@ -0,0 +1,4 @@ +//! Chart components + +mod pie; +pub use pie::{LegendPosition, PieChart}; diff --git a/src/widget/charts/pie.rs b/src/widget/charts/pie.rs new file mode 100644 index 0000000..bfd6080 --- /dev/null +++ b/src/widget/charts/pie.rs @@ -0,0 +1,547 @@ +//! Pie Chart +//! +//! Provides an element that can be used for a 'gauge' by supplying a single value, +//! a 'pie' chart by providing multiple values with an 'thickness_ratio' of 1.0 or +//! a 'donut' chart. +//! +//! Includes the following features +//! * An optional legend +//! * configuring colors +//! * specifying start and end angles +//! * animated highlighting of segments +//! * hiding segments (via clicking on the legend) +//! * rendering tooltips (with an optional custom renderer) +//! * showing a text in the middle (useful for gauges/donut charts) + +use std::collections::HashSet; + +use yew::html::IntoPropValue; +use yew::prelude::*; + +use crate::css; +use crate::dom::align::align_to_xy; +use crate::prelude::*; +use crate::props::{IntoOptionalRenderFn, RenderFn}; +use crate::widget::canvas::{Canvas, Circle, Group, Text}; +use crate::widget::{Button, Container}; + +use pwt_macros::{builder, widget}; + +// default colors +const DEFAULT_COLORS: &[&str] = &[ + "var(--pwt-color-primary)", + "var(--pwt-color-secondary)", + "var(--pwt-color-tertiary)", +]; + +const GAUGE_COLORS: &[(f64, &str)] = &[ + (0.9, "var(--pwt-color-error)"), + (0.7, "var(--pwt-color-warning)"), + (0.0, "var(--pwt-color-primary)"), +]; + +// base size for the viewBox (before recalculating due to the start/end angles) +const SIZE: f64 = 110.0; + +#[derive(PartialEq, Eq, Clone, Copy, Debug)] +/// The position of the legend relative to a chart. +pub enum LegendPosition { + Hidden, + Start, + End, + Top, + Bottom, +} + +#[widget(pwt=crate, comp=PwtPieChart, @element)] +#[derive(Properties, Clone, PartialEq)] +#[builder] +/// Pie chart properties +pub struct PieChart { + #[builder] + #[prop_or(0.3)] + /// The ratio of the ring to the inner radius of the pie chart, must be between 0.01 and 1.0. + /// + /// 1.0 results in a pie chart, values below in a donut shaped one. + thickness_ratio: f64, + + // the values to show. if only one, it's assumed to be between 0.0 and 1.0 + // and the remainder will be calculated. otherwise it's assumed they're relative + // values + values: Vec<(AttrValue, f64)>, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + /// Text to display inside the chart. Note that the size and relative position of the text does + /// not change when the ratio changes or when the chart is clipped due to different start/end + /// angles. Most useful when using default gauge charts (or only slightly changed angles). + text: Option, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + /// The list of colors to use. Only used when giving multiple values. + /// + /// For the 'gauge' type chart, The 'primary', 'warning' and 'error' colors are used. + /// For the multi-value chart 'primary', 'secondary' and 'tertiary' colors are used. + /// + /// If there are not enough colors for the given amount of values, the colors will + /// be reused and mixed with an increasing amount of the 'surface' color. + colors: Option>, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + /// Allows highlighting the separate value segments. Default is true for charts with multiple + /// values and false for charts with a single value. + allow_highlight: Option, + + #[builder_cb(IntoOptionalRenderFn, into_optional_render_fn, (AttrValue, f64, usize))] + #[prop_or_default] + /// Tooltip renderer to override the default one. The parameters are the title, the value and + /// the index into values. + render_tooltip: Option>, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or(true)] + /// Determine if tooltips are shown or not. + show_tooltip: bool, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + /// Determines the position of the legend. + /// + /// Defaults: + /// for gauge type chart: 'Hidden' + /// for multi-value chart: 'End' + legend: Option, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or(0.0)] + /// The starting angle of the chart in degrees, default is 0.0. + angle_start: f64, + + #[builder(IntoPropValue, into_prop_value)] + #[prop_or(360.0)] + /// The ending angle of the chart in degrees, default is 360.0, + angle_end: f64, +} + +impl PieChart { + /// Creates a new chart with a single value from 0.0 to 1.0 (smaller and larger values will + /// be clamped). + pub fn new(title: impl Into, value: f64) -> Self { + yew::props!(Self { + values: vec![(title.into(), value.clamp(0.0, 1.0))] + }) + } + + /// Creates a new chart with multiple relative values. + pub fn with_values(values: Vec<(impl Into, f64)>) -> Self { + yew::props!(Self { + values: values + .into_iter() + .map(|(title, value)| (title.into(), value)) + .collect::>() + }) + } + + fn segment_get_color(&self, index: usize, value: f64) -> String { + if self.values.len() == 1 { + for (threshold, color) in GAUGE_COLORS { + if value >= *threshold { + return color.to_string(); + } + } + } + let (base_color, cycle) = match &self.colors { + Some(colors) => { + let base = colors[index % colors.len()].to_string(); + let cycle = index / colors.len(); + (base, cycle) + } + None => { + let base = DEFAULT_COLORS[index % DEFAULT_COLORS.len()].to_string(); + let cycle = index / DEFAULT_COLORS.len(); + (base, cycle) + } + }; + format!( + "color-mix(in hsl, {base_color} {}%, var(--pwt-color-surface))", + 100.0 - (20.0 * cycle as f64) + ) + } + + fn effective_legend_position(&self) -> LegendPosition { + self.legend.unwrap_or({ + if self.values.len() == 1 { + LegendPosition::Hidden + } else { + LegendPosition::End + } + }) + } +} + +struct Segment { + dasharray: String, + offset: f64, + highlight_offset: (f64, f64), + tooltip: Html, + color: String, + allow_highlight: bool, +} + +pub enum Msg { + Highlight(Option), + MouseOver(Option<(i32, i32)>), + ToggleValue(usize), +} + +pub struct PwtPieChart { + radius: f32, + stroke_width: f32, + start: f64, + max_len: f64, + circumference: f64, + highlight: Option, + mouse_pos: Option<(f64, f64)>, + tooltip_ref: NodeRef, + svg_ref: NodeRef, + hidden: HashSet, + segments: Vec, + bottom_diff: f64, + left_diff: f64, + right_diff: f64, +} + +impl PwtPieChart { + fn new(ctx: &Context) -> Self { + let mut this = Self { + radius: 0.0, + stroke_width: 0.0, + start: 0.0, + max_len: 0.0, + circumference: 0.0, + highlight: None, + mouse_pos: None, + tooltip_ref: Default::default(), + svg_ref: Default::default(), + hidden: HashSet::new(), + segments: Vec::new(), + bottom_diff: 0.0, + left_diff: 0.0, + right_diff: 0.0, + }; + this.recalculate_draw_values(ctx); + this.recalculate_segments(ctx); + this + } + + fn recalculate_draw_values(&mut self, ctx: &Context) { + let props = ctx.props(); + let ratio = props.thickness_ratio.clamp(0.01, 1.0) as f32; + self.stroke_width = ratio * 50.0; + self.radius = 50.0 - (self.stroke_width / 2.0); + self.circumference = self.radius as f64 * std::f64::consts::TAU; + self.max_len = self.circumference * ((props.angle_end - props.angle_start) / 360.0); + self.start = self.circumference * (props.angle_start / 360.0); + self.left_diff = 0.0; + self.right_diff = 0.0; + self.bottom_diff = 0.0; + + let outer_radius = (self.radius + self.stroke_width / 2.0) as f64; + let inner_radius = outer_radius - self.stroke_width as f64; + if props.angle_start > 0.0 && props.angle_end < 360.0 { + let start_cos = props.angle_start.to_radians().cos(); + let end_cos = props.angle_end.to_radians().cos(); + let cos = start_cos.max(end_cos); + + let bottom_radius = if props.angle_start > 90.0 && props.angle_end < 270.0 { + inner_radius + } else { + outer_radius + }; + + self.bottom_diff = outer_radius - (cos * bottom_radius); + } + + if props.angle_start > 90.0 { + let sin = props.angle_start.to_radians().sin(); + self.left_diff = outer_radius - sin * outer_radius; + } + + if props.angle_end < 270.0 { + let sin = props.angle_end.to_radians().sin(); + self.right_diff = outer_radius + sin * outer_radius; + } + } + + fn recalculate_segments(&mut self, ctx: &Context) { + let props = ctx.props(); + let mut segments = Vec::with_capacity(props.values.len()); + + let sum = if props.values.len() == 1 { + 1.0 + } else { + props + .values + .iter() + .enumerate() + .filter_map(|(index, (_, value))| (!self.hidden.contains(&index)).then_some(value)) + .sum() + }; + + let allow_highlight = if props.values.len() == 1 { + false + } else { + props.allow_highlight.unwrap_or(true) + }; + + let mut last_value = 0.0; + for (index, (title, value)) in props.values.iter().enumerate() { + let is_visible = !self.hidden.contains(&index); + + let highlight_offset = if is_visible { + let percent = (last_value + value * 0.5) / sum; + let angle = ((props.angle_end - props.angle_start) * percent + props.angle_start) + .to_radians(); + (4.0 * angle.cos(), 4.0 * angle.sin()) + } else { + (0.0, 0.0) + }; + // even if this segment is not visible, add the circle but with size 0 so the animation + // has a start point and the segments don't overlap + let offset = -(self.start + last_value / sum * self.max_len); + let length = if is_visible { + // adding 0.01 here to reduce the seams without changing the size too much + value / sum * self.max_len + 0.01 + } else { + 0.0 + }; + let remainder = self.circumference; + let dasharray = format!("{length} {remainder}",); + + if is_visible { + last_value += value; + } + + let tooltip = if let Some(renderer) = &props.render_tooltip { + renderer.apply(&(title.clone(), *value, index)) + } else { + let pct = (value / sum * 100.0).round(); + format!("{}: {} ({pct}%)", title, value).into() + }; + + let color = props.segment_get_color(index, *value); + + segments.push(Segment { + dasharray, + offset, + highlight_offset, + tooltip, + color, + allow_highlight, + }); + } + + self.segments = segments; + } + + fn render_legend(&self, ctx: &Context) -> Option { + let props = ctx.props(); + let direction = match props.effective_legend_position() { + LegendPosition::Hidden => return None, + LegendPosition::Start | LegendPosition::End => css::FlexDirection::Column, + LegendPosition::Top | LegendPosition::Bottom => css::FlexDirection::Row, + }; + let legend = Container::new() + .class("pwt-gap-1") + .class(css::Display::Flex) + .class(direction) + .class(css::AlignItems::Stretch) + .children(self.segments.iter().enumerate().map(|(index, segment)| { + let visible = !self.hidden.contains(&index); + let opacity = if visible { 100 } else { 10 }; + Button::new(props.values[index].0.to_string()) + .icon_class("fa fa-circle") + .icon_style(format!("color: {}; opacity: {opacity}%;", segment.color)) + .on_activate(ctx.link().callback(move |_| Msg::ToggleValue(index))) + .onpointerenter(ctx.link().callback(move |_| Msg::Highlight(Some(index)))) + .onpointerleave(ctx.link().callback(move |_| Msg::Highlight(None))) + .into() + })); + Some(legend) + } + + fn render_tooltip(&self, ctx: &Context) -> Option { + let props = ctx.props(); + match (props.show_tooltip, self.mouse_pos, self.highlight) { + (true, Some(_), Some(index)) => Some( + Container::new() + .attribute("role", "tooltip") + .attribute("aria-live", "polite") + .attribute("data-show", Some("")) + .class("pwt-tooltip") + .class("pwt-tooltip-rich") + .with_child(self.segments[index].tooltip.clone()) + .into_html_with_ref(self.tooltip_ref.clone()), + ), + _ => None, + } + } +} + +impl Component for PwtPieChart { + type Message = Msg; + type Properties = PieChart; + + fn create(ctx: &Context) -> Self { + Self::new(ctx) + } + + fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { + self.recalculate_draw_values(ctx); + if ctx.props().values != old_props.values { + self.hidden = HashSet::new(); + self.mouse_pos = None; + self.highlight = None; + } + self.recalculate_segments(ctx); + true + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::Highlight(highlight) => self.highlight = highlight, + Msg::MouseOver(pos) => { + if let Some((x, y)) = pos { + self.mouse_pos = Some((x as f64, y as f64)); + } else { + self.mouse_pos = None; + } + } + Msg::ToggleValue(index) => { + if self.hidden.contains(&index) { + self.hidden.remove(&index); + self.recalculate_segments(ctx); + } else if self.hidden.len() + 1 < ctx.props().values.len() { + self.hidden.insert(index); + self.recalculate_segments(ctx); + } + } + } + true + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + + let mut group = Group::new().style("transform", "rotate(90deg)"); + + // background for the 'gauge' type chart + if props.values.len() == 1 { + group.add_child( + Circle::new() + .fill("none") + .r(self.radius) + .stroke("var(--pwt-color-surface)") + .stroke_width(self.stroke_width) + .style( + "stroke-dasharray", + format!("{} {}", self.max_len, self.circumference), + ) + .style("stroke-dashoffset", (-self.start).to_string()) + .style("transition", "0.3s"), + ); + }; + + for (index, segment) in self.segments.iter().enumerate() { + let (x_off, y_off) = match self.highlight { + Some(idx) if idx == index && segment.allow_highlight => segment.highlight_offset, + _ => (0.0, 0.0), + }; + + group.add_child( + Circle::new() + .fill("none") + .r(self.radius) + .stroke(segment.color.to_string()) + .stroke_width(self.stroke_width) + .style("stroke-dasharray", segment.dasharray.clone()) + .style("stroke-dashoffset", segment.offset.to_string()) + .style("transition", "0.3s") + .style("transform", format!("translate({x_off}px, {y_off}px)")) + .onpointerenter(ctx.link().callback(move |_| Msg::Highlight(Some(index)))) + .onpointerleave(ctx.link().callback(|_| Msg::Highlight(None))), + ); + + // invisible copy of the original position so we don't flicker between positions when + // the mouse is positioned on parts of the segment that moves out of the way + group.add_child( + Circle::new() + .fill("none") + .r(self.radius) + .stroke("transparent") + .stroke_width(self.stroke_width) + .style("stroke-dasharray", segment.dasharray.clone()) + .style("stroke-dashoffset", segment.offset.to_string()) + .onpointerenter(ctx.link().callback(move |_| Msg::Highlight(Some(index)))) + .onpointerleave(ctx.link().callback(|_| Msg::Highlight(None))), + ); + } + + let width = SIZE - (self.left_diff + self.right_diff); + let height = SIZE - self.bottom_diff; + let mut canvas = Canvas::new() + .onpointermove(ctx.link().callback(|event: PointerEvent| { + Msg::MouseOver(Some((event.client_x(), event.client_y()))) + })) + .onpointerleave(ctx.link().callback(|_| Msg::MouseOver(None))) + .attribute( + "viewBox", + format!( + "{} {} {} {}", + -(width - self.left_diff + self.right_diff) / 2.0, + -(height + self.bottom_diff) / 2.0, + width, + height + ), + ) + .with_child(group); + + canvas.add_optional_child(props.text.as_ref().map(|text| { + Text::new(text.to_string()) + .dy(0) + .attribute("text-anchor", "middle") + .attribute("alignment-baseline", "central") + })); + + Container::new() + .with_std_props(&props.std_props) + .listeners(&props.listeners) + .class(css::Display::Flex) + .class(css::AlignItems::Center) + .class(match props.effective_legend_position() { + LegendPosition::Hidden => css::FlexDirection::Column, + LegendPosition::Bottom => css::FlexDirection::Column, + LegendPosition::Top => css::FlexDirection::ColumnReverse, + LegendPosition::End => css::FlexDirection::Row, + LegendPosition::Start => css::FlexDirection::RowReverse, + }) + .with_child(canvas.into_html_with_ref(self.svg_ref.clone())) + .with_optional_child(self.render_legend(ctx)) + .with_optional_child(self.render_tooltip(ctx)) + .into() + } + + fn rendered(&mut self, _ctx: &Context, _first_render: bool) { + if let Some((x, y)) = self.mouse_pos { + if let Some(tooltip_ref) = self.tooltip_ref.get() { + let _ = align_to_xy( + tooltip_ref, + (x + 20.0, y + 20.0), + crate::dom::align::Point::TopStart, + ); + } + } + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 0df2cbf..176e7c9 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -28,6 +28,8 @@ pub use catalog_loader::CatalogLoader; #[doc(hidden)] pub use catalog_loader::PwtCatalogLoader; +pub mod charts; + mod column; pub use column::Column; -- 2.47.3