public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
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	[thread overview]
Message-ID: <20260320160816.4113364-4-d.csapak@proxmox.com> (raw)
In-Reply-To: <20260320160816.4113364-1-d.csapak@proxmox.com>

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 <d.csapak@proxmox.com>
---
 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<AttrValue>,
+
+    #[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<Vec<AttrValue>>,
+
+    #[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<bool>,
+
+    #[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<RenderFn<(AttrValue, f64, usize)>>,
+
+    #[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<LegendPosition>,
+
+    #[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<AttrValue>, 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<AttrValue>, f64)>) -> Self {
+        yew::props!(Self {
+            values: values
+                .into_iter()
+                .map(|(title, value)| (title.into(), value))
+                .collect::<Vec<_>>()
+        })
+    }
+
+    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<usize>),
+    MouseOver(Option<(i32, i32)>),
+    ToggleValue(usize),
+}
+
+pub struct PwtPieChart {
+    radius: f32,
+    stroke_width: f32,
+    start: f64,
+    max_len: f64,
+    circumference: f64,
+    highlight: Option<usize>,
+    mouse_pos: Option<(f64, f64)>,
+    tooltip_ref: NodeRef,
+    svg_ref: NodeRef,
+    hidden: HashSet<usize>,
+    segments: Vec<Segment>,
+    bottom_diff: f64,
+    left_diff: f64,
+    right_diff: f64,
+}
+
+impl PwtPieChart {
+    fn new(ctx: &Context<Self>) -> 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<Self>) {
+        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<Self>) {
+        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<Self>) -> Option<Container> {
+        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<Self>) -> Option<Html> {
+        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 {
+        Self::new(ctx)
+    }
+
+    fn changed(&mut self, ctx: &Context<Self>, 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<Self>, 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<Self>) -> 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<Self>, _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





      parent reply	other threads:[~2026-03-20 16:08 UTC|newest]

Thread overview: 4+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-03-20 16:03 [RFC PATCH yew-widget-toolkit 0/3] implement " Dominik Csapak
2026-03-20 16:03 ` [RFC PATCH yew-widget-toolkit 1/3] widget: button: add 'icon_style' property Dominik Csapak
2026-03-20 16:03 ` [RFC PATCH yew-widget-toolkit 2/3] macros: widget: impl WidgetStyleBuilder for svgs Dominik Csapak
2026-03-20 16:04 ` Dominik Csapak [this message]

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=20260320160816.4113364-4-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=yew-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