liecharts 0.1.0-beta.1

A Rust charting library with PNG and SVG rendering support
Documentation
use std::f64::consts::PI;

use vello_cpu::kurbo::{Arc, BezPath, PathSeg, Point, Shape, Vec2};

use crate::{
    component::{ChartComponent, SeriesComponent, SeriesContext},
    layout::LayoutOutput,
    model::{ChartModel, PolarBarSeries},
    text::create_text_layout,
    visual::{Color, FillStrokeStyle, Stroke, StrokeStyle, TextAlign, TextBaseline, VisualElement},
};

pub struct PolarBarSeriesComponent {
    series: PolarBarSeries,
    series_index: usize,
    grid_index: usize,
}

impl PolarBarSeriesComponent {
    pub fn new(series: &PolarBarSeries, series_index: usize) -> Self {
        Self {
            series: series.clone(),
            series_index,
            grid_index: 0,
        }
    }

    fn get_center_and_radius(&self, ctx: &SeriesContext) -> (Point, f64) {
        let plot_bounds = ctx.plot_bounds();
        let chart_width = plot_bounds.width();
        let chart_height = plot_bounds.height();
        let chart_x = plot_bounds.x0;
        let chart_y = plot_bounds.y0;

        let cx = chart_x + chart_width * 0.5;
        let cy = chart_y + chart_height * 0.5;
        let max_radius = chart_width.min(chart_height) / 2.0;

        (Point::new(cx, cy), max_radius)
    }

    fn build_grid(&self, center: Point, max_radius: f64) -> Vec<VisualElement> {
        let mut elements = Vec::new();
        let grid_color = Color::new(200, 200, 200);
        let stroke = Stroke::new(grid_color, 1.0);
        let stroke_style = StrokeStyle::new(grid_color, 1.0);

        let max_value = self.series.data.iter().map(|d| d.value).fold(0.0, f64::max);

        let split_number = 5;
        for i in 1..=split_number {
            let r = max_radius * (i as f64 / split_number as f64);
            elements.push(VisualElement::Circle {
                center,
                radius: r,
                style: FillStrokeStyle {
                    fill: None,
                    stroke: Some(stroke.clone()),
                },
            });

            let label_value = max_value * (i as f64 / split_number as f64);
            let label_text = format!("{:.0}", label_value);
            let label_pos = Point::new(center.x, center.y - r);

            let label_font = crate::model::TextStyle {
                font_size: 10.0,
                font_family: "sans-serif".to_string(),
                color: Color::new(100, 100, 100),
                font_weight: crate::option::FontWeight::Named(
                    crate::option::FontWeightNamed::Normal,
                ),
                ..Default::default()
            };

            let layout_obj = create_text_layout(&label_text, &label_font, None);
            let th = layout_obj.height() as f64;

            elements.push(VisualElement::TextRun {
                text: label_text,
                position: Point::new(label_pos.x + 3.0, label_pos.y - th / 2.0),
                style: crate::model::TextStyle {
                    color: label_font.color,
                    font_size: label_font.font_size,
                    font_family: label_font.font_family,
                    font_weight: label_font.font_weight,
                    font_style: label_font.font_style,
                    align: TextAlign::Left,
                    vertical_align: TextBaseline::Top,
                },
                rotation: 0.0,
                max_width: None,
                layout: Some(layout_obj),
            });
        }

        let n = self.series.data.len();
        if n > 0 {
            let total_angle = 2.0 * PI;
            let pad_angle = self.series.pad_angle;
            let available_angle = total_angle - pad_angle * n as f64;
            let bar_angle = available_angle / n as f64;
            let start_angle = self.series.start_angle;

            for i in 0..n {
                let angle = start_angle + i as f64 * (bar_angle + pad_angle) + bar_angle / 2.0;
                let end_x = center.x + angle.cos() * max_radius;
                let end_y = center.y + angle.sin() * max_radius;

                elements.push(VisualElement::Line {
                    start: center,
                    end: Point::new(end_x, end_y),
                    style: stroke_style.clone(),
                });
            }
        }

        elements
    }

    fn build_bars(&self, center: Point, max_radius: f64) -> Vec<VisualElement> {
        let mut elements = Vec::new();

        if self.series.data.is_empty() {
            return elements;
        }

        let n = self.series.data.len();
        let total_angle = 2.0 * PI;
        let pad_angle = self.series.pad_angle;
        let available_angle = total_angle - pad_angle * n as f64;
        let bar_angle = available_angle / n as f64;
        let start_angle = self.series.start_angle;

        let max_value = self.series.data.iter().map(|d| d.value).fold(0.0, f64::max);
        let radius_scale = if max_value > 0.0 { 1.0 } else { 0.0 };

        for (i, data_item) in self.series.data.iter().enumerate() {
            let angle_start = start_angle + i as f64 * (bar_angle + pad_angle);
            let angle_end = angle_start + bar_angle;

            let radius_ratio = if max_value > 0.0 {
                (data_item.value / max_value).clamp(0.0, 1.0)
            } else {
                0.0
            };
            let outer_r = max_radius * radius_ratio * radius_scale;

            if outer_r < 1.0 {
                continue;
            }

            let color = self
                .series
                .colors
                .get(i % self.series.colors.len())
                .copied()
                .unwrap_or(Color::new(100, 100, 100));

            let mut path = BezPath::new();
            path.move_to(center);

            let outer_start = Point::new(
                center.x + angle_start.cos() * outer_r,
                center.y + angle_start.sin() * outer_r,
            );
            path.line_to(outer_start);

            let sweep_angle = angle_end - angle_start;
            if sweep_angle.abs() > 1e-12 {
                let arc = Arc::new(
                    center,
                    Vec2::new(outer_r, outer_r),
                    angle_start,
                    sweep_angle,
                    0.0,
                );
                arc.to_path(0.5).segments().for_each(|seg| match seg {
                    PathSeg::Line(line) => path.line_to(line.p1),
                    PathSeg::Quad(quad) => path.quad_to(quad.p1, quad.p2),
                    PathSeg::Cubic(cubic) => path.curve_to(cubic.p1, cubic.p2, cubic.p3),
                });
            }

            path.line_to(center);
            path.close_path();

            elements.push(VisualElement::Path {
                path,
                style: FillStrokeStyle {
                    fill: Some(color),
                    stroke: Some(Stroke::new(Color::new(255, 255, 255), 1.0)),
                },
            });
        }

        elements
    }
}

impl SeriesComponent for PolarBarSeriesComponent {
    fn series_index(&self) -> usize {
        self.series_index
    }

    fn grid_index(&self) -> usize {
        self.grid_index
    }

    fn is_empty(&self) -> bool {
        self.series.data.is_empty()
    }
}

impl ChartComponent for PolarBarSeriesComponent {
    fn build_visual_elements(
        &self,
        resolved: &ChartModel,
        layout: &LayoutOutput,
    ) -> Vec<VisualElement> {
        let ctx = match self.create_context(resolved, layout) {
            Some(ctx) => ctx,
            None => return Vec::new(),
        };

        let (center, max_radius) = self.get_center_and_radius(&ctx);

        let mut elements = Vec::new();
        elements.extend(self.build_grid(center, max_radius));
        elements.extend(self.build_bars(center, max_radius));

        elements
    }
}