Skip to main content

map2fig/plot/
mollweide.rs

1use super::{
2    DebugOverlay, MollweideScale, blit_grid_to_sink, draw_debug_overlay_raster,
3    draw_figure_labels_png, fill_grid_background, percentile, render_projection_to_grid,
4};
5use crate::colorbar::{format_tick_label_with_units, render_colorbar_gradient};
6use crate::healpix::is_seen;
7use crate::layout::{MollweideLayout, compute_mollweide_layout};
8use crate::params::MollweideParams;
9use crate::render::pdf::{draw_colorbar_pdf, draw_projection_border_pdf};
10use crate::render::raster::RasterGrid;
11use crate::rotation::CoordSystem;
12use crate::scale::{
13    HistogramRange, Scale, build_histogram_scale, generate_colorbar_ticks, unsafe_float_cmp,
14};
15use crate::{PixelSink, PngSink};
16use ab_glyph::{Font, FontRef, PxScale};
17use cairo::{Context, Format, ImageSurface, PdfSurface};
18use image::{Rgba, RgbaImage};
19use imageproc::drawing::draw_text_mut;
20use std::path::Path;
21
22pub fn compute_mollweide_scale(
23    map: &[f64],
24    minv: Option<f64>,
25    maxv: Option<f64>,
26    gamma: f64,
27    scale: Scale,
28) -> MollweideScale {
29    let mut values: Vec<f64> = map.iter().filter(|v| is_seen(**v)).copied().collect();
30
31    if values.is_empty() {
32        panic!("Map contains no valid HEALPix values");
33    }
34
35    values.sort_unstable_by(unsafe_float_cmp);
36
37    let data_min = *values.first().unwrap();
38    let data_max = *values.last().unwrap();
39
40    let (minv, maxv) = match scale {
41        // 🔴 Histogram scale overrides percentiles
42        Scale::Histogram => match (minv, maxv) {
43            (Some(lo), Some(hi)) => (lo, hi),
44            _ => (data_min, data_max),
45        },
46
47        // 🟢 All other scales keep percentile default
48        _ => match (minv, maxv) {
49            (Some(lo), Some(hi)) => (lo, hi),
50            _ => (percentile(&values, 5.0), percentile(&values, 95.0)),
51        },
52    };
53
54    if gamma <= 0.0 {
55        panic!("Gamma must be > 0");
56    }
57
58    if minv > maxv {
59        panic!("Invalid color scale: {minv} > {maxv}");
60    }
61
62    MollweideScale { minv, maxv }
63}
64
65pub fn render_mollweide_pixels(
66    params: crate::params::RenderMollweideParams,
67    layout: MollweideLayout,
68    sink: &mut dyn PixelSink,
69    debug_overlay: Option<DebugOverlay>,
70) {
71    use crate::mollweide::MollweideProjection;
72    let proj = MollweideProjection;
73
74    let mut grid = RasterGrid::new(layout.map_w as u32, layout.map_h as u32);
75
76    if let Some(overlay) = debug_overlay
77        && overlay.show_background
78    {
79        fill_grid_background(&mut grid);
80    }
81
82    render_projection_to_grid(
83        crate::params::RenderGridParams {
84            map: params.map,
85            proj: &proj,
86            scale: params.scale,
87            cmap: params.cmap,
88            scale_type: params.scale_type,
89            neg_mode: params.neg_mode,
90            gamma: params.gamma,
91            bad_color: params.bad_color,
92            meta: params.meta,
93            hist_scale: params.hist_scale,
94            view: params.view,
95            mask: params.mask,
96            scale_cache: params.scale_cache,
97            underflow: (255, 0, 0),
98            overflow: (0, 0, 255),
99        },
100        &mut grid,
101    );
102
103    // Draw debug overlay only if provided
104    if let Some(overlay) = debug_overlay {
105        draw_debug_overlay_raster(&mut grid, overlay);
106    }
107
108    blit_grid_to_sink(&grid, sink, 0, 0);
109}
110
111pub fn plot_mollweide_pdf(params: MollweideParams) {
112    _plot_mollweide_pdf_impl(params, render_mollweide_pixels);
113}
114
115pub fn _plot_mollweide_pdf_impl<F>(params: MollweideParams, pixel_renderer: F)
116where
117    F: Fn(
118        crate::params::RenderMollweideParams,
119        MollweideLayout,
120        &mut dyn PixelSink,
121        Option<DebugOverlay>,
122    ),
123{
124    let map = params.plot.map;
125    let width = params.plot.width;
126    let filename = params.plot.filename;
127    let minv = params.scale.minv;
128    let maxv = params.scale.maxv;
129    let cmap = params.color.cmap;
130    let show_colorbar = params.display.show_colorbar;
131    let transparent = params.display.transparent;
132    let draw_border = params.display.draw_border;
133    let gamma = params.scale.gamma;
134    let scale = params.scale.scale;
135    let neg_mode = params.scale.neg_mode;
136    let bad_color = params.color.bad_color;
137    let meta = params.meta;
138    let latex_rendering = params.display.latex_rendering;
139    let units = params.display.units.as_deref();
140    let view = params.view;
141    let show_graticule = params.graticule.show_graticule;
142    let grat_coord = params.graticule.grat_coord;
143    let grat_overlay = params.graticule.grat_overlay;
144    let overlay_color = params.graticule.overlay_color;
145    let dpar_deg = params.graticule.dpar_deg;
146    let dmer_deg = params.graticule.dmer_deg;
147    let mask = params.display.mask.as_ref();
148
149    let (layout, cb_layout) = compute_mollweide_layout(
150        width as f64,
151        show_colorbar,
152        params.display.tick_direction.clone(),
153    );
154
155    let mut values: Vec<f64> = map.iter().filter(|&v| is_seen(*v)).copied().collect();
156
157    if values.is_empty() {
158        // Diagnostic: check what we actually have
159        let n_maps = map.len();
160        let n_finite = map.iter().filter(|v| v.is_finite()).count();
161        let n_gt_neg1e30 = map.iter().filter(|v| **v > -1e30).count();
162        let n_inf = map.iter().filter(|v| v.is_infinite()).count();
163        let n_nan = map.iter().filter(|v| v.is_nan()).count();
164
165        eprintln!("\n=== DEBUG: No valid HEALPix values found ===");
166        eprintln!("Total pixels in map: {}", n_maps);
167        eprintln!("Finite values: {}", n_finite);
168        eprintln!("Values > -1e30: {}", n_gt_neg1e30);
169        eprintln!("Infinite values: {}", n_inf);
170        eprintln!("NaN values: {}", n_nan);
171        if !map.is_empty() {
172            eprintln!(
173                "Min: {:.6e}, Max: {:.6e}",
174                map.iter().copied().fold(f64::INFINITY, f64::min),
175                map.iter().copied().fold(f64::NEG_INFINITY, f64::max)
176            );
177            eprintln!("First 5 values: {:?}", &map[..5.min(map.len())]);
178        }
179        panic!("Map contains no valid HEALPix values");
180    }
181
182    values.sort_unstable_by(unsafe_float_cmp);
183
184    let surface_pdf = PdfSurface::new(layout.width, layout.height, filename)
185        .expect("Failed to create PDF surface");
186
187    let cr_pdf = Context::new(&surface_pdf).unwrap();
188
189    if transparent {
190        cr_pdf.set_operator(cairo::Operator::Source);
191        cr_pdf.set_source_rgba(0.0, 0.0, 0.0, 0.0);
192        cr_pdf.paint().unwrap();
193    }
194
195    // Create raster surface
196    let surface_img = ImageSurface::create(
197        Format::ARgb32,
198        (layout.map_w + 2.0 * layout.map_pad) as i32,
199        (layout.map_h + 2.0 * layout.map_pad) as i32,
200    )
201    .expect("Failed to create image surface");
202
203    let cr_img = Context::new(&surface_img).unwrap();
204
205    // Clear raster background
206    if transparent {
207        cr_img.set_source_rgba(0.0, 0.0, 0.0, 0.0);
208    } else {
209        cr_img.set_source_rgb(1.0, 1.0, 1.0);
210    }
211    cr_img.paint().unwrap();
212
213    let scale_params = compute_mollweide_scale(map, minv, maxv, gamma, scale);
214
215    let hist_scale_opt = if scale == Scale::Histogram {
216        let range = match (minv, maxv) {
217            (Some(minv), Some(maxv)) => HistogramRange::Explicit {
218                min: minv,
219                max: maxv,
220            },
221            _ => HistogramRange::Full,
222        };
223
224        Some(build_histogram_scale(
225            map, range, 1024, // number of bins
226        ))
227    } else {
228        None
229    };
230
231    // Pre-compute scale cache for fast pixel rendering
232    let scale_cache = crate::scale::ScaleCache::new(scale_params.minv, scale_params.maxv, scale);
233
234    // Phase 2B: Image pre-rendering optimization
235    // Create in-memory pixel buffer instead of rendering directly to Cairo surface.
236    // This avoids per-pixel Cairo operations and allows batch optimization.
237    let map_w_int = (layout.map_w + 2.0 * layout.map_pad) as u32;
238    let map_h_int = (layout.map_h + 2.0 * layout.map_pad) as u32;
239
240    // Tier 3a: Lazy initialization - skip kernel zeroing of pixel buffer
241    // Reduces 1.58M page faults by using uninitialized memory. All pixels are
242    // written in the clear_background loop immediately after (see below).
243    let mut pixel_buffer = crate::render::create_image_buffer_uninitialized(map_w_int, map_h_int);
244
245    // Clear buffer background (matches what we'd paint on Cairo surface)
246    let bg_color = if transparent {
247        image::Rgba([0, 0, 0, 0])
248    } else {
249        image::Rgba([255, 255, 255, 255])
250    };
251    for pixel in pixel_buffer.pixels_mut() {
252        *pixel = bg_color;
253    }
254
255    // Render pixels to in-memory buffer (fast memory writes, no Cairo overhead)
256    let mut sink = PngSink {
257        img: &mut pixel_buffer,
258        x0: 0,
259        y0: 0,
260    };
261
262    let debug_overlay = if cfg!(feature = "debug_overlay") {
263        Some(DebugOverlay::grid_only())
264    } else {
265        None
266    };
267
268    pixel_renderer(
269        crate::params::RenderMollweideParams {
270            map,
271            scale: &scale_params,
272            cmap,
273            gamma,
274            scale_type: scale,
275            neg_mode,
276            bad_color,
277            meta,
278            hist_scale: hist_scale_opt.as_ref(),
279            view,
280            mask,
281            scale_cache: Some(&scale_cache),
282        },
283        layout,
284        &mut sink,
285        debug_overlay,
286    );
287
288    // Convert pre-rendered image to Cairo surface and paint onto PDF surface
289    // IMPORTANT: Cairo's Format::ARgb32 on little-endian systems expects bytes in
290    // memory as B, G, R, A (not A, R, G, B). image::RgbaImage stores as R, G, B, A,
291    // so we must reorder to B, G, R, A for Cairo.
292    let mut argb_buffer = Vec::with_capacity(pixel_buffer.len() * 4);
293    for pixel in pixel_buffer.pixels() {
294        // Convert RGBA to BGRA for Cairo::Format::ARgb32
295        argb_buffer.push(pixel[2]); // B
296        argb_buffer.push(pixel[1]); // G
297        argb_buffer.push(pixel[0]); // R
298        argb_buffer.push(pixel[3]); // A
299    }
300
301    if let Ok(pixel_surface) = cairo::ImageSurface::create_for_data(
302        argb_buffer,
303        cairo::Format::ARgb32,
304        map_w_int as i32,
305        map_h_int as i32,
306        map_w_int as i32 * 4,
307    ) {
308        let _ = cr_pdf.set_source_surface(&pixel_surface, layout.map_x, layout.map_y);
309        cr_pdf.paint().unwrap();
310    }
311
312    // Note: We no longer need surface_img for pixel rendering.
313    // It's retained for compatibility with graticule drawing below if needed.
314
315    // Draw graticule BEFORE border (so border appears on top)
316    if show_graticule {
317        use crate::graticule::{
318            render_graticule_cairo, render_graticule_cairo_with_color,
319            render_graticule_mollweide_vectorized,
320        };
321
322        let grat_coord_sys = grat_coord.unwrap_or(CoordSystem::E);
323
324        let graticule = render_graticule_mollweide_vectorized(
325            view,
326            dpar_deg,
327            dmer_deg,
328            grat_coord_sys,
329            view.input_coord,
330        );
331
332        // Render primary graticule in black
333        render_graticule_cairo(
334            &graticule,
335            &cr_pdf,
336            layout.map_x,
337            layout.map_y,
338            layout.map_w,
339            layout.map_h,
340        );
341
342        // Render secondary graticule overlay if specified
343        if let Some(overlay_sys) = grat_overlay {
344            let overlay_graticule = render_graticule_mollweide_vectorized(
345                view,
346                dpar_deg,
347                dmer_deg,
348                overlay_sys,
349                view.input_coord,
350            );
351
352            // Convert RGBA color to normalized RGB for Cairo
353            let r = overlay_color[0] as f64 / 255.0;
354            let g = overlay_color[1] as f64 / 255.0;
355            let b = overlay_color[2] as f64 / 255.0;
356
357            render_graticule_cairo_with_color(
358                &overlay_graticule,
359                &cr_pdf,
360                crate::params::GeometryRect {
361                    x: layout.map_x,
362                    y: layout.map_y,
363                    w: layout.map_w,
364                    h: layout.map_h,
365                },
366                (r, g, b),
367            );
368        }
369    }
370
371    // Draw vector border ON TOP
372    if draw_border {
373        draw_projection_border_pdf(
374            &cr_pdf,
375            layout.map_x,
376            layout.map_y,
377            layout.map_w,
378            layout.map_h,
379            layout.border_width_px,
380        );
381    }
382
383    if show_colorbar {
384        draw_colorbar_pdf(
385            &cr_pdf,
386            cb_layout,
387            crate::params::ColorbarParams {
388                cmap,
389                minv: scale_params.minv,
390                maxv: scale_params.maxv,
391                scale_type: scale,
392                gamma,
393                hist_scale: hist_scale_opt.as_ref(),
394                latex_rendering,
395                units,
396                extend: &params.display.extend,
397                units_font_size: params.display.units_font_size,
398                map_width: None,
399            },
400        );
401    }
402
403    // Draw figure labels (rlabel, llabel)
404    crate::render::pdf::draw_figure_labels_pdf(
405        &cr_pdf,
406        layout.width,
407        layout.height,
408        &params.display.rlabel,
409        &params.display.llabel,
410        latex_rendering,
411        params.display.label_font_size,
412    );
413
414    surface_pdf.finish();
415}
416
417pub fn plot_mollweide_png(params: MollweideParams) {
418    _plot_mollweide_png_impl_projected(params, render_mollweide_pixels, ProjectionType::Mollweide);
419}
420
421#[derive(Clone, Copy, Debug)]
422pub enum ProjectionType {
423    Mollweide,
424    Hammer,
425}
426
427pub fn _plot_mollweide_png_impl_projected<F>(
428    params: MollweideParams,
429    pixel_renderer: F,
430    projection: ProjectionType,
431) where
432    F: Fn(
433        crate::params::RenderMollweideParams,
434        MollweideLayout,
435        &mut dyn PixelSink,
436        Option<DebugOverlay>,
437    ),
438{
439    let map = params.plot.map;
440    let width = params.plot.width;
441    let filename = params.plot.filename;
442    let minv = params.scale.minv;
443    let maxv = params.scale.maxv;
444    let cmap = params.color.cmap;
445    let show_colorbar = params.display.show_colorbar;
446    let transparent = params.display.transparent;
447    let draw_border = params.display.draw_border;
448    let gamma = params.scale.gamma;
449    let scale = params.scale.scale;
450    let neg_mode = params.scale.neg_mode;
451    let bad_color = params.color.bad_color;
452    let bg_color = params.color.bg_color;
453    let meta = params.meta;
454    let latex_rendering = params.display.latex_rendering;
455    let units = params.display.units.as_deref();
456    let view = params.view;
457    let show_graticule = params.graticule.show_graticule;
458    let grat_coord = params.graticule.grat_coord;
459    let grat_overlay = params.graticule.grat_overlay;
460    let overlay_color = params.graticule.overlay_color;
461    let dpar_deg = params.graticule.dpar_deg;
462    let dmer_deg = params.graticule.dmer_deg;
463    let mask = params.display.mask.as_ref();
464
465    let (layout, cb_layout) = compute_mollweide_layout(
466        width as f64,
467        show_colorbar,
468        params.display.tick_direction.clone(),
469    );
470
471    let font_data = include_bytes!("../../assets/fonts/DejaVuSans.ttf");
472    let font = FontRef::try_from_slice(font_data).expect("Failed to load font");
473
474    let mut values: Vec<f64> = map.iter().filter(|&v| is_seen(*v)).copied().collect();
475
476    if values.is_empty() {
477        // Diagnostic: check what we actually have
478        let n_maps = map.len();
479        let n_finite = map.iter().filter(|v| v.is_finite()).count();
480        let n_gt_neg1e30 = map.iter().filter(|v| **v > -1e30).count();
481        let n_inf = map.iter().filter(|v| v.is_infinite()).count();
482        let n_nan = map.iter().filter(|v| v.is_nan()).count();
483
484        eprintln!("\n=== DEBUG: No valid HEALPix values found ===");
485        eprintln!("Total pixels in map: {}", n_maps);
486        eprintln!("Finite values: {}", n_finite);
487        eprintln!("Values > -1e30: {}", n_gt_neg1e30);
488        eprintln!("Infinite values: {}", n_inf);
489        eprintln!("NaN values: {}", n_nan);
490        if !map.is_empty() {
491            eprintln!(
492                "Min: {:.6e}, Max: {:.6e}",
493                map.iter().copied().fold(f64::INFINITY, f64::min),
494                map.iter().copied().fold(f64::NEG_INFINITY, f64::max)
495            );
496            eprintln!("First 5 values: {:?}", &map[..5.min(map.len())]);
497        }
498        panic!("Map contains no valid HEALPix values");
499    }
500
501    values.sort_unstable_by(unsafe_float_cmp);
502
503    let bg = Rgba([
504        bg_color[0],
505        bg_color[1],
506        bg_color[2],
507        if transparent { 0 } else { 255 },
508    ]);
509
510    let mut img = RgbaImage::from_pixel(layout.width as u32, layout.height as u32, bg);
511
512    let scale_params = compute_mollweide_scale(map, minv, maxv, gamma, scale);
513
514    let hist_scale = if scale == Scale::Histogram {
515        let range = match (minv, maxv) {
516            (Some(minv), Some(maxv)) => HistogramRange::Explicit {
517                min: minv,
518                max: maxv,
519            },
520            _ => HistogramRange::Full,
521        };
522
523        Some(build_histogram_scale(
524            map, range, 1024, // number of bins
525        ))
526    } else {
527        None
528    };
529
530    // Pre-compute scale cache for fast pixel rendering
531    let scale_cache = crate::scale::ScaleCache::new(scale_params.minv, scale_params.maxv, scale);
532
533    let mut sink = PngSink {
534        img: &mut img,
535        x0: layout.map_x as u32,
536        y0: layout.map_y as u32,
537    };
538
539    let debug_overlay = if cfg!(feature = "debug_overlay") {
540        Some(DebugOverlay::grid_only())
541    } else {
542        None
543    };
544
545    pixel_renderer(
546        crate::params::RenderMollweideParams {
547            map,
548            scale: &scale_params,
549            cmap,
550            gamma,
551            scale_type: scale,
552            neg_mode,
553            bad_color,
554            meta,
555            hist_scale: hist_scale.as_ref(),
556            view,
557            mask,
558            scale_cache: Some(&scale_cache),
559        },
560        layout,
561        &mut sink,
562        debug_overlay,
563    );
564
565    if draw_border || show_graticule {
566        use cairo::{Context, Format, ImageSurface};
567
568        // Creating a padded surface (shared for both border and graticule)
569        let pad = layout.border_width_px.ceil() as i32;
570        let surf_w = layout.map_w as i32 + 2 * pad;
571        let surf_h = layout.map_h as i32 + 2 * pad;
572
573        let mut border_surf = ImageSurface::create(Format::ARgb32, surf_w, surf_h).unwrap();
574
575        {
576            let border_cr = Context::new(&border_surf).unwrap();
577
578            border_cr.set_source_rgba(0.0, 0.0, 0.0, 0.0);
579            border_cr.paint().unwrap();
580
581            // Draw graticule using Cairo (anti-aliased) before border
582            if show_graticule {
583                use crate::graticule::{
584                    render_graticule_cairo, render_graticule_cairo_with_color,
585                    render_graticule_hammer_vectorized, render_graticule_mollweide_vectorized,
586                };
587
588                let grat_coord_sys = grat_coord.unwrap_or(CoordSystem::E);
589
590                let graticule = match projection {
591                    ProjectionType::Mollweide => render_graticule_mollweide_vectorized(
592                        view,
593                        dpar_deg,
594                        dmer_deg,
595                        grat_coord_sys,
596                        view.input_coord,
597                    ),
598                    ProjectionType::Hammer => render_graticule_hammer_vectorized(
599                        view,
600                        dpar_deg,
601                        dmer_deg,
602                        grat_coord_sys,
603                        view.input_coord,
604                    ),
605                };
606
607                // Render primary graticule on Cairo surface (anti-aliased)
608                render_graticule_cairo(
609                    &graticule,
610                    &border_cr,
611                    pad as f64,
612                    pad as f64,
613                    layout.map_w,
614                    layout.map_h,
615                );
616
617                // Render secondary graticule overlay if specified
618                if let Some(overlay_sys) = grat_overlay {
619                    let overlay_graticule = match projection {
620                        ProjectionType::Mollweide => render_graticule_mollweide_vectorized(
621                            view,
622                            dpar_deg,
623                            dmer_deg,
624                            overlay_sys,
625                            view.input_coord,
626                        ),
627                        ProjectionType::Hammer => render_graticule_hammer_vectorized(
628                            view,
629                            dpar_deg,
630                            dmer_deg,
631                            overlay_sys,
632                            view.input_coord,
633                        ),
634                    };
635
636                    // Convert RGBA color to normalized RGB for Cairo
637                    let r = overlay_color[0] as f64 / 255.0;
638                    let g = overlay_color[1] as f64 / 255.0;
639                    let b = overlay_color[2] as f64 / 255.0;
640
641                    render_graticule_cairo_with_color(
642                        &overlay_graticule,
643                        &border_cr,
644                        crate::params::GeometryRect {
645                            x: pad as f64,
646                            y: pad as f64,
647                            w: layout.map_w,
648                            h: layout.map_h,
649                        },
650                        (r, g, b),
651                    );
652                }
653            }
654
655            if draw_border {
656                draw_projection_border_pdf(
657                    &border_cr,
658                    pad as f64,
659                    pad as f64,
660                    layout.map_w,
661                    layout.map_h,
662                    layout.border_width_px,
663                );
664            }
665            // border_cr dropped here
666        }
667
668        border_surf.flush();
669
670        let stride = border_surf.stride() as usize;
671        let data = border_surf.data().unwrap();
672
673        for y in 0..surf_h {
674            for x in 0..surf_w {
675                let idx = (y as usize) * stride + (x as usize) * 4;
676                let a = data[idx + 3];
677                if a == 0 {
678                    continue;
679                }
680
681                let r = data[idx + 2];
682                let g = data[idx + 1];
683                let b = data[idx];
684
685                let dst_x = layout.map_x as i32 + x - pad;
686                let dst_y = layout.map_y as i32 + y - pad;
687
688                if dst_x < 0 || dst_y < 0 {
689                    continue;
690                }
691
692                let dst_x = dst_x as u32;
693                let dst_y = dst_y as u32;
694
695                if dst_x >= img.width() || dst_y >= img.height() {
696                    continue;
697                }
698
699                let dst = img.get_pixel(dst_x, dst_y);
700                let src = Rgba([r, g, b, a]);
701
702                let alpha = a as f32 / 255.0;
703
704                let out = Rgba([
705                    (src[0] as f32 + dst[0] as f32 * (1.0 - alpha)) as u8,
706                    (src[1] as f32 + dst[1] as f32 * (1.0 - alpha)) as u8,
707                    (src[2] as f32 + dst[2] as f32 * (1.0 - alpha)) as u8,
708                    (a as f32 + dst[3] as f32 * (1.0 - alpha)) as u8,
709                ]);
710
711                img.put_pixel(dst_x, dst_y, out);
712            }
713        }
714    }
715
716    if show_colorbar {
717        let mut sink = PngSink {
718            img: &mut img,
719            x0: layout.cbar_pad as u32,
720            y0: layout.cbar_y as u32,
721        };
722
723        render_colorbar_gradient(
724            0,
725            0,
726            layout.cbar_w as u32,
727            layout.cbar_h as u32,
728            cmap,
729            gamma,
730            &mut sink,
731        );
732
733        let ticks = generate_colorbar_ticks(
734            scale_params.minv,
735            scale_params.maxv,
736            &scale,
737            hist_scale.as_ref(),
738        );
739
740        // Draw extend arrows first so ticks render on top
741        crate::colorbar::draw_colorbar_extends(
742            &params.display.extend,
743            layout.cbar_pad,
744            layout.cbar_y,
745            layout.cbar_w,
746            layout.cbar_h,
747            cmap,
748            &mut img,
749        );
750
751        // Scale tick heights relative to colorbar
752        let major_tick_height = cb_layout.major_tick_height as u32;
753        let minor_tick_height = cb_layout.minor_tick_height as u32;
754
755        // Scale tick widths relative to image width
756        let major_tick_width = cb_layout.major_tick_width as u32;
757        let minor_tick_width = cb_layout.minor_tick_width as u32;
758
759        let tick_bottom = cb_layout.tick_bottom as u32;
760
761        // Major ticks + labels
762        let tick_top = tick_bottom.saturating_sub(major_tick_height);
763        for (&t, &val) in ticks.major_positions.iter().zip(ticks.major_values.iter()) {
764            let px = (layout.cbar_pad + (t * layout.cbar_w).round()) as u32;
765            for dx in 0..major_tick_width as i32 {
766                let x = (px as i32 + dx) as u32;
767                if x < layout.width as u32 {
768                    for py in tick_top - 1..=tick_bottom {
769                        img.put_pixel(x, py, Rgba([0, 0, 0, 255]));
770                    }
771                }
772            }
773
774            // Draw label
775            let label =
776                format_tick_label_with_units(val, scale, Some(t), latex_rendering, units, false);
777            let font_scale = PxScale::from(cb_layout.tick_font_size as f32);
778
779            // Measure actual text width using font metrics
780            let mut text_width = 0.0;
781            for ch in label.chars() {
782                let glyph_id = font.glyph_id(ch);
783                text_width += font.h_advance_unscaled(glyph_id) * font_scale.x;
784            }
785            let text_x = px as i32 - (text_width / 2.0) as i32;
786
787            draw_text_mut(
788                &mut img,
789                Rgba([0, 0, 0, 255]),
790                text_x,
791                cb_layout.tick_label_pad as i32,
792                font_scale,
793                &font,
794                &label,
795            );
796        }
797
798        // Minor ticks
799        let tick_top = tick_bottom.saturating_sub(minor_tick_height);
800        for (&t, &_val) in ticks.minor_positions.iter().zip(ticks.minor_values.iter()) {
801            let px = (layout.cbar_pad + (t * layout.cbar_w).round()) as u32;
802
803            for dx in 0..minor_tick_width as i32 {
804                let x = (px as i32 + dx) as u32;
805                if x < width {
806                    for py in tick_top - 1..=tick_bottom {
807                        img.put_pixel(x, py, Rgba([0, 0, 0, 255]));
808                    }
809                }
810            }
811        }
812    }
813
814    // Render graticule if requested
815    if show_graticule {
816        // Get the input coordinate system from the view transform
817        let grat_coord_sys = grat_coord.unwrap_or(CoordSystem::E);
818
819        // Create a temporary RasterGrid wrapper for the mollweide map region
820        let mut grid = RasterGrid {
821            width: layout.map_w as u32,
822            height: layout.map_h as u32,
823            buffer: vec![Rgba([0, 0, 0, 0]); (layout.map_w * layout.map_h) as usize],
824            valid: vec![true; (layout.map_w * layout.map_h) as usize],
825        };
826
827        // Copy the map region from the image
828        for y in 0..layout.map_h as u32 {
829            for x in 0..layout.map_w as u32 {
830                let src_x = layout.map_x as u32 + x;
831                let src_y = layout.map_y as u32 + y;
832                if src_x < img.width() && src_y < img.height() {
833                    let pixel = img.get_pixel(src_x, src_y);
834                    let idx = (y * grid.width + x) as usize;
835                    grid.buffer[idx] = *pixel;
836                }
837            }
838        }
839
840        // Render the graticule on the grid
841        crate::graticule::render_graticule_mollweide(
842            &mut grid,
843            view,
844            dpar_deg,
845            dmer_deg,
846            grat_coord_sys,
847            view.input_coord,
848        );
849
850        // Copy the grid back to the image
851        for y in 0..layout.map_h as u32 {
852            for x in 0..layout.map_w as u32 {
853                let dst_x = layout.map_x as u32 + x;
854                let dst_y = layout.map_y as u32 + y;
855                if dst_x < img.width() && dst_y < img.height() {
856                    let idx = (y * grid.width + x) as usize;
857                    img.put_pixel(dst_x, dst_y, grid.buffer[idx]);
858                }
859            }
860        }
861    }
862
863    // Draw units label below colorbar
864    if show_colorbar && let Some(units_str) = units {
865        let scale = layout.width / 1200.0;
866        let units_y = (cb_layout.tick_label_pad + 30.0 * scale) as i32;
867
868        if latex_rendering {
869            // Scale LaTeX font size with width (reduced for appropriate sizing)
870            let latex_font_size =
871                (cb_layout.tick_font_size * 0.467).round().clamp(3.0, 24.0) as u32;
872            // Try to render LaTeX and composite onto image
873            if let Some(rendered) =
874                crate::latex_render::render_latex_to_png(units_str, latex_font_size)
875            {
876                // Composite the rendered LaTeX PNG onto the main image
877                let latex_img = image::load_from_memory(&rendered.image_data)
878                    .expect("Failed to load rendered LaTeX");
879                let latex_rgba = latex_img.to_rgba8();
880
881                // Center horizontally
882                let x_offset = (layout.cbar_pad + layout.cbar_w / 2.0
883                    - latex_rgba.width() as f64 / 2.0) as i32;
884
885                // Composite with alpha blending
886                for (lx, ly, pixel) in latex_rgba.enumerate_pixels() {
887                    let img_x = x_offset + lx as i32;
888                    let img_y = units_y + ly as i32;
889
890                    if img_x >= 0
891                        && img_x < layout.width as i32
892                        && img_y >= 0
893                        && img_y < layout.height as i32
894                    {
895                        let alpha = pixel[3] as f32 / 255.0;
896                        if alpha > 0.01 {
897                            let existing = img.get_pixel(img_x as u32, img_y as u32);
898                            let blended = Rgba([
899                                ((pixel[0] as f32 * alpha + existing[0] as f32 * (1.0 - alpha))
900                                    as u8),
901                                ((pixel[1] as f32 * alpha + existing[1] as f32 * (1.0 - alpha))
902                                    as u8),
903                                ((pixel[2] as f32 * alpha + existing[2] as f32 * (1.0 - alpha))
904                                    as u8),
905                                255,
906                            ]);
907                            img.put_pixel(img_x as u32, img_y as u32, blended);
908                        }
909                    }
910                }
911            } else {
912                // Fallback to stripped LaTeX text if rendering fails
913                let units_label = units_str
914                    .strip_prefix('$')
915                    .unwrap_or(units_str)
916                    .strip_suffix('$')
917                    .unwrap_or(units_str);
918
919                let text_width_est =
920                    (units_label.len() as f32 * cb_layout.tick_font_size as f32 * 0.6) as i32;
921                let center_x =
922                    (layout.cbar_pad + layout.cbar_w / 2.0 - text_width_est as f64 / 2.0) as i32;
923
924                draw_text_mut(
925                    &mut img,
926                    Rgba([0, 0, 0, 255]),
927                    center_x,
928                    units_y,
929                    PxScale::from(cb_layout.tick_font_size as f32),
930                    &font,
931                    units_label,
932                );
933            }
934        } else {
935            // Non-LaTeX: render as plain text
936            if let Some(units_label) = crate::colorbar::format_units_label(false, Some(units_str)) {
937                let text_width_est =
938                    (units_label.len() as f32 * cb_layout.tick_font_size as f32 * 0.6) as i32;
939                let center_x =
940                    (layout.cbar_pad + layout.cbar_w / 2.0 - text_width_est as f64 / 2.0) as i32;
941
942                draw_text_mut(
943                    &mut img,
944                    Rgba([0, 0, 0, 255]),
945                    center_x,
946                    units_y,
947                    PxScale::from(cb_layout.tick_font_size as f32),
948                    &font,
949                    &units_label,
950                );
951            }
952        }
953    }
954
955    // Draw figure labels (rlabel, llabel)
956    draw_figure_labels_png(
957        &mut img,
958        layout.width as u32,
959        layout.height as u32,
960        &params.display.rlabel,
961        &params.display.llabel,
962        latex_rendering,
963        params.display.label_font_size,
964    );
965
966    img.save(filename).expect("Failed to save PNG");
967}
968
969pub fn plot_mollweide_auto(params: MollweideParams) {
970    let ext = Path::new(params.plot.filename.as_str())
971        .extension()
972        .and_then(|s| s.to_str())
973        .unwrap_or("")
974        .to_ascii_lowercase();
975
976    match ext.as_str() {
977        "png" => plot_mollweide_png(params),
978        "pdf" => plot_mollweide_pdf(params),
979        _ => {
980            panic!(
981                "Unsupported output format: .{} (expected .png or .pdf)",
982                ext
983            );
984        }
985    }
986}