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::{BezPath, Point, Vec2};

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

pub struct RadarSeriesComponent {
    series: RadarSeries,
    series_index: usize,
    grid_index: usize,
    radar_config: Option<RadarConfig>,
}

impl RadarSeriesComponent {
    pub fn new(
        series: &RadarSeries,
        series_index: usize,
        radar_config: Option<&RadarConfig>,
    ) -> Self {
        Self {
            series: series.clone(),
            series_index,
            grid_index: 0,
            radar_config: radar_config.cloned(),
        }
    }

    fn get_center_and_radius(&self, ctx: &SeriesContext) -> (Point, f64, 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 config = self.radar_config.as_ref();

        let center_x = config.map(|c| c.center.0 / 100.0).unwrap_or(0.5);
        let center_y = config.map(|c| c.center.1 / 100.0).unwrap_or(0.5);
        let radius_inner = config.map(|c| c.radius.0 / 100.0).unwrap_or(0.0);
        let radius_outer = config.map(|c| c.radius.1 / 100.0).unwrap_or(0.75);

        let cx = chart_x + chart_width * center_x;
        let cy = chart_y + chart_height * center_y;
        let max_radius = chart_width.min(chart_height) / 2.0;
        let outer_r = max_radius * radius_outer;
        let inner_r = max_radius * radius_inner;

        (Point::new(cx, cy), inner_r, outer_r)
    }

    fn build_radar_grid(&self, center: Point, outer_r: f64, inner_r: f64) -> Vec<VisualElement> {
        let mut elements = Vec::new();
        let config = match &self.radar_config {
            Some(c) => c,
            None => return elements,
        };
        let n = config.indicator.len();
        if n == 0 {
            return elements;
        }

        let split_number = config.split_number;
        let angle_step = 2.0 * PI / n as f64;
        let start_angle = -PI / 2.0;

        let directions: Vec<Vec2> = (0..n)
            .map(|i| {
                let angle = start_angle + i as f64 * angle_step;
                Vec2::new(angle.cos(), angle.sin())
            })
            .collect();

        let grid_color = Color::new(200, 200, 200);

        for level in 1..=split_number {
            let r = inner_r + (outer_r - inner_r) * (level as f64 / split_number as f64);
            let mut points: Vec<Point> = directions
                .iter()
                .map(|dir| Point::new(center.x + dir.x * r, center.y + dir.y * r))
                .collect();
            points.push(points[0]);

            elements.push(VisualElement::Polyline {
                points,
                style: StrokeStyle {
                    color: grid_color,
                    width: 0.5,
                },
            });
        }

        for dir in &directions {
            let end = Point::new(center.x + dir.x * outer_r, center.y + dir.y * outer_r);
            elements.push(VisualElement::Line {
                start: center,
                end,
                style: StrokeStyle {
                    color: grid_color,
                    width: 0.5,
                },
            });
        }

        if config.name.show {
            for (i, indicator) in config.indicator.iter().enumerate() {
                let dir = directions[i];
                let label_pos = Point::new(
                    center.x + dir.x * (outer_r + 15.0),
                    center.y + dir.y * (outer_r + 15.0),
                );

                let align = if dir.x.abs() < 0.01 {
                    TextAlign::Center
                } else if dir.x > 0.0 {
                    TextAlign::Left
                } else {
                    TextAlign::Right
                };

                let baseline = if dir.y.abs() < 0.01 {
                    TextBaseline::Middle
                } else if dir.y > 0.0 {
                    TextBaseline::Top
                } else {
                    TextBaseline::Bottom
                };

                let name_font = &config.name.text_style;
                let layout_obj = create_text_layout(&indicator.name, name_font, None);
                let text_width = layout_obj.width() as f64;
                let text_height = layout_obj.height() as f64;

                let dx = match align {
                    TextAlign::Left => 0.0,
                    TextAlign::Center => -text_width / 2.0,
                    TextAlign::Right => -text_width,
                };
                let dy_adj = match baseline {
                    TextBaseline::Top => 0.0,
                    TextBaseline::Middle => -text_height / 2.0,
                    TextBaseline::Bottom => -text_height,
                    _ => 0.0,
                };

                elements.push(VisualElement::TextRun {
                    text: indicator.name.clone(),
                    position: Point::new(label_pos.x + dx, label_pos.y + dy_adj),
                    style: crate::model::TextStyle {
                        color: name_font.color,
                        font_size: name_font.font_size,
                        font_family: name_font.font_family.clone(),
                        font_weight: name_font.font_weight,
                        font_style: name_font.font_style,
                        align,
                        vertical_align: baseline,
                    },
                    rotation: 0.0,
                    max_width: None,
                    layout: Some(layout_obj),
                });
            }
        }

        elements
    }

    fn build_data_polygon(&self, center: Point, outer_r: f64, inner_r: f64) -> Vec<VisualElement> {
        let mut elements = Vec::new();
        let config = match &self.radar_config {
            Some(c) => c,
            None => return elements,
        };
        let n = config.indicator.len();
        if n == 0 || self.series.data.is_empty() {
            return elements;
        }

        let angle_step = 2.0 * PI / n as f64;
        let start_angle = -PI / 2.0;

        let directions: Vec<Vec2> = (0..n)
            .map(|i| {
                let angle = start_angle + i as f64 * angle_step;
                Vec2::new(angle.cos(), angle.sin())
            })
            .collect();

        for data_item in &self.series.data {
            let values = &data_item.value;
            if values.len() < n {
                continue;
            }

            let mut points: Vec<Point> = Vec::new();
            for (i, &val) in values.iter().enumerate().take(n) {
                let max_val = config.indicator[i].max;
                let ratio = if max_val > 0.0 {
                    (val / max_val).clamp(0.0, 1.0)
                } else {
                    0.0
                };
                let r = inner_r + (outer_r - inner_r) * ratio;
                let dir = directions[i];
                points.push(Point::new(center.x + dir.x * r, center.y + dir.y * r));
            }

            if let Some(area_style) = &self.series.area_style
                && points.len() >= 2
            {
                let mut path = BezPath::new();
                path.move_to(points[0]);
                for pt in points.iter().skip(1) {
                    path.line_to(*pt);
                }
                path.close_path();

                elements.push(VisualElement::Path {
                    path,
                    style: FillStrokeStyle {
                        fill: Some(
                            area_style
                                .color
                                .unwrap_or(self.series.color)
                                .set_alpha(area_style.opacity),
                        ),
                        stroke: None,
                    },
                });
            }

            let mut line_points = points.clone();
            line_points.push(points[0]);
            elements.push(VisualElement::Polyline {
                points: line_points,
                style: StrokeStyle {
                    color: self.series.line_style.color,
                    width: self.series.line_style.width,
                },
            });

            for pt in &points {
                elements.push(VisualElement::Circle {
                    center: *pt,
                    radius: self.series.symbol_size,
                    style: FillStrokeStyle {
                        fill: Some(self.series.color),
                        stroke: Some(Stroke::new(Color::new(255, 255, 255), 1.0)),
                    },
                });
            }
        }

        elements
    }
}

impl SeriesComponent for RadarSeriesComponent {
    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 RadarSeriesComponent {
    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, inner_r, outer_r) = self.get_center_and_radius(&ctx);

        let mut elements = Vec::new();
        elements.extend(self.build_radar_grid(center, outer_r, inner_r));
        elements.extend(self.build_data_polygon(center, outer_r, inner_r));

        elements
    }
}