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. Size is (width, height) in pixels.
359pub 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
460/// Write box plot to PNG using plotters bitmap backend. Size is (width, height) in pixels.
461pub 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
558/// Write heatmap to PNG using plotters bitmap backend. Size is (width, height) in pixels.
559pub 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
617/// Write box plot to EPS (Encapsulated PostScript). No external dependencies.
618pub 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
826/// Write heatmap to EPS (Encapsulated PostScript). No external dependencies.
827pub 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    /// Verifies that EPS output contains expected structural elements: header, grid, axis box,
1021    /// tick marks, tick labels, axis titles, and series data.
1022    #[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        // Header and bounding box
1051        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        // Grid (light gray lines)
1056        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        // Axis box
1063        assert!(content.contains("closepath stroke"), "axis box");
1064
1065        // Tick marks (short outward lines; we draw moveto then rlineto then stroke)
1066        assert!(content.contains("moveto"), "tick/line moveto");
1067        assert!(content.contains("stroke"), "stroke");
1068
1069        // Tick labels (numeric text)
1070        assert!(content.contains(") show"), "tick or axis label show");
1071
1072        // Axis titles (column names)
1073        assert!(content.contains("(x_col)"), "x axis title");
1074        assert!(content.contains("(y_col)"), "y axis title");
1075
1076        // Series data (color and drawing)
1077        assert!(content.contains("setrgbcolor"), "series color");
1078        assert!(content.contains("lineto"), "line series");
1079    }
1080}