rust-office-charts 0.1.2

Render Office Open XML charts to SVG from native Rust or WebAssembly
Documentation
use super::{begin_point_hit, polygon, shade, ChartColors, ChartData, Node, Point, Scene};
use crate::chart::{chart_node, child};
use std::f64::consts::{PI, TAU};
use std::fmt::Write;

struct Camera {
    x: f64,
    y: f64,
    radius: f64,
    thickness: f64,
    sin: f64,
    cos: f64,
    focal: f64,
}

impl Camera {
    fn project(&self, x: f64, z: f64, elevation: f64) -> Point {
        let depth = z * self.cos - elevation * self.sin;
        let scale = 1. / (1. + depth / self.focal);
        Point {
            x: self.x + x * scale,
            y: self.y - (z * self.sin + elevation * self.cos) * scale,
        }
    }

    fn rim(&self, angle: f64, offset: (f64, f64), elevation: f64) -> Point {
        self.project(
            self.radius * angle.sin() + offset.0,
            self.radius * angle.cos() + offset.1,
            elevation,
        )
    }
}

fn property(node: Node, name: &str) -> Option<f64> {
    child(node, name)?
        .attribute("val")?
        .parse::<f64>()
        .ok()
        .filter(|v| v.is_finite())
}

struct Slice {
    index: u32,
    start: f64,
    end: f64,
    offset: (f64, f64),
    fill: String,
    stroke: String,
}

fn arc_angles(start: f64, end: f64) -> impl DoubleEndedIterator<Item = f64> {
    let steps = ((end - start).abs().to_degrees() / 3.).ceil().max(1.) as usize;
    (0..=steps).map(move |i| start + (end - start) * i as f64 / steps as f64)
}

fn side(out: &mut String, cam: &Camera, slice: &Slice, start: f64, end: f64, key: usize) {
    let points: Vec<_> = arc_angles(start, end)
        .map(|a| cam.rim(a, slice.offset, cam.thickness))
        .chain(
            arc_angles(start, end)
                .rev()
                .map(|a| cam.rim(a, slice.offset, 0.)),
        )
        .collect();
    if slice.fill == "none" {
        polygon(out, &points, "none", &slice.stroke);
        return;
    }
    let right = points[0].x;
    let left = cam.rim(end, slice.offset, cam.thickness).x;
    let _ = write!(
        out,
        r#"<defs><linearGradient id="pie-side-{key}" gradientUnits="userSpaceOnUse" x1="{left:.3}" x2="{right:.3}" y1="0" y2="0">"#
    );
    let span = (right - left).max(f64::EPSILON);
    let steps = ((end - start).to_degrees() / 5.).ceil().max(1.) as usize;
    for i in 0..=steps {
        let angle = end - (end - start) * i as f64 / steps as f64;
        let position =
            ((cam.rim(angle, slice.offset, cam.thickness).x - left) / span).clamp(0., 1.);
        let illumination = (0.395 - 0.432 * angle.sin() - 0.303 * angle.cos()).max(0.395);
        let paint = shade(&slice.fill, illumination);
        let _ = write!(
            out,
            r#"<stop offset="{position:.4}" stop-color="{paint}"/>"#
        );
    }
    out.push_str("</linearGradient></defs>");
    polygon(
        out,
        &points,
        &format!("url(#pie-side-{key})"),
        &slice.stroke,
    );
}

pub(super) fn draw(
    scene: &Scene,
    data: &ChartData,
    group: Node,
    colors: &ChartColors,
    out: &mut String,
) {
    let Some((series_index, series)) = data
        .series
        .iter()
        .enumerate()
        .find(|(_, s)| s.chart_type == "pie3D")
    else {
        return;
    };
    let Some(source) = child(group, "ser") else {
        return;
    };
    let view = group
        .ancestors()
        .find(|n| chart_node(*n, "chart"))
        .and_then(|n| child(n, "view3D"));
    let elevation = view
        .and_then(|v| property(v, "rotX"))
        .unwrap_or(14.82)
        .clamp(0., 90.)
        .to_radians();
    let rotation = view
        .and_then(|v| property(v, "rotY"))
        .unwrap_or(0.)
        .to_radians();
    let perspective = view
        .and_then(|v| property(v, "perspective"))
        .unwrap_or(30.)
        .clamp(0., 240.);
    let perspective = if view
        .and_then(|v| child(v, "rAngAx"))
        .is_some_and(|n| !matches!(n.attribute("val"), Some("0" | "false" | "off")))
    {
        0.
    } else {
        perspective
    };
    let inset = 14.4_f64.min(0.1 * scene.width.min(scene.height));
    let mut left = scene.left;
    let mut top = scene.top;
    let mut width = scene.width;
    let mut height = scene.height;
    if colors.spreadsheet_defaults {
        use crate::parse::chart_svg::{font_size, on, text_props, text_width, val};
        let chart = group.ancestors().find(|n| chart_node(*n, "chart"));
        let automatic = chart
            .and_then(|n| child(n, "plotArea"))
            .and_then(|n| child(n, "layout"))
            .and_then(|n| child(n, "manualLayout"))
            .is_none();
        if automatic {
            let title = chart.and_then(|n| child(n, "title"));
            if title.is_some() && !on(val(title, "overlay")) {
                let size = font_size(text_props(title, true).or(text_props(title, false)), 24.);
                let space = size * 1.17 + 12.;
                top += space;
                height -= space;
            }
            if let Some(legend) = chart
                .and_then(|n| child(n, "legend"))
                .filter(|n| !on(val(Some(*n), "overlay")))
            {
                let size = font_size(text_props(Some(legend), false), 40. / 3.);
                let pos = val(Some(legend), "legendPos").unwrap_or("r");
                if matches!(pos, "l" | "r" | "tr") {
                    let longest = series
                        .points
                        .iter()
                        .filter_map(|p| p.categories.first())
                        .map(|c| text_width(&c.label, size))
                        .fold(0., f64::max);
                    let space = longest + size * 1.055 + 13.333;
                    if pos == "l" {
                        left += space;
                    }
                    width -= space;
                } else {
                    let space = size * 1.76 + 10.;
                    if pos == "t" {
                        top += space;
                    }
                    height -= space;
                }
            }
        }
    }
    let inner_height = (height - 2. * inset).max(1.);
    let inner_width = (width - 2. * inset).max(1.);
    let focal = if perspective == 0. {
        f64::INFINITY
    } else {
        inner_height * 1.067 / (perspective.to_radians() * 0.5).min(1.30).tan()
    };
    let depth = 0.243
        * view
            .and_then(|v| property(v, "depthPercent"))
            .unwrap_or(100.)
            .clamp(20., 2000.)
        / 100.;
    let explosion = property(source, "explosion").unwrap_or(0.).clamp(0., 400.) / 100.;
    let offsets: std::collections::HashMap<u32, f64> = source
        .children()
        .filter(|n| chart_node(*n, "dPt"))
        .filter_map(|p| {
            let idx = child(p, "idx")?.attribute("val")?.parse().ok()?;
            Some((idx, property(p, "explosion")?.clamp(0., 400.) / 100.))
        })
        .collect();
    let max_offset = offsets.values().copied().fold(explosion, f64::max);
    let (sin, cos) = elevation.sin_cos();
    // Fit the projected cylinder to the chart canvas. Its silhouette is wider
    // than an orthographic ellipse because the front rim is nearer the camera.
    let half_width = inner_width * 0.485;
    let half_height = inner_height * 0.485;
    let horizontal =
        half_width / ((half_width * cos / focal).hypot(1.) + half_width * depth * sin / focal);
    let front = half_height / (sin + half_height * cos / focal);
    let back = half_height
        / (sin + depth * cos - half_height * (cos - depth * sin) / focal).max(f64::EPSILON);
    let radius = horizontal.min(front).min(back) / (1. + max_offset);
    let cam = Camera {
        x: left + width / 2.,
        y: top + height / 2. - 0.002 * radius,
        radius,
        thickness: radius * depth,
        sin,
        cos,
        focal: focal.max(radius * (1. + depth + max_offset) * 1.1),
    };
    let values: Vec<_> = series
        .points
        .iter()
        .filter_map(|p| {
            let value = p.value.as_ref()?.parse::<f64>().ok()?.abs();
            (value.is_finite() && value > 0.).then_some((p.index, value))
        })
        .collect();
    let max_value = values.iter().map(|(_, v)| *v).fold(0., f64::max);
    if max_value == 0. {
        return;
    }
    let total: f64 = values.iter().map(|(_, v)| v / max_value).sum();
    let mut angle = rotation.rem_euclid(TAU);
    let mut slices = Vec::with_capacity(values.len());
    for (idx, value) in values {
        let end = angle + TAU * (value / max_value) / total;
        let mid = (angle + end) / 2.;
        let offset = offsets.get(&idx).copied().unwrap_or(explosion) * radius;
        slices.push(Slice {
            index: idx,
            start: angle,
            end,
            offset: (offset * mid.sin(), offset * mid.cos()),
            fill: colors.fill(series_index, Some(idx)),
            stroke: colors
                .outline(series_index, Some(idx))
                .unwrap_or("none")
                .to_owned(),
        });
        angle = end;
    }
    slices.sort_by(|a, b| b.offset.1.total_cmp(&a.offset.1));
    for (i, slice) in slices.iter().enumerate() {
        if colors.interactive {
            let middle = (slice.start + slice.end) / 2.;
            let anchor = cam.project(
                slice.offset.0 + radius * 0.5 * middle.sin(),
                slice.offset.1 + radius * 0.5 * middle.cos(),
                cam.thickness,
            );
            begin_point_hit(out, colors, "slice", series_index, slice.index, anchor);
        }
        let center = |e| cam.project(slice.offset.0, slice.offset.1, e);
        if slice.offset != (0., 0.) {
            for (a, visible) in [
                (slice.start, slice.start.sin() < 0.),
                (slice.end, slice.end.sin() > 0.),
            ] {
                if visible {
                    polygon(
                        out,
                        &[
                            center(0.),
                            cam.rim(a, slice.offset, 0.),
                            cam.rim(a, slice.offset, cam.thickness),
                            center(cam.thickness),
                        ],
                        &shade(
                            &slice.fill,
                            if colors.spreadsheet_defaults {
                                0.4
                            } else {
                                0.65
                            },
                        ),
                        &slice.stroke,
                    );
                }
            }
        }
        let tangent = (-(radius * cam.cos)
            / (cam.focal + slice.offset.1 * cam.cos - cam.thickness * cam.sin))
            .clamp(-1., 1.)
            .acos();
        for period in 0..=1 {
            let start = slice.start.max(tangent + TAU * period as f64);
            let end = slice.end.min(TAU - tangent + TAU * period as f64);
            if end > start {
                side(out, &cam, slice, start, end, 2 * i + period);
            }
        }
        let mut top: Vec<_> = arc_angles(slice.start, slice.end)
            .map(|a| cam.rim(a, slice.offset, cam.thickness))
            .collect();
        if slice.end - slice.start < 2. * PI - 1e-10 {
            top.push(center(cam.thickness));
        }
        let top_light = if colors.spreadsheet_defaults {
            0.4 + 0.6 * (elevation - PI / 4.).cos()
        } else {
            0.923
        };
        let fill = shade(&slice.fill, top_light);
        polygon(out, &top, &fill, &slice.stroke);
        colors.end_hit(out);
    }
}