1use color_eyre::Result;
4use std::fs::File;
5use std::io::Write;
6use std::path::Path;
7
8use crate::chart_data::{
9 format_axis_label, format_x_axis_label, BoxPlotData, HeatmapData, XAxisTemporalKind,
10};
11use crate::chart_modal::ChartType;
12
13fn ps_escape(s: &str) -> String {
15 s.replace('\\', "\\\\")
16 .replace('(', "\\(")
17 .replace(')', "\\)")
18}
19
20fn nice_ticks(min: f64, max: f64, max_ticks: usize) -> Vec<f64> {
22 let range = if max > min { max - min } else { 1.0 };
23 if range <= 0.0 || max_ticks == 0 {
24 return vec![min];
25 }
26 let raw_step = range / (max_ticks as f64).max(1.0);
27 let mag = 10.0_f64.powf(raw_step.log10().floor());
28 let norm = if mag > 0.0 { raw_step / mag } else { raw_step };
29 let step = if norm <= 1.0 {
30 1.0 * mag
31 } else if norm <= 2.0 {
32 2.0 * mag
33 } else if norm <= 5.0 {
34 5.0 * mag
35 } else {
36 10.0 * mag
37 };
38 let step = step.max(f64::EPSILON);
39 let start = (min / step).floor() * step;
40 let mut ticks = Vec::new();
41 let mut v = start;
42 while v <= max + step * 0.001 {
43 if v >= min - step * 0.001 {
44 ticks.push(v);
45 }
46 v += step;
47 if ticks.len() > max_ticks + 2 {
48 break;
49 }
50 }
51 if ticks.is_empty() {
52 ticks.push(min);
53 }
54 ticks
55}
56
57fn format_tick(v: f64) -> String {
59 format_axis_label(v)
60}
61
62pub struct ChartExportBounds {
64 pub x_min: f64,
65 pub x_max: f64,
66 pub y_min: f64,
67 pub y_max: f64,
68 pub x_label: String,
70 pub y_label: String,
72 pub x_axis_kind: XAxisTemporalKind,
74 pub log_scale: bool,
76 pub chart_title: Option<String>,
78}
79
80pub struct BoxPlotExportBounds {
82 pub y_min: f64,
83 pub y_max: f64,
84 pub x_labels: Vec<String>,
85 pub x_label: String,
86 pub y_label: String,
87 pub chart_title: Option<String>,
88}
89
90pub struct ChartExportSeries {
92 pub name: String,
93 pub points: Vec<(f64, f64)>,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ChartExportFormat {
99 Png,
100 Eps,
101}
102
103impl ChartExportFormat {
104 pub const ALL: [Self; 2] = [Self::Png, Self::Eps];
105
106 pub fn extension(self) -> &'static str {
107 match self {
108 Self::Png => "png",
109 Self::Eps => "eps",
110 }
111 }
112
113 pub fn as_str(self) -> &'static str {
114 match self {
115 Self::Png => "PNG",
116 Self::Eps => "EPS",
117 }
118 }
119}
120
121pub fn write_chart_eps(
123 path: &Path,
124 series: &[ChartExportSeries],
125 chart_type: ChartType,
126 bounds: &ChartExportBounds,
127) -> Result<()> {
128 if series.is_empty() || series.iter().all(|s| s.points.is_empty()) {
129 return Err(color_eyre::eyre::eyre!("No data to export"));
130 }
131
132 const W: f64 = 400.0;
133 const H: f64 = 300.0;
134 const MARGIN_LEFT: f64 = 50.0;
135 const MARGIN_BOTTOM: f64 = 40.0;
136 const PLOT_W: f64 = W - MARGIN_LEFT - 40.0;
137 const PLOT_H: f64 = H - MARGIN_BOTTOM - 30.0;
138
139 let x_min = bounds.x_min;
140 let x_max = bounds.x_max;
141 let y_min = bounds.y_min;
142 let y_max = bounds.y_max;
143 let x_range = if x_max > x_min { x_max - x_min } else { 1.0 };
144 let y_range = if y_max > y_min { y_max - y_min } else { 1.0 };
145
146 let to_x = |x: f64| MARGIN_LEFT + (x - x_min) / x_range * PLOT_W;
147 let to_y = |y: f64| MARGIN_BOTTOM + (y - y_min) / y_range * PLOT_H;
148
149 let mut f = File::create(path)?;
150
151 writeln!(f, "%!PS-Adobe-3.0 EPSF-3.0")?;
152 writeln!(
153 f,
154 "%%BoundingBox: 0 0 {} {}",
155 W.ceil() as i32,
156 H.ceil() as i32
157 )?;
158 writeln!(f, "%%Creator: datui")?;
159 writeln!(f, "%%EndComments")?;
160 writeln!(f, "gsave")?;
161 writeln!(f, "1 setlinewidth")?;
162
163 if let Some(ref title) = bounds.chart_title {
165 if !title.is_empty() {
166 const CHAR_W: f64 = 6.0;
167 writeln!(f, "/Helvetica findfont 12 scalefont setfont")?;
168 let title_w = title.len() as f64 * CHAR_W;
169 let tx = (W / 2.0 - title_w / 2.0).max(4.0).min(W - title_w - 4.0);
170 writeln!(f, "{} {} moveto ({}) show", tx, H - 15.0, ps_escape(title))?;
171 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
172 }
173 }
174
175 const MAX_TICKS: usize = 8;
177 let x_ticks = nice_ticks(x_min, x_max, MAX_TICKS);
178 let y_ticks = nice_ticks(y_min, y_max, MAX_TICKS);
179
180 writeln!(f, "0.9 setgray")?;
182 writeln!(f, "0.5 setlinewidth")?;
183 for &v in &x_ticks {
184 let px = to_x(v);
185 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
186 writeln!(
187 f,
188 "{} {} moveto 0 {} rlineto stroke",
189 px, MARGIN_BOTTOM, PLOT_H
190 )?;
191 }
192 }
193 for &v in &y_ticks {
194 let py = to_y(v);
195 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
196 writeln!(
197 f,
198 "{} {} moveto {} 0 rlineto stroke",
199 MARGIN_LEFT, py, PLOT_W
200 )?;
201 }
202 }
203 writeln!(f, "1 setlinewidth")?;
204 writeln!(f, "0 setgray")?;
205
206 writeln!(f, "{} {} moveto", MARGIN_LEFT, MARGIN_BOTTOM)?;
208 writeln!(f, "{} 0 rlineto", PLOT_W)?;
209 writeln!(f, "0 {} rlineto", PLOT_H)?;
210 writeln!(f, "{} 0 rlineto", -PLOT_W)?;
211 writeln!(f, "closepath stroke")?;
212
213 const TICK_LEN: f64 = 4.0;
215 for &v in &x_ticks {
216 let px = to_x(v);
217 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
218 writeln!(
219 f,
220 "{} {} moveto 0 {} rlineto stroke",
221 px, MARGIN_BOTTOM, -TICK_LEN
222 )?;
223 }
224 }
225 for &v in &y_ticks {
226 let py = to_y(v);
227 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
228 writeln!(
229 f,
230 "{} {} moveto {} 0 rlineto stroke",
231 MARGIN_LEFT, py, -TICK_LEN
232 )?;
233 }
234 }
235
236 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
238 let char_w: f64 = 5.0;
239 let format_x_tick = |v: f64| format_x_axis_label(v, bounds.x_axis_kind);
240 for &v in &x_ticks {
241 let px = to_x(v);
242 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
243 let s = format_x_tick(v);
244 let label_w = s.len() as f64 * char_w;
245 let tx = (px - label_w / 2.0)
246 .max(MARGIN_LEFT)
247 .min(MARGIN_LEFT + PLOT_W - label_w);
248 writeln!(
249 f,
250 "{} {} moveto ({}) show",
251 tx,
252 MARGIN_BOTTOM - 12.0,
253 ps_escape(&s)
254 )?;
255 }
256 }
257 let format_y_tick = |v: f64| {
258 if bounds.log_scale {
259 format_axis_label(v.exp_m1())
260 } else {
261 format_tick(v)
262 }
263 };
264 for &v in &y_ticks {
265 let py = to_y(v);
266 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
267 let s = format_y_tick(v);
268 let label_w = s.len() as f64 * char_w;
269 let tx = (MARGIN_LEFT - label_w - 4.0).max(2.0);
270 writeln!(f, "{} {} moveto ({}) show", tx, py - 3.0, ps_escape(&s))?;
271 }
272 }
273
274 writeln!(f, "/Helvetica findfont 10 scalefont setfont")?;
276 let x_label = &bounds.x_label;
277 let y_label = &bounds.y_label;
278 if !x_label.is_empty() {
279 let x_center = MARGIN_LEFT + PLOT_W / 2.0;
280 let x_str_approx_len = x_label.len() as f64 * char_w;
281 writeln!(
282 f,
283 "{} {} moveto ({}) show",
284 (x_center - x_str_approx_len / 2.0).max(MARGIN_LEFT),
285 MARGIN_BOTTOM - 24.0,
286 ps_escape(x_label)
287 )?;
288 }
289 if !y_label.is_empty() {
290 writeln!(f, "gsave")?;
291 writeln!(
292 f,
293 "12 {} translate -90 rotate",
294 MARGIN_BOTTOM + PLOT_H / 2.0
295 )?;
296 let y_str_approx_len = y_label.len() as f64 * char_w;
297 writeln!(
298 f,
299 "{} 0 moveto ({}) show",
300 -y_str_approx_len / 2.0,
301 ps_escape(y_label)
302 )?;
303 writeln!(f, "grestore")?;
304 }
305
306 let palette: [(f64, f64, f64); 7] = [
308 (0.0, 0.7, 0.9), (0.9, 0.0, 0.5), (0.0, 0.7, 0.0), (0.9, 0.8, 0.0), (0.0, 0.0, 0.9), (0.9, 0.0, 0.0), (0.5, 0.9, 0.9), ];
316
317 for (idx, s) in series.iter().enumerate() {
318 if s.points.is_empty() {
319 continue;
320 }
321 let (r, g, b) = palette[idx % palette.len()];
322 writeln!(f, "{} {} {} setrgbcolor", r, g, b)?;
323
324 match chart_type {
325 ChartType::Line => {
326 let (px, py) = s.points[0];
327 writeln!(f, "{} {} moveto", to_x(px), to_y(py))?;
328 for &(px, py) in &s.points[1..] {
329 writeln!(f, "{} {} lineto", to_x(px), to_y(py))?;
330 }
331 writeln!(f, "stroke")?;
332 }
333 ChartType::Scatter => {
334 let rad = 3.0;
335 for &(px, py) in &s.points {
336 writeln!(f, "{} {} {} 0 360 arc fill", to_x(px), to_y(py), rad)?;
337 }
338 }
339 ChartType::Bar => {
340 let n = s.points.len() as f64;
341 let bar_w = (PLOT_W / n).clamp(1.0, 20.0) * 0.7;
342 for &(px, py) in &s.points {
343 let cx = to_x(px) - bar_w / 2.0;
344 let cy = to_y(0.0_f64.max(y_min));
345 let h = to_y(py) - cy;
346 writeln!(f, "{} {} {} {} rectfill", cx, cy, bar_w, h)?;
347 }
348 }
349 }
350 }
351
352 writeln!(f, "grestore")?;
353 writeln!(f, "%%EOF")?;
354 f.sync_all()?;
355 Ok(())
356}
357
358pub fn write_chart_png(
360 path: &Path,
361 series: &[ChartExportSeries],
362 chart_type: ChartType,
363 bounds: &ChartExportBounds,
364) -> Result<()> {
365 use plotters::prelude::*;
366
367 if series.is_empty() || series.iter().all(|s| s.points.is_empty()) {
368 return Err(color_eyre::eyre::eyre!("No data to export"));
369 }
370
371 let root = BitMapBackend::new(path, (640, 480)).into_drawing_area();
372 root.fill(&WHITE)?;
373
374 let x_min = bounds.x_min;
375 let x_max = bounds.x_max;
376 let y_min = bounds.y_min;
377 let y_max = bounds.y_max;
378
379 let mut binding = ChartBuilder::on(&root);
380 let builder = binding.margin(30);
381 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
382 builder.caption(t.as_str(), ("sans-serif", 20))
383 } else {
384 builder
385 };
386 let mut chart = builder
387 .x_label_area_size(40)
388 .y_label_area_size(50)
389 .build_cartesian_2d(x_min..x_max, y_min..y_max)?;
390
391 let x_axis_kind = bounds.x_axis_kind;
392 let log_scale = bounds.log_scale;
393 let x_formatter = move |v: &f64| format_x_axis_label(*v, x_axis_kind);
394 let y_formatter = move |v: &f64| {
395 if log_scale {
396 format_axis_label(v.exp_m1())
397 } else {
398 format_axis_label(*v)
399 }
400 };
401 chart
402 .configure_mesh()
403 .x_desc(bounds.x_label.as_str())
404 .y_desc(bounds.y_label.as_str())
405 .x_label_formatter(&x_formatter)
406 .y_label_formatter(&y_formatter)
407 .draw()?;
408
409 let colors = [
410 CYAN,
411 MAGENTA,
412 GREEN,
413 YELLOW,
414 BLUE,
415 RED,
416 RGBColor(128, 255, 255),
417 ];
418
419 for (idx, s) in series.iter().enumerate() {
420 if s.points.is_empty() {
421 continue;
422 }
423 let color = colors[idx % colors.len()];
424 match chart_type {
425 ChartType::Line => {
426 chart
427 .draw_series(LineSeries::new(s.points.iter().copied(), color))?
428 .label(s.name.as_str())
429 .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
430 }
431 ChartType::Scatter => {
432 chart.draw_series(PointSeries::of_element(
433 s.points.iter().copied(),
434 3,
435 color,
436 &|c, s, _| EmptyElement::at(c) + Circle::new((0, 0), s, color.filled()),
437 ))?;
438 }
439 ChartType::Bar => {
440 chart.draw_series(s.points.iter().map(|&(x, y)| {
441 let x0 = x - 0.3;
442 let x1 = x + 0.3;
443 Rectangle::new([(x0, 0.0), (x1, y)], color.filled())
444 }))?;
445 }
446 }
447 }
448
449 chart
450 .configure_series_labels()
451 .background_style(WHITE.mix(0.8))
452 .border_style(BLACK)
453 .draw()?;
454
455 root.present()?;
456 Ok(())
457}
458
459pub fn write_box_plot_png(
461 path: &Path,
462 data: &BoxPlotData,
463 bounds: &BoxPlotExportBounds,
464) -> Result<()> {
465 use plotters::prelude::*;
466
467 if data.stats.is_empty() {
468 return Err(color_eyre::eyre::eyre!("No data to export"));
469 }
470
471 let root = BitMapBackend::new(path, (640, 480)).into_drawing_area();
472 root.fill(&WHITE)?;
473
474 let x_min = -0.5;
475 let x_max = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
476 let mut binding = ChartBuilder::on(&root);
477 let builder = binding.margin(30);
478 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
479 builder.caption(t.as_str(), ("sans-serif", 20))
480 } else {
481 builder
482 };
483 let mut chart = builder
484 .x_label_area_size(40)
485 .y_label_area_size(50)
486 .build_cartesian_2d(x_min..x_max, bounds.y_min..bounds.y_max)?;
487
488 let labels = bounds.x_labels.clone();
489 let label_span = (x_max - x_min).max(f64::EPSILON);
490 chart
491 .configure_mesh()
492 .x_labels(labels.len())
493 .x_desc(bounds.x_label.as_str())
494 .y_desc(bounds.y_label.as_str())
495 .x_label_formatter(&move |v: &f64| {
496 let label_count = labels.len().saturating_sub(1) as f64;
497 let idx = if label_count > 0.0 {
498 ((v - x_min) / label_span * label_count).round() as isize
499 } else {
500 0
501 };
502 if idx >= 0 && (idx as usize) < labels.len() {
503 labels[idx as usize].clone()
504 } else {
505 String::new()
506 }
507 })
508 .draw()?;
509
510 let colors = [
511 CYAN,
512 MAGENTA,
513 GREEN,
514 YELLOW,
515 BLUE,
516 RED,
517 RGBColor(128, 255, 255),
518 ];
519 let box_half = 0.3;
520 let cap_half = 0.2;
521
522 for (idx, stat) in data.stats.iter().enumerate() {
523 let x = idx as f64;
524 let color = colors[idx % colors.len()];
525 let outline = ShapeStyle::from(&color).stroke_width(1);
526 chart.draw_series(std::iter::once(Rectangle::new(
527 [(x - box_half, stat.q1), (x + box_half, stat.q3)],
528 outline,
529 )))?;
530 chart.draw_series(std::iter::once(PathElement::new(
531 vec![(x - box_half, stat.median), (x + box_half, stat.median)],
532 color,
533 )))?;
534 chart.draw_series(std::iter::once(PathElement::new(
535 vec![(x, stat.min), (x, stat.q1)],
536 color,
537 )))?;
538 chart.draw_series(std::iter::once(PathElement::new(
539 vec![(x, stat.q3), (x, stat.max)],
540 color,
541 )))?;
542 chart.draw_series(std::iter::once(PathElement::new(
543 vec![(x - cap_half, stat.min), (x + cap_half, stat.min)],
544 color,
545 )))?;
546 chart.draw_series(std::iter::once(PathElement::new(
547 vec![(x - cap_half, stat.max), (x + cap_half, stat.max)],
548 color,
549 )))?;
550 }
551
552 root.present()?;
553 Ok(())
554}
555
556pub fn write_heatmap_png(
558 path: &Path,
559 data: &HeatmapData,
560 bounds: &ChartExportBounds,
561) -> Result<()> {
562 use plotters::prelude::*;
563
564 if data.counts.is_empty() || data.max_count <= 0.0 {
565 return Err(color_eyre::eyre::eyre!("No data to export"));
566 }
567
568 let root = BitMapBackend::new(path, (640, 480)).into_drawing_area();
569 root.fill(&WHITE)?;
570
571 let mut binding = ChartBuilder::on(&root);
572 let builder = binding.margin(30);
573 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
574 builder.caption(t.as_str(), ("sans-serif", 20))
575 } else {
576 builder
577 };
578 let mut chart = builder
579 .x_label_area_size(40)
580 .y_label_area_size(50)
581 .build_cartesian_2d(bounds.x_min..bounds.x_max, bounds.y_min..bounds.y_max)?;
582
583 let x_step = (bounds.x_max - bounds.x_min) / data.x_bins.max(1) as f64;
584 let y_step = (bounds.y_max - bounds.y_min) / data.y_bins.max(1) as f64;
585 for y in 0..data.y_bins {
586 for x in 0..data.x_bins {
587 let count = data.counts[y][x];
588 let intensity = (count / data.max_count).clamp(0.0, 1.0);
589 let shade = (255.0 * (1.0 - intensity)) as u8;
590 let color = RGBColor(shade, shade, 255);
591 let x0 = bounds.x_min + x as f64 * x_step;
592 let x1 = x0 + x_step;
593 let y0 = bounds.y_min + y as f64 * y_step;
594 let y1 = y0 + y_step;
595 chart.draw_series(std::iter::once(Rectangle::new(
596 [(x0, y0), (x1, y1)],
597 color.filled(),
598 )))?;
599 }
600 }
601
602 chart
603 .configure_mesh()
604 .x_desc(bounds.x_label.as_str())
605 .y_desc(bounds.y_label.as_str())
606 .x_label_formatter(&|v| format_x_axis_label(*v, bounds.x_axis_kind))
607 .y_label_formatter(&|v| format_axis_label(*v))
608 .draw()?;
609
610 root.present()?;
611 Ok(())
612}
613
614pub fn write_box_plot_eps(
616 path: &Path,
617 data: &BoxPlotData,
618 bounds: &BoxPlotExportBounds,
619) -> Result<()> {
620 if data.stats.is_empty() {
621 return Err(color_eyre::eyre::eyre!("No data to export"));
622 }
623
624 const W: f64 = 400.0;
625 const H: f64 = 300.0;
626 const MARGIN_LEFT: f64 = 50.0;
627 const MARGIN_BOTTOM: f64 = 40.0;
628 const PLOT_W: f64 = W - MARGIN_LEFT - 40.0;
629 const PLOT_H: f64 = H - MARGIN_BOTTOM - 30.0;
630
631 let x_min = -0.5;
632 let x_max = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
633 let y_min = bounds.y_min;
634 let y_max = bounds.y_max;
635 let x_range = if x_max > x_min { x_max - x_min } else { 1.0 };
636 let y_range = if y_max > y_min { y_max - y_min } else { 1.0 };
637
638 let to_x = |x: f64| MARGIN_LEFT + (x - x_min) / x_range * PLOT_W;
639 let to_y = |y: f64| MARGIN_BOTTOM + (y - y_min) / y_range * PLOT_H;
640
641 let mut f = File::create(path)?;
642 writeln!(f, "%!PS-Adobe-3.0 EPSF-3.0")?;
643 writeln!(
644 f,
645 "%%BoundingBox: 0 0 {} {}",
646 W.ceil() as i32,
647 H.ceil() as i32
648 )?;
649 writeln!(f, "%%Creator: datui")?;
650 writeln!(f, "%%EndComments")?;
651 writeln!(f, "gsave")?;
652 writeln!(f, "1 setlinewidth")?;
653
654 if let Some(ref title) = bounds.chart_title {
655 if !title.is_empty() {
656 const CHAR_W: f64 = 6.0;
657 writeln!(f, "/Helvetica findfont 12 scalefont setfont")?;
658 let title_w = title.len() as f64 * CHAR_W;
659 let tx = (W / 2.0 - title_w / 2.0).max(4.0).min(W - title_w - 4.0);
660 writeln!(f, "{} {} moveto ({}) show", tx, H - 15.0, ps_escape(title))?;
661 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
662 }
663 }
664
665 const MAX_TICKS: usize = 8;
666 let y_ticks = nice_ticks(y_min, y_max, MAX_TICKS);
667 let x_ticks: Vec<f64> = (0..data.stats.len()).map(|i| i as f64).collect();
668
669 writeln!(f, "0.9 setgray")?;
670 writeln!(f, "0.5 setlinewidth")?;
671 for &v in &x_ticks {
672 let px = to_x(v);
673 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
674 writeln!(
675 f,
676 "{} {} moveto 0 {} rlineto stroke",
677 px, MARGIN_BOTTOM, PLOT_H
678 )?;
679 }
680 }
681 for &v in &y_ticks {
682 let py = to_y(v);
683 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
684 writeln!(
685 f,
686 "{} {} moveto {} 0 rlineto stroke",
687 MARGIN_LEFT, py, PLOT_W
688 )?;
689 }
690 }
691 writeln!(f, "1 setlinewidth")?;
692 writeln!(f, "0 setgray")?;
693
694 writeln!(f, "{} {} moveto", MARGIN_LEFT, MARGIN_BOTTOM)?;
695 writeln!(f, "{} 0 rlineto", PLOT_W)?;
696 writeln!(f, "0 {} rlineto", PLOT_H)?;
697 writeln!(f, "{} 0 rlineto", -PLOT_W)?;
698 writeln!(f, "closepath stroke")?;
699
700 const TICK_LEN: f64 = 4.0;
701 for &v in &x_ticks {
702 let px = to_x(v);
703 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
704 writeln!(
705 f,
706 "{} {} moveto 0 {} rlineto stroke",
707 px, MARGIN_BOTTOM, -TICK_LEN
708 )?;
709 }
710 }
711 for &v in &y_ticks {
712 let py = to_y(v);
713 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
714 writeln!(
715 f,
716 "{} {} moveto {} 0 rlineto stroke",
717 MARGIN_LEFT, py, -TICK_LEN
718 )?;
719 }
720 }
721
722 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
723 let char_w: f64 = 5.0;
724 for (i, &v) in x_ticks.iter().enumerate() {
725 let px = to_x(v);
726 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
727 let label = bounds.x_labels.get(i).map(|s| s.as_str()).unwrap_or("");
728 let label_w = label.len() as f64 * char_w;
729 let tx = (px - label_w / 2.0)
730 .max(MARGIN_LEFT)
731 .min(MARGIN_LEFT + PLOT_W - label_w);
732 writeln!(
733 f,
734 "{} {} moveto ({}) show",
735 tx,
736 MARGIN_BOTTOM - 12.0,
737 ps_escape(label)
738 )?;
739 }
740 }
741 for &v in &y_ticks {
742 let py = to_y(v);
743 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
744 let s = format_axis_label(v);
745 let label_w = s.len() as f64 * char_w;
746 let tx = (MARGIN_LEFT - label_w - 4.0).max(2.0);
747 writeln!(f, "{} {} moveto ({}) show", tx, py - 3.0, ps_escape(&s))?;
748 }
749 }
750
751 writeln!(f, "/Helvetica findfont 10 scalefont setfont")?;
752 if !bounds.x_label.is_empty() {
753 let x_center = MARGIN_LEFT + PLOT_W / 2.0;
754 let x_str_approx_len = bounds.x_label.len() as f64 * char_w;
755 writeln!(
756 f,
757 "{} {} moveto ({}) show",
758 (x_center - x_str_approx_len / 2.0).max(MARGIN_LEFT),
759 MARGIN_BOTTOM - 24.0,
760 ps_escape(&bounds.x_label)
761 )?;
762 }
763 if !bounds.y_label.is_empty() {
764 writeln!(f, "gsave")?;
765 writeln!(
766 f,
767 "12 {} translate -90 rotate",
768 MARGIN_BOTTOM + PLOT_H / 2.0
769 )?;
770 let y_str_approx_len = bounds.y_label.len() as f64 * char_w;
771 writeln!(
772 f,
773 "{} 0 moveto ({}) show",
774 -y_str_approx_len / 2.0,
775 ps_escape(&bounds.y_label)
776 )?;
777 writeln!(f, "grestore")?;
778 }
779
780 let palette: [(f64, f64, f64); 7] = [
781 (0.0, 0.7, 0.9),
782 (0.9, 0.0, 0.5),
783 (0.0, 0.7, 0.0),
784 (0.9, 0.8, 0.0),
785 (0.0, 0.0, 0.9),
786 (0.9, 0.0, 0.0),
787 (0.5, 0.9, 0.9),
788 ];
789 let box_half = 0.3;
790 let cap_half = 0.2;
791
792 for (idx, stat) in data.stats.iter().enumerate() {
793 let (r, g, b) = palette[idx % palette.len()];
794 writeln!(f, "{} {} {} setrgbcolor", r, g, b)?;
795 let x = idx as f64;
796 let x_left = to_x(x - box_half);
797 let x_right = to_x(x + box_half);
798 let y_q1 = to_y(stat.q1);
799 let y_q3 = to_y(stat.q3);
800 writeln!(f, "{} {} moveto", x_left, y_q1)?;
801 writeln!(f, "{} {} lineto", x_right, y_q1)?;
802 writeln!(f, "{} {} lineto", x_right, y_q3)?;
803 writeln!(f, "{} {} lineto", x_left, y_q3)?;
804 writeln!(f, "closepath stroke")?;
805 writeln!(f, "{} {} moveto", x_left, to_y(stat.median))?;
806 writeln!(f, "{} {} lineto stroke", x_right, to_y(stat.median))?;
807 writeln!(f, "{} {} moveto", to_x(x), to_y(stat.min))?;
808 writeln!(f, "{} {} lineto stroke", to_x(x), to_y(stat.q1))?;
809 writeln!(f, "{} {} moveto", to_x(x), to_y(stat.q3))?;
810 writeln!(f, "{} {} lineto stroke", to_x(x), to_y(stat.max))?;
811 writeln!(f, "{} {} moveto", to_x(x - cap_half), to_y(stat.min))?;
812 writeln!(f, "{} {} lineto stroke", to_x(x + cap_half), to_y(stat.min))?;
813 writeln!(f, "{} {} moveto", to_x(x - cap_half), to_y(stat.max))?;
814 writeln!(f, "{} {} lineto stroke", to_x(x + cap_half), to_y(stat.max))?;
815 }
816
817 writeln!(f, "grestore")?;
818 writeln!(f, "%%EOF")?;
819 f.sync_all()?;
820 Ok(())
821}
822
823pub fn write_heatmap_eps(
825 path: &Path,
826 data: &HeatmapData,
827 bounds: &ChartExportBounds,
828) -> Result<()> {
829 if data.counts.is_empty() || data.max_count <= 0.0 {
830 return Err(color_eyre::eyre::eyre!("No data to export"));
831 }
832
833 const W: f64 = 400.0;
834 const H: f64 = 300.0;
835 const MARGIN_LEFT: f64 = 50.0;
836 const MARGIN_BOTTOM: f64 = 40.0;
837 const PLOT_W: f64 = W - MARGIN_LEFT - 40.0;
838 const PLOT_H: f64 = H - MARGIN_BOTTOM - 30.0;
839
840 let x_min = bounds.x_min;
841 let x_max = bounds.x_max;
842 let y_min = bounds.y_min;
843 let y_max = bounds.y_max;
844 let x_range = if x_max > x_min { x_max - x_min } else { 1.0 };
845 let y_range = if y_max > y_min { y_max - y_min } else { 1.0 };
846 let to_x = |x: f64| MARGIN_LEFT + (x - x_min) / x_range * PLOT_W;
847 let to_y = |y: f64| MARGIN_BOTTOM + (y - y_min) / y_range * PLOT_H;
848
849 let mut f = File::create(path)?;
850 writeln!(f, "%!PS-Adobe-3.0 EPSF-3.0")?;
851 writeln!(
852 f,
853 "%%BoundingBox: 0 0 {} {}",
854 W.ceil() as i32,
855 H.ceil() as i32
856 )?;
857 writeln!(f, "%%Creator: datui")?;
858 writeln!(f, "%%EndComments")?;
859 writeln!(f, "gsave")?;
860 writeln!(f, "1 setlinewidth")?;
861
862 if let Some(ref title) = bounds.chart_title {
863 if !title.is_empty() {
864 const CHAR_W: f64 = 6.0;
865 writeln!(f, "/Helvetica findfont 12 scalefont setfont")?;
866 let title_w = title.len() as f64 * CHAR_W;
867 let tx = (W / 2.0 - title_w / 2.0).max(4.0).min(W - title_w - 4.0);
868 writeln!(f, "{} {} moveto ({}) show", tx, H - 15.0, ps_escape(title))?;
869 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
870 }
871 }
872
873 const MAX_TICKS: usize = 8;
874 let x_ticks = nice_ticks(x_min, x_max, MAX_TICKS);
875 let y_ticks = nice_ticks(y_min, y_max, MAX_TICKS);
876
877 writeln!(f, "0.9 setgray")?;
878 writeln!(f, "0.5 setlinewidth")?;
879 for &v in &x_ticks {
880 let px = to_x(v);
881 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
882 writeln!(
883 f,
884 "{} {} moveto 0 {} rlineto stroke",
885 px, MARGIN_BOTTOM, PLOT_H
886 )?;
887 }
888 }
889 for &v in &y_ticks {
890 let py = to_y(v);
891 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
892 writeln!(
893 f,
894 "{} {} moveto {} 0 rlineto stroke",
895 MARGIN_LEFT, py, PLOT_W
896 )?;
897 }
898 }
899 writeln!(f, "1 setlinewidth")?;
900 writeln!(f, "0 setgray")?;
901
902 writeln!(f, "{} {} moveto", MARGIN_LEFT, MARGIN_BOTTOM)?;
903 writeln!(f, "{} 0 rlineto", PLOT_W)?;
904 writeln!(f, "0 {} rlineto", PLOT_H)?;
905 writeln!(f, "{} 0 rlineto", -PLOT_W)?;
906 writeln!(f, "closepath stroke")?;
907
908 let x_step = (x_max - x_min) / data.x_bins.max(1) as f64;
909 let y_step = (y_max - y_min) / data.y_bins.max(1) as f64;
910 for y in 0..data.y_bins {
911 for x in 0..data.x_bins {
912 let count = data.counts[y][x];
913 let intensity = (count / data.max_count).clamp(0.0, 1.0);
914 let shade = 1.0 - intensity;
915 writeln!(f, "{} {} {} setrgbcolor", shade, shade, 1.0)?;
916 let x0 = to_x(x_min + x as f64 * x_step);
917 let x1 = to_x(x_min + (x + 1) as f64 * x_step);
918 let y0 = to_y(y_min + y as f64 * y_step);
919 let y1 = to_y(y_min + (y + 1) as f64 * y_step);
920 writeln!(f, "{} {} {} {} rectfill", x0, y0, x1 - x0, y1 - y0)?;
921 }
922 }
923 writeln!(f, "0 setgray")?;
924
925 const TICK_LEN: f64 = 4.0;
926 for &v in &x_ticks {
927 let px = to_x(v);
928 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
929 writeln!(
930 f,
931 "{} {} moveto 0 {} rlineto stroke",
932 px, MARGIN_BOTTOM, -TICK_LEN
933 )?;
934 }
935 }
936 for &v in &y_ticks {
937 let py = to_y(v);
938 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
939 writeln!(
940 f,
941 "{} {} moveto {} 0 rlineto stroke",
942 MARGIN_LEFT, py, -TICK_LEN
943 )?;
944 }
945 }
946
947 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
948 let char_w: f64 = 5.0;
949 for &v in &x_ticks {
950 let px = to_x(v);
951 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
952 let s = format_x_axis_label(v, bounds.x_axis_kind);
953 let label_w = s.len() as f64 * char_w;
954 let tx = (px - label_w / 2.0)
955 .max(MARGIN_LEFT)
956 .min(MARGIN_LEFT + PLOT_W - label_w);
957 writeln!(
958 f,
959 "{} {} moveto ({}) show",
960 tx,
961 MARGIN_BOTTOM - 12.0,
962 ps_escape(&s)
963 )?;
964 }
965 }
966 for &v in &y_ticks {
967 let py = to_y(v);
968 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
969 let s = format_axis_label(v);
970 let label_w = s.len() as f64 * char_w;
971 let tx = (MARGIN_LEFT - label_w - 4.0).max(2.0);
972 writeln!(f, "{} {} moveto ({}) show", tx, py - 3.0, ps_escape(&s))?;
973 }
974 }
975
976 writeln!(f, "/Helvetica findfont 10 scalefont setfont")?;
977 if !bounds.x_label.is_empty() {
978 let x_center = MARGIN_LEFT + PLOT_W / 2.0;
979 let x_str_approx_len = bounds.x_label.len() as f64 * char_w;
980 writeln!(
981 f,
982 "{} {} moveto ({}) show",
983 (x_center - x_str_approx_len / 2.0).max(MARGIN_LEFT),
984 MARGIN_BOTTOM - 24.0,
985 ps_escape(&bounds.x_label)
986 )?;
987 }
988 if !bounds.y_label.is_empty() {
989 writeln!(f, "gsave")?;
990 writeln!(
991 f,
992 "12 {} translate -90 rotate",
993 MARGIN_BOTTOM + PLOT_H / 2.0
994 )?;
995 let y_str_approx_len = bounds.y_label.len() as f64 * char_w;
996 writeln!(
997 f,
998 "{} 0 moveto ({}) show",
999 -y_str_approx_len / 2.0,
1000 ps_escape(&bounds.y_label)
1001 )?;
1002 writeln!(f, "grestore")?;
1003 }
1004
1005 writeln!(f, "grestore")?;
1006 writeln!(f, "%%EOF")?;
1007 f.sync_all()?;
1008 Ok(())
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use crate::chart_modal::ChartType;
1015 use std::io::Read;
1016
1017 #[test]
1020 fn eps_contains_desired_elements() {
1021 let series = vec![ChartExportSeries {
1022 name: "s1".to_string(),
1023 points: vec![(0.0, 1.0), (1.0, 2.0), (2.0, 1.5)],
1024 }];
1025 let bounds = ChartExportBounds {
1026 x_min: 0.0,
1027 x_max: 2.0,
1028 y_min: 0.0,
1029 y_max: 2.5,
1030 x_label: "x_col".to_string(),
1031 y_label: "y_col".to_string(),
1032 x_axis_kind: XAxisTemporalKind::Numeric,
1033 log_scale: false,
1034 chart_title: None,
1035 };
1036
1037 let dir = tempfile::tempdir().expect("temp dir");
1038 let path = dir.path().join("chart.eps");
1039 write_chart_eps(&path, &series, ChartType::Line, &bounds).expect("write_chart_eps");
1040
1041 let mut content = String::new();
1042 std::fs::File::open(&path)
1043 .expect("open")
1044 .read_to_string(&mut content)
1045 .expect("read");
1046
1047 assert!(content.contains("%!PS-Adobe-3.0 EPSF-3.0"), "EPS header");
1049 assert!(content.contains("%%BoundingBox:"), "BoundingBox");
1050 assert!(content.contains("%%Creator: datui"), "Creator");
1051
1052 assert!(content.contains("0.9 setgray"), "grid color");
1054 assert!(
1055 content.contains("rlineto stroke") && content.matches("rlineto stroke").count() > 2,
1056 "grid/axis lines"
1057 );
1058
1059 assert!(content.contains("closepath stroke"), "axis box");
1061
1062 assert!(content.contains("moveto"), "tick/line moveto");
1064 assert!(content.contains("stroke"), "stroke");
1065
1066 assert!(content.contains(") show"), "tick or axis label show");
1068
1069 assert!(content.contains("(x_col)"), "x axis title");
1071 assert!(content.contains("(y_col)"), "y axis title");
1072
1073 assert!(content.contains("setrgbcolor"), "series color");
1075 assert!(content.contains("lineto"), "line series");
1076 }
1077}