Skip to main content

forme/chart/
mod.rs

1//! # Chart Rendering
2//!
3//! Engine-native chart generation. Each chart type produces a flat list of
4//! `ChartPrimitive` drawing commands. The PDF renderer iterates the list to
5//! emit vector graphics directly — no SVG intermediary.
6
7pub mod area;
8pub mod bar;
9pub mod dot;
10pub mod line;
11pub mod pie;
12
13use crate::font::metrics::StandardFontMetrics;
14use crate::font::StandardFont;
15use crate::style::Color;
16
17/// A drawing primitive emitted by chart builders.
18#[derive(Debug, Clone)]
19pub enum ChartPrimitive {
20    /// A filled rectangle.
21    Rect {
22        x: f64,
23        y: f64,
24        w: f64,
25        h: f64,
26        fill: Color,
27    },
28    /// A stroked line segment.
29    Line {
30        x1: f64,
31        y1: f64,
32        x2: f64,
33        y2: f64,
34        stroke: Color,
35        width: f64,
36    },
37    /// A stroked polyline (connected line segments).
38    Polyline {
39        points: Vec<(f64, f64)>,
40        stroke: Color,
41        width: f64,
42    },
43    /// A filled closed polygon.
44    FilledPath {
45        points: Vec<(f64, f64)>,
46        fill: Color,
47        opacity: f64,
48    },
49    /// A filled circle.
50    Circle {
51        cx: f64,
52        cy: f64,
53        r: f64,
54        fill: Color,
55    },
56    /// A filled arc sector (pie slice).
57    ArcSector {
58        cx: f64,
59        cy: f64,
60        r: f64,
61        start_angle: f64,
62        end_angle: f64,
63        fill: Color,
64    },
65    /// A text label.
66    Label {
67        text: String,
68        x: f64,
69        y: f64,
70        font_size: f64,
71        color: Color,
72        anchor: TextAnchor,
73    },
74}
75
76/// Text horizontal alignment for labels.
77#[derive(Debug, Clone, Copy)]
78pub enum TextAnchor {
79    Left,
80    Center,
81    Right,
82}
83
84// ── Constants ──────────────────────────────────────────────────
85
86/// Default color palette for chart series/slices.
87pub const DEFAULT_COLORS: &[&str] = &[
88    "#1a365d", "#2b6cb0", "#3182ce", "#4299e1", "#63b3ed", "#90cdf4", "#e53e3e", "#dd6b20",
89    "#38a169", "#805ad5",
90];
91
92pub const Y_AXIS_WIDTH: f64 = 28.0;
93pub const X_AXIS_HEIGHT: f64 = 20.0;
94pub const AXIS_LABEL_FONT: f64 = 8.0;
95pub const LABEL_MARGIN: f64 = 4.0;
96pub const TITLE_FONT: f64 = 11.0;
97pub const TITLE_HEIGHT: f64 = 20.0;
98pub const GRID_COLOR: Color = Color {
99    r: 0.88,
100    g: 0.88,
101    b: 0.88,
102    a: 1.0,
103};
104pub const AXIS_COLOR: Color = Color {
105    r: 0.4,
106    g: 0.4,
107    b: 0.4,
108    a: 1.0,
109};
110pub const LABEL_COLOR: Color = Color {
111    r: 0.3,
112    g: 0.3,
113    b: 0.3,
114    a: 1.0,
115};
116
117// ── Helpers ────────────────────────────────────────────────────
118
119/// Helvetica metrics for measuring label widths.
120fn helvetica_metrics() -> StandardFontMetrics {
121    StandardFont::Helvetica.metrics()
122}
123
124/// Measure the width of a label string in Helvetica at the given font size.
125pub fn measure_label(text: &str, font_size: f64) -> f64 {
126    helvetica_metrics().measure_string(text, font_size, 0.0)
127}
128
129/// Round a range maximum to a "nice" number for axis ticks.
130pub fn nice_number(value: f64) -> f64 {
131    if value <= 0.0 {
132        return 1.0;
133    }
134    let exp = value.log10().floor();
135    let frac = value / 10.0_f64.powf(exp);
136    let nice = if frac <= 1.0 {
137        1.0
138    } else if frac <= 2.0 {
139        2.0
140    } else if frac <= 5.0 {
141        5.0
142    } else {
143        10.0
144    };
145    nice * 10.0_f64.powf(exp)
146}
147
148/// Format a number compactly (1000 → "1K", 1000000 → "1M").
149pub fn format_number(value: f64) -> String {
150    if value.abs() >= 1_000_000.0 {
151        format!("{:.1}M", value / 1_000_000.0)
152    } else if value.abs() >= 1_000.0 {
153        format!("{:.1}K", value / 1_000.0)
154    } else if value == value.floor() {
155        format!("{}", value as i64)
156    } else {
157        format!("{:.1}", value)
158    }
159}
160
161/// Lighten a hex color toward white by the given factor (0.0=unchanged, 1.0=white).
162pub fn lighten_color(color: &Color, factor: f64) -> Color {
163    Color {
164        r: color.r + (1.0 - color.r) * factor,
165        g: color.g + (1.0 - color.g) * factor,
166        b: color.b + (1.0 - color.b) * factor,
167        a: color.a,
168    }
169}
170
171/// Parse a hex color string (#RGB or #RRGGBB) to a Color.
172pub fn parse_hex_color(hex: &str) -> Color {
173    let hex = hex.trim_start_matches('#');
174    match hex.len() {
175        3 => {
176            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0);
177            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0);
178            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0);
179            Color::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)
180        }
181        6 => {
182            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
183            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
184            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
185            Color::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)
186        }
187        _ => Color::BLACK,
188    }
189}
190
191/// Get a color from the default palette by index, or parse a custom color string.
192pub fn resolve_color(custom: Option<&str>, index: usize) -> Color {
193    match custom {
194        Some(c) => parse_hex_color(c),
195        None => parse_hex_color(DEFAULT_COLORS[index % DEFAULT_COLORS.len()]),
196    }
197}