Skip to main content

esoc_chart/
render.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Rendering pipeline: Figure → Canvas.
3
4use esoc_gfx::canvas::Canvas;
5use esoc_gfx::element::{DrawElement, Element};
6use esoc_gfx::geom::Rect;
7use esoc_gfx::layer::Layer;
8use esoc_gfx::style::{Fill, FontStyle, Stroke, TextAnchor};
9use esoc_gfx::transform::{AxisTransform, CoordinateTransform, ViewportTransform};
10
11use crate::axes::Axes;
12use crate::axis::Scale;
13use crate::error::Result;
14use crate::figure::Figure;
15use crate::legend::{render_legend, LegendEntry};
16use crate::series::DataBounds;
17
18/// Margins for plot area (in pixels).
19struct Margins {
20    top: f64,
21    right: f64,
22    bottom: f64,
23    left: f64,
24}
25
26/// Render a figure to a canvas.
27pub fn render_figure(fig: &Figure) -> Result<Canvas> {
28    let mut canvas = Canvas::new(fig.width, fig.height);
29
30    // Background
31    canvas.draw(
32        Element::filled_rect(
33            Rect::new(0.0, 0.0, fig.width, fig.height),
34            Fill::Solid(fig.theme.background),
35        ),
36        Layer::Background,
37    );
38
39    // Figure title
40    let title_offset = if let Some(title) = &fig.title {
41        let font = FontStyle {
42            family: fig.theme.font_family.clone(),
43            size: fig.theme.title_font_size,
44            weight: 700,
45            color: fig.theme.foreground,
46            anchor: TextAnchor::Middle,
47        };
48        canvas.add(DrawElement::text(
49            fig.width / 2.0,
50            fig.theme.title_font_size + 8.0,
51            title,
52            font,
53            Layer::Annotations,
54        ));
55        fig.theme.title_font_size + 16.0
56    } else {
57        8.0
58    };
59
60    // For now, single-axes layout (fill available space)
61    let n_axes = fig.axes.len().max(1);
62    let avail_height = fig.height - title_offset;
63    let axes_height = avail_height / n_axes as f64;
64
65    for (i, axes) in fig.axes.iter().enumerate() {
66        let axes_y = title_offset + i as f64 * axes_height;
67        let axes_rect = Rect::new(0.0, axes_y, fig.width, axes_height);
68        render_axes(&mut canvas, axes, axes_rect, &fig.theme)?;
69    }
70
71    Ok(canvas)
72}
73
74fn render_axes(
75    canvas: &mut Canvas,
76    axes: &Axes,
77    bounds: Rect,
78    theme: &crate::theme::Theme,
79) -> Result<()> {
80    // Compute margins
81    let margins = compute_margins(axes, theme);
82    let plot_area = Rect::new(
83        bounds.x + margins.left,
84        bounds.y + margins.top,
85        (bounds.width - margins.left - margins.right).max(1.0),
86        (bounds.height - margins.top - margins.bottom).max(1.0),
87    );
88
89    // Merge data bounds from all series
90    let data_bounds = axes
91        .series
92        .iter()
93        .map(|s| s.data_bounds())
94        .reduce(DataBounds::union)
95        .unwrap_or(DataBounds::new(0.0, 1.0, 0.0, 1.0));
96
97    // Apply manual range overrides or pad
98    let (x_min, x_max) = axes.x_config.range.unwrap_or_else(|| {
99        let b = data_bounds.pad(0.05);
100        (b.x_min, b.x_max)
101    });
102    let (y_min, y_max) = axes.y_config.range.unwrap_or_else(|| {
103        let b = data_bounds.pad(0.05);
104        (b.y_min, b.y_max)
105    });
106
107    // Build transforms
108    let x_transform = match &axes.x_config.scale {
109        Scale::Linear => AxisTransform::Linear {
110            min: x_min,
111            max: x_max,
112        },
113        Scale::Log => AxisTransform::Log {
114            min: x_min,
115            max: x_max,
116        },
117        Scale::Categorical(labels) => AxisTransform::Categorical {
118            count: labels.len(),
119        },
120    };
121    let y_transform = match &axes.y_config.scale {
122        Scale::Linear => AxisTransform::Linear {
123            min: y_min,
124            max: y_max,
125        },
126        Scale::Log => AxisTransform::Log {
127            min: y_min,
128            max: y_max,
129        },
130        Scale::Categorical(labels) => AxisTransform::Categorical {
131            count: labels.len(),
132        },
133    };
134    let viewport = ViewportTransform::new(plot_area);
135    let coord_transform = CoordinateTransform::new(x_transform, y_transform, viewport);
136
137    // Grid lines
138    if theme.show_grid {
139        render_grid(canvas, &plot_area, x_min, x_max, y_min, y_max, axes, theme);
140    }
141
142    // Axis frame
143    render_axis_frame(canvas, &plot_area, x_min, x_max, y_min, y_max, axes, theme);
144
145    // Data series
146    for (i, series) in axes.series.iter().enumerate() {
147        series.render(canvas, &coord_transform, theme, i);
148    }
149
150    // Axes title
151    if let Some(title) = &axes.title {
152        let font = FontStyle {
153            family: theme.font_family.clone(),
154            size: theme.title_font_size * 0.9,
155            weight: 700,
156            color: theme.foreground,
157            anchor: TextAnchor::Middle,
158        };
159        canvas.add(DrawElement::text(
160            plot_area.x + plot_area.width / 2.0,
161            plot_area.y - 8.0,
162            title,
163            font,
164            Layer::Annotations,
165        ));
166    }
167
168    // X-axis label
169    if let Some(label) = &axes.x_config.label {
170        let font = FontStyle {
171            family: theme.font_family.clone(),
172            size: theme.label_font_size,
173            weight: 400,
174            color: theme.foreground,
175            anchor: TextAnchor::Middle,
176        };
177        canvas.add(DrawElement::text(
178            plot_area.x + plot_area.width / 2.0,
179            plot_area.bottom() + margins.bottom - 8.0,
180            label,
181            font,
182            Layer::Annotations,
183        ));
184    }
185
186    // Y-axis label (rotated)
187    if let Some(label) = &axes.y_config.label {
188        let font = FontStyle {
189            family: theme.font_family.clone(),
190            size: theme.label_font_size,
191            weight: 400,
192            color: theme.foreground,
193            anchor: TextAnchor::Middle,
194        };
195        let lx = plot_area.x - margins.left + theme.label_font_size + 4.0;
196        let ly = plot_area.y + plot_area.height / 2.0;
197        canvas.add(DrawElement::new(
198            Element::Text {
199                position: esoc_gfx::geom::Point::new(lx, ly),
200                content: label.clone(),
201                font,
202                rotation: Some(-90.0),
203            },
204            Layer::Annotations,
205        ));
206    }
207
208    // Legend
209    if axes.show_legend {
210        let entries: Vec<LegendEntry> = axes
211            .series
212            .iter()
213            .enumerate()
214            .filter_map(|(i, s)| {
215                s.label().map(|l| LegendEntry {
216                    label: l.to_string(),
217                    color: theme.palette.get(i),
218                })
219            })
220            .collect();
221        if !entries.is_empty() {
222            render_legend(canvas, plot_area, &entries, axes.legend_position, theme);
223        }
224    }
225
226    Ok(())
227}
228
229fn compute_margins(axes: &Axes, theme: &crate::theme::Theme) -> Margins {
230    let top = if axes.title.is_some() {
231        theme.title_font_size * 1.5 + 10.0
232    } else {
233        20.0
234    };
235    let bottom = if axes.x_config.label.is_some() {
236        theme.tick_font_size * 1.5 + theme.label_font_size + 20.0
237    } else {
238        theme.tick_font_size * 1.5 + 20.0
239    };
240    let left = if axes.y_config.label.is_some() {
241        theme.tick_font_size * 4.0 + theme.label_font_size + 15.0
242    } else {
243        theme.tick_font_size * 4.0 + 15.0
244    };
245    let right = 20.0;
246
247    Margins {
248        top,
249        right,
250        bottom,
251        left,
252    }
253}
254
255fn render_grid(
256    canvas: &mut Canvas,
257    plot_area: &Rect,
258    x_min: f64,
259    x_max: f64,
260    y_min: f64,
261    y_max: f64,
262    axes: &Axes,
263    theme: &crate::theme::Theme,
264) {
265    let grid_stroke = Stroke::solid(theme.grid_color, theme.grid_width);
266
267    // X grid
268    let x_ticks = crate::axis::nice_ticks(x_min, x_max, axes.x_config.tick_count);
269    for &pos in &x_ticks.positions {
270        if pos < x_min || pos > x_max {
271            continue;
272        }
273        let t = if (x_max - x_min).abs() < 1e-15 {
274            0.5
275        } else {
276            (pos - x_min) / (x_max - x_min)
277        };
278        let px = plot_area.x + t * plot_area.width;
279        canvas.add(DrawElement::line(
280            px,
281            plot_area.y,
282            px,
283            plot_area.bottom(),
284            grid_stroke.clone(),
285            Layer::Grid,
286        ));
287    }
288
289    // Y grid
290    let y_ticks = crate::axis::nice_ticks(y_min, y_max, axes.y_config.tick_count);
291    for &pos in &y_ticks.positions {
292        if pos < y_min || pos > y_max {
293            continue;
294        }
295        let t = if (y_max - y_min).abs() < 1e-15 {
296            0.5
297        } else {
298            (pos - y_min) / (y_max - y_min)
299        };
300        let py = plot_area.bottom() - t * plot_area.height;
301        canvas.add(DrawElement::line(
302            plot_area.x,
303            py,
304            plot_area.right(),
305            py,
306            grid_stroke.clone(),
307            Layer::Grid,
308        ));
309    }
310}
311
312fn render_axis_frame(
313    canvas: &mut Canvas,
314    plot_area: &Rect,
315    x_min: f64,
316    x_max: f64,
317    y_min: f64,
318    y_max: f64,
319    axes: &Axes,
320    theme: &crate::theme::Theme,
321) {
322    let axis_stroke = Stroke::solid(theme.foreground, theme.axis_width);
323
324    // Bottom axis line
325    canvas.add(DrawElement::line(
326        plot_area.x,
327        plot_area.bottom(),
328        plot_area.right(),
329        plot_area.bottom(),
330        axis_stroke.clone(),
331        Layer::Grid,
332    ));
333    // Left axis line
334    canvas.add(DrawElement::line(
335        plot_area.x,
336        plot_area.y,
337        plot_area.x,
338        plot_area.bottom(),
339        axis_stroke,
340        Layer::Grid,
341    ));
342
343    // X tick marks and labels
344    let x_ticks = crate::axis::nice_ticks(x_min, x_max, axes.x_config.tick_count);
345    let tick_font = FontStyle {
346        family: theme.font_family.clone(),
347        size: theme.tick_font_size,
348        weight: 400,
349        color: theme.foreground,
350        anchor: TextAnchor::Middle,
351    };
352
353    for (i, &pos) in x_ticks.positions.iter().enumerate() {
354        if pos < x_min || pos > x_max {
355            continue;
356        }
357        let t = if (x_max - x_min).abs() < 1e-15 {
358            0.5
359        } else {
360            (pos - x_min) / (x_max - x_min)
361        };
362        let px = plot_area.x + t * plot_area.width;
363
364        // Tick mark
365        canvas.add(DrawElement::line(
366            px,
367            plot_area.bottom(),
368            px,
369            plot_area.bottom() + 5.0,
370            Stroke::solid(theme.foreground, 0.5),
371            Layer::Grid,
372        ));
373
374        // Tick label
375        let label = axes
376            .x_config
377            .tick_labels
378            .as_ref()
379            .and_then(|tl| tl.get(i))
380            .unwrap_or(&x_ticks.labels[i]);
381        canvas.add(DrawElement::text(
382            px,
383            plot_area.bottom() + 5.0 + theme.tick_font_size,
384            label,
385            tick_font.clone(),
386            Layer::Grid,
387        ));
388    }
389
390    // Y tick marks and labels
391    let y_ticks = crate::axis::nice_ticks(y_min, y_max, axes.y_config.tick_count);
392    let y_tick_font = FontStyle {
393        family: theme.font_family.clone(),
394        size: theme.tick_font_size,
395        weight: 400,
396        color: theme.foreground,
397        anchor: TextAnchor::End,
398    };
399
400    for (i, &pos) in y_ticks.positions.iter().enumerate() {
401        if pos < y_min || pos > y_max {
402            continue;
403        }
404        let t = if (y_max - y_min).abs() < 1e-15 {
405            0.5
406        } else {
407            (pos - y_min) / (y_max - y_min)
408        };
409        let py = plot_area.bottom() - t * plot_area.height;
410
411        // Tick mark
412        canvas.add(DrawElement::line(
413            plot_area.x - 5.0,
414            py,
415            plot_area.x,
416            py,
417            Stroke::solid(theme.foreground, 0.5),
418            Layer::Grid,
419        ));
420
421        // Tick label
422        let label = axes
423            .y_config
424            .tick_labels
425            .as_ref()
426            .and_then(|tl| tl.get(i))
427            .unwrap_or(&y_ticks.labels[i]);
428        canvas.add(DrawElement::text(
429            plot_area.x - 8.0,
430            py + theme.tick_font_size * 0.35,
431            label,
432            y_tick_font.clone(),
433            Layer::Grid,
434        ));
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use crate::figure::Figure;
441
442    #[test]
443    fn test_render_empty_figure() {
444        let mut fig = Figure::new().title("Empty");
445        fig.add_axes();
446        let svg = fig.to_svg().unwrap();
447        assert!(svg.contains("<svg"));
448        assert!(svg.contains("</svg>"));
449    }
450
451    #[test]
452    fn test_render_scatter_svg() {
453        let mut fig = Figure::new().size(400.0, 300.0).title("Scatter Test");
454        let ax = fig.add_axes();
455        ax.x_label("X").y_label("Y");
456        ax.scatter(&[1.0, 2.0, 3.0, 4.0], &[1.0, 4.0, 2.0, 3.0])
457            .label("data")
458            .done();
459        let svg = fig.to_svg().unwrap();
460        assert!(svg.contains("<circle"));
461        assert!(svg.contains("Scatter Test"));
462    }
463
464    #[test]
465    fn test_render_line_svg() {
466        let mut fig = Figure::new();
467        let ax = fig.add_axes();
468        ax.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5])
469            .label("trend")
470            .done();
471        let svg = fig.to_svg().unwrap();
472        assert!(svg.contains("<polyline"));
473    }
474}