Skip to main content

datui_lib/
chart_export.rs

1//! Chart export to PNG (plotters bitmap) and EPS (minimal PostScript, no deps).
2
3use 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
13/// Escape a string for PostScript ( and ) and \.
14fn ps_escape(s: &str) -> String {
15    s.replace('\\', "\\\\")
16        .replace('(', "\\(")
17        .replace(')', "\\)")
18}
19
20/// Generate "nice" tick values in [min, max] with roughly max_ticks steps.
21fn 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
57/// Format a numeric tick for display (used for y when not log scale).
58fn format_tick(v: f64) -> String {
59    format_axis_label(v)
60}
61
62/// Bounds and options for rendering the chart to a file.
63pub struct ChartExportBounds {
64    pub x_min: f64,
65    pub x_max: f64,
66    pub y_min: f64,
67    pub y_max: f64,
68    /// X-axis column name (for axis title).
69    pub x_label: String,
70    /// Y-axis column name(s), e.g. "col" or "a, b" (for axis title).
71    pub y_label: String,
72    /// How to format x-axis tick labels (date/datetime/time vs numeric).
73    pub x_axis_kind: XAxisTemporalKind,
74    /// If true, y values in data/bounds are ln(1+y); y-axis labels must be shown in linear space (exp_m1).
75    pub log_scale: bool,
76    /// Optional chart title shown on export. None or empty = no title.
77    pub chart_title: Option<String>,
78}
79
80/// Bounds and options for rendering a box plot export.
81pub 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
90/// One series: name and (x, y) points (y already log-transformed if log scale).
91pub struct ChartExportSeries {
92    pub name: String,
93    pub points: Vec<(f64, f64)>,
94}
95
96/// Export format for chart: PNG or EPS.
97#[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
121/// Write chart to EPS (Encapsulated PostScript). No external dependencies.
122pub 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    // Optional chart title at top center
164    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    // Tick positions for grid, ticks, and labels
176    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    // Grid (light gray, behind plot)
181    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    // Axis box
207    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    // Tick marks (short lines on axes)
214    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    // Tick labels and axis titles (text)
237    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    // Axis titles (x_label below tick labels, y_label left of plot)
275    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    // Fixed palette (RGB 0–1)
307    let palette: [(f64, f64, f64); 7] = [
308        (0.0, 0.7, 0.9), // cyan
309        (0.9, 0.0, 0.5), // magenta
310        (0.0, 0.7, 0.0), // green
311        (0.9, 0.8, 0.0), // yellow
312        (0.0, 0.0, 0.9), // blue
313        (0.9, 0.0, 0.0), // red
314        (0.5, 0.9, 0.9), // light cyan
315    ];
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
358/// Write chart to PNG using plotters bitmap backend.
359pub 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
459/// Write box plot to PNG using plotters bitmap backend.
460pub 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
556/// Write heatmap to PNG using plotters bitmap backend.
557pub 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
614/// Write box plot to EPS (Encapsulated PostScript). No external dependencies.
615pub 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
823/// Write heatmap to EPS (Encapsulated PostScript). No external dependencies.
824pub 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    /// Verifies that EPS output contains expected structural elements: header, grid, axis box,
1018    /// tick marks, tick labels, axis titles, and series data.
1019    #[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        // Header and bounding box
1048        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        // Grid (light gray lines)
1053        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        // Axis box
1060        assert!(content.contains("closepath stroke"), "axis box");
1061
1062        // Tick marks (short outward lines; we draw moveto then rlineto then stroke)
1063        assert!(content.contains("moveto"), "tick/line moveto");
1064        assert!(content.contains("stroke"), "stroke");
1065
1066        // Tick labels (numeric text)
1067        assert!(content.contains(") show"), "tick or axis label show");
1068
1069        // Axis titles (column names)
1070        assert!(content.contains("(x_col)"), "x axis title");
1071        assert!(content.contains("(y_col)"), "y axis title");
1072
1073        // Series data (color and drawing)
1074        assert!(content.contains("setrgbcolor"), "series color");
1075        assert!(content.contains("lineto"), "line series");
1076    }
1077}