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 (width, height): (u32, u32),
365) -> Result<()> {
366 use plotters::prelude::*;
367
368 if series.is_empty() || series.iter().all(|s| s.points.is_empty()) {
369 return Err(color_eyre::eyre::eyre!("No data to export"));
370 }
371
372 let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
373 root.fill(&WHITE)?;
374
375 let x_min = bounds.x_min;
376 let x_max = bounds.x_max;
377 let y_min = bounds.y_min;
378 let y_max = bounds.y_max;
379
380 let mut binding = ChartBuilder::on(&root);
381 let builder = binding.margin(30);
382 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
383 builder.caption(t.as_str(), ("sans-serif", 20))
384 } else {
385 builder
386 };
387 let mut chart = builder
388 .x_label_area_size(40)
389 .y_label_area_size(50)
390 .build_cartesian_2d(x_min..x_max, y_min..y_max)?;
391
392 let x_axis_kind = bounds.x_axis_kind;
393 let log_scale = bounds.log_scale;
394 let x_formatter = move |v: &f64| format_x_axis_label(*v, x_axis_kind);
395 let y_formatter = move |v: &f64| {
396 if log_scale {
397 format_axis_label(v.exp_m1())
398 } else {
399 format_axis_label(*v)
400 }
401 };
402 chart
403 .configure_mesh()
404 .x_desc(bounds.x_label.as_str())
405 .y_desc(bounds.y_label.as_str())
406 .x_label_formatter(&x_formatter)
407 .y_label_formatter(&y_formatter)
408 .draw()?;
409
410 let colors = [
411 CYAN,
412 MAGENTA,
413 GREEN,
414 YELLOW,
415 BLUE,
416 RED,
417 RGBColor(128, 255, 255),
418 ];
419
420 for (idx, s) in series.iter().enumerate() {
421 if s.points.is_empty() {
422 continue;
423 }
424 let color = colors[idx % colors.len()];
425 match chart_type {
426 ChartType::Line => {
427 chart
428 .draw_series(LineSeries::new(s.points.iter().copied(), color))?
429 .label(s.name.as_str())
430 .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], color));
431 }
432 ChartType::Scatter => {
433 chart.draw_series(PointSeries::of_element(
434 s.points.iter().copied(),
435 3,
436 color,
437 &|c, s, _| EmptyElement::at(c) + Circle::new((0, 0), s, color.filled()),
438 ))?;
439 }
440 ChartType::Bar => {
441 chart.draw_series(s.points.iter().map(|&(x, y)| {
442 let x0 = x - 0.3;
443 let x1 = x + 0.3;
444 Rectangle::new([(x0, 0.0), (x1, y)], color.filled())
445 }))?;
446 }
447 }
448 }
449
450 chart
451 .configure_series_labels()
452 .background_style(WHITE.mix(0.8))
453 .border_style(BLACK)
454 .draw()?;
455
456 root.present()?;
457 Ok(())
458}
459
460pub fn write_box_plot_png(
462 path: &Path,
463 data: &BoxPlotData,
464 bounds: &BoxPlotExportBounds,
465 (width, height): (u32, u32),
466) -> Result<()> {
467 use plotters::prelude::*;
468
469 if data.stats.is_empty() {
470 return Err(color_eyre::eyre::eyre!("No data to export"));
471 }
472
473 let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
474 root.fill(&WHITE)?;
475
476 let x_min = -0.5;
477 let x_max = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
478 let mut binding = ChartBuilder::on(&root);
479 let builder = binding.margin(30);
480 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
481 builder.caption(t.as_str(), ("sans-serif", 20))
482 } else {
483 builder
484 };
485 let mut chart = builder
486 .x_label_area_size(40)
487 .y_label_area_size(50)
488 .build_cartesian_2d(x_min..x_max, bounds.y_min..bounds.y_max)?;
489
490 let labels = bounds.x_labels.clone();
491 let label_span = (x_max - x_min).max(f64::EPSILON);
492 chart
493 .configure_mesh()
494 .x_labels(labels.len())
495 .x_desc(bounds.x_label.as_str())
496 .y_desc(bounds.y_label.as_str())
497 .x_label_formatter(&move |v: &f64| {
498 let label_count = labels.len().saturating_sub(1) as f64;
499 let idx = if label_count > 0.0 {
500 ((v - x_min) / label_span * label_count).round() as isize
501 } else {
502 0
503 };
504 if idx >= 0 && (idx as usize) < labels.len() {
505 labels[idx as usize].clone()
506 } else {
507 String::new()
508 }
509 })
510 .draw()?;
511
512 let colors = [
513 CYAN,
514 MAGENTA,
515 GREEN,
516 YELLOW,
517 BLUE,
518 RED,
519 RGBColor(128, 255, 255),
520 ];
521 let box_half = 0.3;
522 let cap_half = 0.2;
523
524 for (idx, stat) in data.stats.iter().enumerate() {
525 let x = idx as f64;
526 let color = colors[idx % colors.len()];
527 let outline = ShapeStyle::from(&color).stroke_width(1);
528 chart.draw_series(std::iter::once(Rectangle::new(
529 [(x - box_half, stat.q1), (x + box_half, stat.q3)],
530 outline,
531 )))?;
532 chart.draw_series(std::iter::once(PathElement::new(
533 vec![(x - box_half, stat.median), (x + box_half, stat.median)],
534 color,
535 )))?;
536 chart.draw_series(std::iter::once(PathElement::new(
537 vec![(x, stat.min), (x, stat.q1)],
538 color,
539 )))?;
540 chart.draw_series(std::iter::once(PathElement::new(
541 vec![(x, stat.q3), (x, stat.max)],
542 color,
543 )))?;
544 chart.draw_series(std::iter::once(PathElement::new(
545 vec![(x - cap_half, stat.min), (x + cap_half, stat.min)],
546 color,
547 )))?;
548 chart.draw_series(std::iter::once(PathElement::new(
549 vec![(x - cap_half, stat.max), (x + cap_half, stat.max)],
550 color,
551 )))?;
552 }
553
554 root.present()?;
555 Ok(())
556}
557
558pub fn write_heatmap_png(
560 path: &Path,
561 data: &HeatmapData,
562 bounds: &ChartExportBounds,
563 (width, height): (u32, u32),
564) -> Result<()> {
565 use plotters::prelude::*;
566
567 if data.counts.is_empty() || data.max_count <= 0.0 {
568 return Err(color_eyre::eyre::eyre!("No data to export"));
569 }
570
571 let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
572 root.fill(&WHITE)?;
573
574 let mut binding = ChartBuilder::on(&root);
575 let builder = binding.margin(30);
576 let builder = if let Some(t) = bounds.chart_title.as_ref().filter(|s| !s.is_empty()) {
577 builder.caption(t.as_str(), ("sans-serif", 20))
578 } else {
579 builder
580 };
581 let mut chart = builder
582 .x_label_area_size(40)
583 .y_label_area_size(50)
584 .build_cartesian_2d(bounds.x_min..bounds.x_max, bounds.y_min..bounds.y_max)?;
585
586 let x_step = (bounds.x_max - bounds.x_min) / data.x_bins.max(1) as f64;
587 let y_step = (bounds.y_max - bounds.y_min) / data.y_bins.max(1) as f64;
588 for y in 0..data.y_bins {
589 for x in 0..data.x_bins {
590 let count = data.counts[y][x];
591 let intensity = (count / data.max_count).clamp(0.0, 1.0);
592 let shade = (255.0 * (1.0 - intensity)) as u8;
593 let color = RGBColor(shade, shade, 255);
594 let x0 = bounds.x_min + x as f64 * x_step;
595 let x1 = x0 + x_step;
596 let y0 = bounds.y_min + y as f64 * y_step;
597 let y1 = y0 + y_step;
598 chart.draw_series(std::iter::once(Rectangle::new(
599 [(x0, y0), (x1, y1)],
600 color.filled(),
601 )))?;
602 }
603 }
604
605 chart
606 .configure_mesh()
607 .x_desc(bounds.x_label.as_str())
608 .y_desc(bounds.y_label.as_str())
609 .x_label_formatter(&|v| format_x_axis_label(*v, bounds.x_axis_kind))
610 .y_label_formatter(&|v| format_axis_label(*v))
611 .draw()?;
612
613 root.present()?;
614 Ok(())
615}
616
617pub fn write_box_plot_eps(
619 path: &Path,
620 data: &BoxPlotData,
621 bounds: &BoxPlotExportBounds,
622) -> Result<()> {
623 if data.stats.is_empty() {
624 return Err(color_eyre::eyre::eyre!("No data to export"));
625 }
626
627 const W: f64 = 400.0;
628 const H: f64 = 300.0;
629 const MARGIN_LEFT: f64 = 50.0;
630 const MARGIN_BOTTOM: f64 = 40.0;
631 const PLOT_W: f64 = W - MARGIN_LEFT - 40.0;
632 const PLOT_H: f64 = H - MARGIN_BOTTOM - 30.0;
633
634 let x_min = -0.5;
635 let x_max = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
636 let y_min = bounds.y_min;
637 let y_max = bounds.y_max;
638 let x_range = if x_max > x_min { x_max - x_min } else { 1.0 };
639 let y_range = if y_max > y_min { y_max - y_min } else { 1.0 };
640
641 let to_x = |x: f64| MARGIN_LEFT + (x - x_min) / x_range * PLOT_W;
642 let to_y = |y: f64| MARGIN_BOTTOM + (y - y_min) / y_range * PLOT_H;
643
644 let mut f = File::create(path)?;
645 writeln!(f, "%!PS-Adobe-3.0 EPSF-3.0")?;
646 writeln!(
647 f,
648 "%%BoundingBox: 0 0 {} {}",
649 W.ceil() as i32,
650 H.ceil() as i32
651 )?;
652 writeln!(f, "%%Creator: datui")?;
653 writeln!(f, "%%EndComments")?;
654 writeln!(f, "gsave")?;
655 writeln!(f, "1 setlinewidth")?;
656
657 if let Some(ref title) = bounds.chart_title {
658 if !title.is_empty() {
659 const CHAR_W: f64 = 6.0;
660 writeln!(f, "/Helvetica findfont 12 scalefont setfont")?;
661 let title_w = title.len() as f64 * CHAR_W;
662 let tx = (W / 2.0 - title_w / 2.0).max(4.0).min(W - title_w - 4.0);
663 writeln!(f, "{} {} moveto ({}) show", tx, H - 15.0, ps_escape(title))?;
664 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
665 }
666 }
667
668 const MAX_TICKS: usize = 8;
669 let y_ticks = nice_ticks(y_min, y_max, MAX_TICKS);
670 let x_ticks: Vec<f64> = (0..data.stats.len()).map(|i| i as f64).collect();
671
672 writeln!(f, "0.9 setgray")?;
673 writeln!(f, "0.5 setlinewidth")?;
674 for &v in &x_ticks {
675 let px = to_x(v);
676 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
677 writeln!(
678 f,
679 "{} {} moveto 0 {} rlineto stroke",
680 px, MARGIN_BOTTOM, PLOT_H
681 )?;
682 }
683 }
684 for &v in &y_ticks {
685 let py = to_y(v);
686 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
687 writeln!(
688 f,
689 "{} {} moveto {} 0 rlineto stroke",
690 MARGIN_LEFT, py, PLOT_W
691 )?;
692 }
693 }
694 writeln!(f, "1 setlinewidth")?;
695 writeln!(f, "0 setgray")?;
696
697 writeln!(f, "{} {} moveto", MARGIN_LEFT, MARGIN_BOTTOM)?;
698 writeln!(f, "{} 0 rlineto", PLOT_W)?;
699 writeln!(f, "0 {} rlineto", PLOT_H)?;
700 writeln!(f, "{} 0 rlineto", -PLOT_W)?;
701 writeln!(f, "closepath stroke")?;
702
703 const TICK_LEN: f64 = 4.0;
704 for &v in &x_ticks {
705 let px = to_x(v);
706 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
707 writeln!(
708 f,
709 "{} {} moveto 0 {} rlineto stroke",
710 px, MARGIN_BOTTOM, -TICK_LEN
711 )?;
712 }
713 }
714 for &v in &y_ticks {
715 let py = to_y(v);
716 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
717 writeln!(
718 f,
719 "{} {} moveto {} 0 rlineto stroke",
720 MARGIN_LEFT, py, -TICK_LEN
721 )?;
722 }
723 }
724
725 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
726 let char_w: f64 = 5.0;
727 for (i, &v) in x_ticks.iter().enumerate() {
728 let px = to_x(v);
729 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
730 let label = bounds.x_labels.get(i).map(|s| s.as_str()).unwrap_or("");
731 let label_w = label.len() as f64 * char_w;
732 let tx = (px - label_w / 2.0)
733 .max(MARGIN_LEFT)
734 .min(MARGIN_LEFT + PLOT_W - label_w);
735 writeln!(
736 f,
737 "{} {} moveto ({}) show",
738 tx,
739 MARGIN_BOTTOM - 12.0,
740 ps_escape(label)
741 )?;
742 }
743 }
744 for &v in &y_ticks {
745 let py = to_y(v);
746 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
747 let s = format_axis_label(v);
748 let label_w = s.len() as f64 * char_w;
749 let tx = (MARGIN_LEFT - label_w - 4.0).max(2.0);
750 writeln!(f, "{} {} moveto ({}) show", tx, py - 3.0, ps_escape(&s))?;
751 }
752 }
753
754 writeln!(f, "/Helvetica findfont 10 scalefont setfont")?;
755 if !bounds.x_label.is_empty() {
756 let x_center = MARGIN_LEFT + PLOT_W / 2.0;
757 let x_str_approx_len = bounds.x_label.len() as f64 * char_w;
758 writeln!(
759 f,
760 "{} {} moveto ({}) show",
761 (x_center - x_str_approx_len / 2.0).max(MARGIN_LEFT),
762 MARGIN_BOTTOM - 24.0,
763 ps_escape(&bounds.x_label)
764 )?;
765 }
766 if !bounds.y_label.is_empty() {
767 writeln!(f, "gsave")?;
768 writeln!(
769 f,
770 "12 {} translate -90 rotate",
771 MARGIN_BOTTOM + PLOT_H / 2.0
772 )?;
773 let y_str_approx_len = bounds.y_label.len() as f64 * char_w;
774 writeln!(
775 f,
776 "{} 0 moveto ({}) show",
777 -y_str_approx_len / 2.0,
778 ps_escape(&bounds.y_label)
779 )?;
780 writeln!(f, "grestore")?;
781 }
782
783 let palette: [(f64, f64, f64); 7] = [
784 (0.0, 0.7, 0.9),
785 (0.9, 0.0, 0.5),
786 (0.0, 0.7, 0.0),
787 (0.9, 0.8, 0.0),
788 (0.0, 0.0, 0.9),
789 (0.9, 0.0, 0.0),
790 (0.5, 0.9, 0.9),
791 ];
792 let box_half = 0.3;
793 let cap_half = 0.2;
794
795 for (idx, stat) in data.stats.iter().enumerate() {
796 let (r, g, b) = palette[idx % palette.len()];
797 writeln!(f, "{} {} {} setrgbcolor", r, g, b)?;
798 let x = idx as f64;
799 let x_left = to_x(x - box_half);
800 let x_right = to_x(x + box_half);
801 let y_q1 = to_y(stat.q1);
802 let y_q3 = to_y(stat.q3);
803 writeln!(f, "{} {} moveto", x_left, y_q1)?;
804 writeln!(f, "{} {} lineto", x_right, y_q1)?;
805 writeln!(f, "{} {} lineto", x_right, y_q3)?;
806 writeln!(f, "{} {} lineto", x_left, y_q3)?;
807 writeln!(f, "closepath stroke")?;
808 writeln!(f, "{} {} moveto", x_left, to_y(stat.median))?;
809 writeln!(f, "{} {} lineto stroke", x_right, to_y(stat.median))?;
810 writeln!(f, "{} {} moveto", to_x(x), to_y(stat.min))?;
811 writeln!(f, "{} {} lineto stroke", to_x(x), to_y(stat.q1))?;
812 writeln!(f, "{} {} moveto", to_x(x), to_y(stat.q3))?;
813 writeln!(f, "{} {} lineto stroke", to_x(x), to_y(stat.max))?;
814 writeln!(f, "{} {} moveto", to_x(x - cap_half), to_y(stat.min))?;
815 writeln!(f, "{} {} lineto stroke", to_x(x + cap_half), to_y(stat.min))?;
816 writeln!(f, "{} {} moveto", to_x(x - cap_half), to_y(stat.max))?;
817 writeln!(f, "{} {} lineto stroke", to_x(x + cap_half), to_y(stat.max))?;
818 }
819
820 writeln!(f, "grestore")?;
821 writeln!(f, "%%EOF")?;
822 f.sync_all()?;
823 Ok(())
824}
825
826pub fn write_heatmap_eps(
828 path: &Path,
829 data: &HeatmapData,
830 bounds: &ChartExportBounds,
831) -> Result<()> {
832 if data.counts.is_empty() || data.max_count <= 0.0 {
833 return Err(color_eyre::eyre::eyre!("No data to export"));
834 }
835
836 const W: f64 = 400.0;
837 const H: f64 = 300.0;
838 const MARGIN_LEFT: f64 = 50.0;
839 const MARGIN_BOTTOM: f64 = 40.0;
840 const PLOT_W: f64 = W - MARGIN_LEFT - 40.0;
841 const PLOT_H: f64 = H - MARGIN_BOTTOM - 30.0;
842
843 let x_min = bounds.x_min;
844 let x_max = bounds.x_max;
845 let y_min = bounds.y_min;
846 let y_max = bounds.y_max;
847 let x_range = if x_max > x_min { x_max - x_min } else { 1.0 };
848 let y_range = if y_max > y_min { y_max - y_min } else { 1.0 };
849 let to_x = |x: f64| MARGIN_LEFT + (x - x_min) / x_range * PLOT_W;
850 let to_y = |y: f64| MARGIN_BOTTOM + (y - y_min) / y_range * PLOT_H;
851
852 let mut f = File::create(path)?;
853 writeln!(f, "%!PS-Adobe-3.0 EPSF-3.0")?;
854 writeln!(
855 f,
856 "%%BoundingBox: 0 0 {} {}",
857 W.ceil() as i32,
858 H.ceil() as i32
859 )?;
860 writeln!(f, "%%Creator: datui")?;
861 writeln!(f, "%%EndComments")?;
862 writeln!(f, "gsave")?;
863 writeln!(f, "1 setlinewidth")?;
864
865 if let Some(ref title) = bounds.chart_title {
866 if !title.is_empty() {
867 const CHAR_W: f64 = 6.0;
868 writeln!(f, "/Helvetica findfont 12 scalefont setfont")?;
869 let title_w = title.len() as f64 * CHAR_W;
870 let tx = (W / 2.0 - title_w / 2.0).max(4.0).min(W - title_w - 4.0);
871 writeln!(f, "{} {} moveto ({}) show", tx, H - 15.0, ps_escape(title))?;
872 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
873 }
874 }
875
876 const MAX_TICKS: usize = 8;
877 let x_ticks = nice_ticks(x_min, x_max, MAX_TICKS);
878 let y_ticks = nice_ticks(y_min, y_max, MAX_TICKS);
879
880 writeln!(f, "0.9 setgray")?;
881 writeln!(f, "0.5 setlinewidth")?;
882 for &v in &x_ticks {
883 let px = to_x(v);
884 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
885 writeln!(
886 f,
887 "{} {} moveto 0 {} rlineto stroke",
888 px, MARGIN_BOTTOM, PLOT_H
889 )?;
890 }
891 }
892 for &v in &y_ticks {
893 let py = to_y(v);
894 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
895 writeln!(
896 f,
897 "{} {} moveto {} 0 rlineto stroke",
898 MARGIN_LEFT, py, PLOT_W
899 )?;
900 }
901 }
902 writeln!(f, "1 setlinewidth")?;
903 writeln!(f, "0 setgray")?;
904
905 writeln!(f, "{} {} moveto", MARGIN_LEFT, MARGIN_BOTTOM)?;
906 writeln!(f, "{} 0 rlineto", PLOT_W)?;
907 writeln!(f, "0 {} rlineto", PLOT_H)?;
908 writeln!(f, "{} 0 rlineto", -PLOT_W)?;
909 writeln!(f, "closepath stroke")?;
910
911 let x_step = (x_max - x_min) / data.x_bins.max(1) as f64;
912 let y_step = (y_max - y_min) / data.y_bins.max(1) as f64;
913 for y in 0..data.y_bins {
914 for x in 0..data.x_bins {
915 let count = data.counts[y][x];
916 let intensity = (count / data.max_count).clamp(0.0, 1.0);
917 let shade = 1.0 - intensity;
918 writeln!(f, "{} {} {} setrgbcolor", shade, shade, 1.0)?;
919 let x0 = to_x(x_min + x as f64 * x_step);
920 let x1 = to_x(x_min + (x + 1) as f64 * x_step);
921 let y0 = to_y(y_min + y as f64 * y_step);
922 let y1 = to_y(y_min + (y + 1) as f64 * y_step);
923 writeln!(f, "{} {} {} {} rectfill", x0, y0, x1 - x0, y1 - y0)?;
924 }
925 }
926 writeln!(f, "0 setgray")?;
927
928 const TICK_LEN: f64 = 4.0;
929 for &v in &x_ticks {
930 let px = to_x(v);
931 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
932 writeln!(
933 f,
934 "{} {} moveto 0 {} rlineto stroke",
935 px, MARGIN_BOTTOM, -TICK_LEN
936 )?;
937 }
938 }
939 for &v in &y_ticks {
940 let py = to_y(v);
941 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
942 writeln!(
943 f,
944 "{} {} moveto {} 0 rlineto stroke",
945 MARGIN_LEFT, py, -TICK_LEN
946 )?;
947 }
948 }
949
950 writeln!(f, "/Helvetica findfont 9 scalefont setfont")?;
951 let char_w: f64 = 5.0;
952 for &v in &x_ticks {
953 let px = to_x(v);
954 if (MARGIN_LEFT..=MARGIN_LEFT + PLOT_W).contains(&px) {
955 let s = format_x_axis_label(v, bounds.x_axis_kind);
956 let label_w = s.len() as f64 * char_w;
957 let tx = (px - label_w / 2.0)
958 .max(MARGIN_LEFT)
959 .min(MARGIN_LEFT + PLOT_W - label_w);
960 writeln!(
961 f,
962 "{} {} moveto ({}) show",
963 tx,
964 MARGIN_BOTTOM - 12.0,
965 ps_escape(&s)
966 )?;
967 }
968 }
969 for &v in &y_ticks {
970 let py = to_y(v);
971 if (MARGIN_BOTTOM..=MARGIN_BOTTOM + PLOT_H).contains(&py) {
972 let s = format_axis_label(v);
973 let label_w = s.len() as f64 * char_w;
974 let tx = (MARGIN_LEFT - label_w - 4.0).max(2.0);
975 writeln!(f, "{} {} moveto ({}) show", tx, py - 3.0, ps_escape(&s))?;
976 }
977 }
978
979 writeln!(f, "/Helvetica findfont 10 scalefont setfont")?;
980 if !bounds.x_label.is_empty() {
981 let x_center = MARGIN_LEFT + PLOT_W / 2.0;
982 let x_str_approx_len = bounds.x_label.len() as f64 * char_w;
983 writeln!(
984 f,
985 "{} {} moveto ({}) show",
986 (x_center - x_str_approx_len / 2.0).max(MARGIN_LEFT),
987 MARGIN_BOTTOM - 24.0,
988 ps_escape(&bounds.x_label)
989 )?;
990 }
991 if !bounds.y_label.is_empty() {
992 writeln!(f, "gsave")?;
993 writeln!(
994 f,
995 "12 {} translate -90 rotate",
996 MARGIN_BOTTOM + PLOT_H / 2.0
997 )?;
998 let y_str_approx_len = bounds.y_label.len() as f64 * char_w;
999 writeln!(
1000 f,
1001 "{} 0 moveto ({}) show",
1002 -y_str_approx_len / 2.0,
1003 ps_escape(&bounds.y_label)
1004 )?;
1005 writeln!(f, "grestore")?;
1006 }
1007
1008 writeln!(f, "grestore")?;
1009 writeln!(f, "%%EOF")?;
1010 f.sync_all()?;
1011 Ok(())
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use crate::chart_modal::ChartType;
1018 use std::io::Read;
1019
1020 #[test]
1023 fn eps_contains_desired_elements() {
1024 let series = vec![ChartExportSeries {
1025 name: "s1".to_string(),
1026 points: vec![(0.0, 1.0), (1.0, 2.0), (2.0, 1.5)],
1027 }];
1028 let bounds = ChartExportBounds {
1029 x_min: 0.0,
1030 x_max: 2.0,
1031 y_min: 0.0,
1032 y_max: 2.5,
1033 x_label: "x_col".to_string(),
1034 y_label: "y_col".to_string(),
1035 x_axis_kind: XAxisTemporalKind::Numeric,
1036 log_scale: false,
1037 chart_title: None,
1038 };
1039
1040 let dir = tempfile::tempdir().expect("temp dir");
1041 let path = dir.path().join("chart.eps");
1042 write_chart_eps(&path, &series, ChartType::Line, &bounds).expect("write_chart_eps");
1043
1044 let mut content = String::new();
1045 std::fs::File::open(&path)
1046 .expect("open")
1047 .read_to_string(&mut content)
1048 .expect("read");
1049
1050 assert!(content.contains("%!PS-Adobe-3.0 EPSF-3.0"), "EPS header");
1052 assert!(content.contains("%%BoundingBox:"), "BoundingBox");
1053 assert!(content.contains("%%Creator: datui"), "Creator");
1054
1055 assert!(content.contains("0.9 setgray"), "grid color");
1057 assert!(
1058 content.contains("rlineto stroke") && content.matches("rlineto stroke").count() > 2,
1059 "grid/axis lines"
1060 );
1061
1062 assert!(content.contains("closepath stroke"), "axis box");
1064
1065 assert!(content.contains("moveto"), "tick/line moveto");
1067 assert!(content.contains("stroke"), "stroke");
1068
1069 assert!(content.contains(") show"), "tick or axis label show");
1071
1072 assert!(content.contains("(x_col)"), "x axis title");
1074 assert!(content.contains("(y_col)"), "y axis title");
1075
1076 assert!(content.contains("setrgbcolor"), "series color");
1078 assert!(content.contains("lineto"), "line series");
1079 }
1080}