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    /// A text label rotated to read bottom to top, centred on (x, y): a
75    /// y-axis title.
76    VerticalLabel {
77        text: String,
78        x: f64,
79        y: f64,
80        font_size: f64,
81        color: Color,
82    },
83}
84
85/// Text horizontal alignment for labels.
86#[derive(Debug, Clone, Copy)]
87pub enum TextAnchor {
88    Left,
89    Center,
90    Right,
91}
92
93// ── Constants ──────────────────────────────────────────────────
94
95/// Default color palette for chart series/slices.
96pub const DEFAULT_COLORS: &[&str] = &[
97    "#1a365d", "#2b6cb0", "#3182ce", "#4299e1", "#63b3ed", "#90cdf4", "#e53e3e", "#dd6b20",
98    "#38a169", "#805ad5",
99];
100
101pub const Y_AXIS_WIDTH: f64 = 28.0;
102pub const X_AXIS_HEIGHT: f64 = 20.0;
103pub const AXIS_LABEL_FONT: f64 = 8.0;
104pub const LABEL_MARGIN: f64 = 4.0;
105pub const TITLE_FONT: f64 = 11.0;
106pub const TITLE_HEIGHT: f64 = 20.0;
107pub const GRID_COLOR: Color = Color {
108    r: 0.88,
109    g: 0.88,
110    b: 0.88,
111    a: 1.0,
112};
113pub const AXIS_COLOR: Color = Color {
114    r: 0.4,
115    g: 0.4,
116    b: 0.4,
117    a: 1.0,
118};
119pub const LABEL_COLOR: Color = Color {
120    r: 0.3,
121    g: 0.3,
122    b: 0.3,
123    a: 1.0,
124};
125
126// ── Helpers ────────────────────────────────────────────────────
127
128/// Helvetica metrics for measuring label widths.
129fn helvetica_metrics() -> StandardFontMetrics {
130    StandardFont::Helvetica.metrics()
131}
132
133/// Measure the width of a label string in Helvetica at the given font size.
134pub fn measure_label(text: &str, font_size: f64) -> f64 {
135    helvetica_metrics().measure_string(text, font_size, 0.0)
136}
137
138/// Round a range maximum to a "nice" number for axis ticks.
139pub fn nice_number(value: f64) -> f64 {
140    if value <= 0.0 {
141        return 1.0;
142    }
143    let exp = value.log10().floor();
144    let frac = value / 10.0_f64.powf(exp);
145    let nice = if frac <= 1.0 {
146        1.0
147    } else if frac <= 2.0 {
148        2.0
149    } else if frac <= 5.0 {
150        5.0
151    } else {
152        10.0
153    };
154    nice * 10.0_f64.powf(exp)
155}
156
157/// Format a number compactly (1000 → "1K", 1000000 → "1M").
158pub fn format_number(value: f64) -> String {
159    if value.abs() >= 1_000_000.0 {
160        format!("{:.1}M", value / 1_000_000.0)
161    } else if value.abs() >= 1_000.0 {
162        format!("{:.1}K", value / 1_000.0)
163    } else if value == value.floor() {
164        format!("{}", value as i64)
165    } else {
166        format!("{:.1}", value)
167    }
168}
169
170/// Lighten a hex color toward white by the given factor (0.0=unchanged, 1.0=white).
171pub fn lighten_color(color: &Color, factor: f64) -> Color {
172    Color {
173        r: color.r + (1.0 - color.r) * factor,
174        g: color.g + (1.0 - color.g) * factor,
175        b: color.b + (1.0 - color.b) * factor,
176        a: color.a,
177    }
178}
179
180/// Parse a hex color string (#RGB or #RRGGBB) to a Color.
181pub fn parse_hex_color(hex: &str) -> Color {
182    let hex = hex.trim_start_matches('#');
183    match hex.len() {
184        3 => {
185            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).unwrap_or(0);
186            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).unwrap_or(0);
187            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).unwrap_or(0);
188            Color::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)
189        }
190        6 => {
191            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
192            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
193            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
194            Color::rgb(r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)
195        }
196        _ => Color::BLACK,
197    }
198}
199
200/// Get a color from the default palette by index, or parse a custom color string.
201pub fn resolve_color(custom: Option<&str>, index: usize) -> Color {
202    match custom {
203        Some(c) => parse_hex_color(c),
204        None => parse_hex_color(DEFAULT_COLORS[index % DEFAULT_COLORS.len()]),
205    }
206}