1use 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
18struct Margins {
20 top: f64,
21 right: f64,
22 bottom: f64,
23 left: f64,
24}
25
26pub fn render_figure(fig: &Figure) -> Result<Canvas> {
28 let mut canvas = Canvas::new(fig.width, fig.height);
29
30 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 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 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 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 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 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 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 if theme.show_grid {
139 render_grid(canvas, &plot_area, x_min, x_max, y_min, y_max, axes, theme);
140 }
141
142 render_axis_frame(canvas, &plot_area, x_min, x_max, y_min, y_max, axes, theme);
144
145 for (i, series) in axes.series.iter().enumerate() {
147 series.render(canvas, &coord_transform, theme, i);
148 }
149
150 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 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 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 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 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 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 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 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 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 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 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 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 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 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}