Skip to main content

map2fig/
cli.rs

1use crate::rotation::{CoordSystem, DEG2RAD, ViewTransform, view_rotation};
2use crate::{Colormap, NegMode, Scale, get_colormap, validate_scale_config};
3use clap::Parser;
4use image::Rgba;
5use std::str::FromStr;
6
7/// Simple HEALPix Mollweide plotter
8#[derive(Parser, Debug)]
9#[command(author, version, about)]
10pub struct Args {
11    /// Input FITS file
12    #[arg(short, long)]
13    pub fits: Option<String>,
14
15    /// Column index
16    #[arg(short = 'i', long, default_value_t = 0)]
17    pub col: usize,
18
19    /// Colormap name
20    #[arg(short = 'c', long, default_value = "planck")]
21    pub cmap: String,
22
23    /// Output width in pixels
24    #[arg(short, long, default_value_t = 1200)]
25    pub width: u32,
26
27    /// Output filename
28    #[arg(short, long)]
29    pub out: Option<String>,
30
31    /// Disable map border
32    #[arg(long)]
33    pub no_border: bool,
34
35    /// Transparent background
36    #[arg(long)]
37    pub transparent: bool,
38
39    /// Disable colorbar
40    #[arg(long)]
41    pub no_cbar: bool,
42
43    /// Lower color scale limit
44    #[arg(long, allow_negative_numbers = true)]
45    pub min: Option<f64>,
46
47    /// Upper color scale limit
48    #[arg(long, allow_negative_numbers = true)]
49    pub max: Option<f64>,
50
51    /// Gamma correction
52    #[arg(long, default_value_t = 1.0)]
53    pub gamma: f64,
54
55    /// Log scale
56    #[arg(long)]
57    pub log: bool,
58
59    /// Symmetric log
60    #[arg(long)]
61    pub symlog: bool,
62
63    /// Histogram equalization
64    #[arg(long)]
65    pub hist: bool,
66
67    /// Linear region width for symlog
68    #[arg(long)]
69    pub linthresh: Option<f64>,
70
71    /// Asinh scaling
72    #[arg(long)]
73    pub asinh: bool,
74
75    /// Negative/invalid handling: zero or unseen
76    #[arg(long, default_value = "unseen")]
77    pub neg_mode: String,
78
79    /// Bad pixel color: auto, gray, or r,g,b,a
80    #[arg(long)]
81    pub bad_color: Option<InputColor>,
82
83    /// Background pixel color: transparent, gray, or r,g,b,a
84    #[arg(long)]
85    pub bg_color: Option<InputColor>,
86
87    /// Planck logarithmic scaling
88    #[arg(long)]
89    pub planck_log: bool,
90
91    /// Factor that multiplies the data itself for unit conversions.
92    #[arg(long, default_value_t = 1.0)]
93    pub scale: f64,
94
95    /// Enable LaTeX-like mathematical rendering for colorbar labels
96    #[arg(long)]
97    pub latex: bool,
98
99    /// Units string for colorbar (supports LaTeX syntax when --latex is enabled)
100    #[arg(long)]
101    pub units: Option<String>,
102
103    /// Input coordinate system: gal, eq, ecl
104    #[arg(long, default_value = "gal")]
105    pub input_coord: String,
106
107    /// Output coordinate system: gal, eq, ecl
108    #[arg(long, default_value = "gal")]
109    pub output_coord: String,
110
111    /// Rotate view so that (lon,lat) becomes the new center `[degrees]`
112    #[arg(long, value_name = "LON,LAT")]
113    pub rotate_to: Option<String>,
114
115    /// Roll angle around the new center `[degrees]`
116    #[arg(long, default_value_t = 0.0)]
117    pub roll: f64,
118
119    /// Projection type: mollweide, gnomonic, or hammer
120    #[arg(long, default_value = "mollweide")]
121    pub projection: String,
122
123    /// Center longitude in degrees (gnomonic: projection center; mollweide: rotation center)
124    #[arg(long, alias = "gnom-lon", allow_negative_numbers = true)]
125    pub lon: Option<f64>,
126
127    /// Center latitude in `[degrees]` (gnomonic: projection center; mollweide: rotation center)
128    #[arg(long, alias = "gnom-lat", allow_negative_numbers = true)]
129    pub lat: Option<f64>,
130
131    /// Field of view width in arcminutes (gnomonic projection only)
132    #[arg(long, alias = "gnom-width", default_value_t = 300.0)]
133    pub fov: f64,
134
135    /// Resolution in arcmin/pixel (gnomonic projection only)
136    #[arg(long, alias = "gnom-res", default_value_t = 1.0)]
137    pub res: f64,
138
139    /// Enable local grid graticule for gnomonic projection
140    #[arg(long, alias = "gnom-graticule")]
141    pub local_graticule: bool,
142
143    /// Graticule spacing for parallels `[degrees]` (gnomonic projection only)
144    #[arg(long, alias = "gnom-grat-dlat", default_value_t = 1.0)]
145    pub local_grat_dlat: f64,
146
147    /// Graticule spacing for meridians `[degrees]` (gnomonic projection only)
148    #[arg(long, alias = "gnom-grat-dlon", default_value_t = 1.0)]
149    pub local_grat_dlon: f64,
150
151    /// Graticule line width in pixels (applies to both local and mollweide graticules)
152    #[arg(long, default_value_t = 1)]
153    pub grat_line_width: u32,
154
155    /// Disable text labels (title and resolution/pixel size labels)
156    #[arg(long)]
157    pub no_text: bool,
158
159    /// Disable title display
160    #[arg(long)]
161    pub no_title: bool,
162
163    /// Disable text scaling with FOV (use constant text sizes, gnomonic projection only)
164    #[arg(long)]
165    pub no_scale_text: bool,
166
167    /// Custom title for map (gnomonic: default is (lon, lat) at center)
168    #[arg(long)]
169    pub title: Option<String>,
170
171    /// Allows for more verbose output
172    #[arg(long)]
173    pub verbose: bool,
174
175    /// Disable automatic downgrading of high-resolution maps for performance
176    #[arg(long)]
177    pub no_downgrade: bool,
178
179    /// Enable graticule overlay (primary coordinate system for mollweide map)
180    #[arg(long)]
181    pub graticule: bool,
182
183    /// Primary graticule coordinate system: gal, eq, ecl
184    /// (defaults to the map's input coordinate system)
185    #[arg(long)]
186    pub grat_coord: Option<String>,
187
188    /// Secondary graticule coordinate system to overlay (e.g., show FK5 over Galactic)
189    /// Specify one of: gal, eq, ecl. If set with --grat-coord, both systems will be displayed.
190    #[arg(long)]
191    pub grat_coord_overlay: Option<String>,
192
193    /// Color for secondary graticule overlay (hex #RRGGBB or r,g,b,a format)
194    /// Default: yellow (#FFFF00) for good contrast on dark colormaps
195    #[arg(long, default_value = "#FFFF00")]
196    pub grat_overlay_color: InputColor,
197
198    /// Show coordinate labels on graticule lines (shows lat/lon values)
199    #[arg(long)]
200    pub grat_labels: bool,
201
202    /// Graticule spacing for parallels `[degrees]`
203    #[arg(long, default_value_t = 15.0)]
204    pub grat_par: f64,
205
206    /// Graticule spacing for meridians `[degrees]`
207    #[arg(long, default_value_t = 15.0)]
208    pub grat_mer: f64,
209
210    /// Extend colorbar with arrows at the ends: none, min, max, or both
211    #[arg(long, default_value = "none")]
212    pub extend: String,
213
214    /// Colorbar tick direction: inward/in (default) or outward/out
215    #[arg(long, default_value = "inward")]
216    pub tick_direction: String,
217
218    /// Tick label font size in points (default: auto-scaled, 12pt at width 800px)
219    #[arg(long)]
220    pub tick_font_size: Option<f32>,
221
222    /// Units text font size in points (default: auto-scaled, 16pt at width 800px)
223    #[arg(long)]
224    pub units_font_size: Option<f32>,
225
226    /// Text label for top-left corner
227    #[arg(long)]
228    pub llabel: Option<String>,
229
230    /// Text label for top-right corner
231    #[arg(long)]
232    pub rlabel: Option<String>,
233
234    /// Font size for labels in points (default: auto-scaled, 16pt at width 800px)
235    #[arg(long)]
236    pub label_font_size: Option<f32>,
237
238    /// Path to mask FITS file (binary mask: 0=masked, 1=valid)
239    #[arg(long)]
240    pub mask_file: Option<String>,
241
242    /// Mask pixels with values below this threshold
243    #[arg(long, allow_negative_numbers = true)]
244    pub mask_below: Option<f64>,
245
246    /// Mask pixels with values above this threshold
247    #[arg(long, allow_negative_numbers = true)]
248    pub mask_above: Option<f64>,
249
250    /// Color for masked regions: transparent, gray, or r,g,b,a format
251    #[arg(long, default_value = "transparent")]
252    pub maskfill_color: String,
253
254    /// Coordinate system of mask file (gal, eq, ecl) - auto-detects if not specified
255    #[arg(long)]
256    pub mask_coord: Option<String>,
257
258    /// Fast render mode: skip graticule, colorbar, and labels for faster iteration
259    #[arg(long)]
260    pub fast_render: bool,
261
262    /// PDF backend: cairo
263    #[arg(long, default_value = "cairo")]
264    pub pdf_backend: String,
265}
266
267/// Colorbar extend option: arrows at minimum, maximum, or both ends
268#[derive(Clone, Debug, PartialEq)]
269pub enum Extend {
270    None,
271    Min,
272    Max,
273    Both,
274}
275
276impl FromStr for Extend {
277    type Err = String;
278
279    fn from_str(s: &str) -> Result<Self, Self::Err> {
280        match s.to_lowercase().as_str() {
281            "none" => Ok(Extend::None),
282            "min" => Ok(Extend::Min),
283            "max" => Ok(Extend::Max),
284            "both" => Ok(Extend::Both),
285            _ => Err(format!(
286                "Invalid extend option '{}'. Expected: none, min, max, or both",
287                s
288            )),
289        }
290    }
291}
292
293/// Colorbar tick direction: inward (in) or outward (out)
294#[derive(Clone, Debug, PartialEq)]
295pub enum TickDirection {
296    Inward,  // Ticks point toward the colorbar (default)
297    Outward, // Ticks point away from the colorbar
298}
299
300impl FromStr for TickDirection {
301    type Err = String;
302
303    fn from_str(s: &str) -> Result<Self, Self::Err> {
304        match s.to_lowercase().as_str() {
305            "inward" | "in" => Ok(TickDirection::Inward),
306            "outward" | "out" => Ok(TickDirection::Outward),
307            _ => Err(format!(
308                "Invalid tick direction '{}'. Expected: inward (in) or outward (out)",
309                s
310            )),
311        }
312    }
313}
314
315/// Color option supporting hex (#RRGGBB), RGBA (r,g,b,a), or special keywords
316#[derive(Clone, Debug)]
317pub enum InputColor {
318    Gray,
319    Underflow,
320    Overflow,
321    Transparent,
322    Rgba(u8, u8, u8, u8),
323    Hex(String), // Store for later parsing with alpha
324}
325
326impl FromStr for InputColor {
327    type Err = String;
328
329    fn from_str(s: &str) -> Result<Self, Self::Err> {
330        let s_lower = s.to_lowercase();
331        match s_lower.as_str() {
332            "under" => Ok(InputColor::Underflow),
333            "over" => Ok(InputColor::Overflow),
334            "gray" | "grey" => Ok(InputColor::Gray),
335            "transparent" | "trans" => Ok(InputColor::Transparent),
336            _ => {
337                // Try hex color first
338                if s.starts_with('#') || (s.len() == 6 && s.chars().all(|c| c.is_ascii_hexdigit()))
339                {
340                    return Ok(InputColor::Hex(s.to_string()));
341                }
342
343                // Try RGBA format
344                let parts: Vec<_> = s.split(',').collect();
345                if parts.len() == 4 {
346                    let vals: Result<Vec<u8>, _> = parts.iter().map(|x| x.trim().parse()).collect();
347                    return match vals {
348                        Ok(v) => Ok(InputColor::Rgba(v[0], v[1], v[2], v[3])),
349                        Err(_) => Err("RGBA values must be 0–255".into()),
350                    };
351                }
352
353                Err(format!(
354                    "Invalid color format: '{}'. Expected hex (#RRGGBB), RGBA (r,g,b,a), or keyword (gray/transparent)",
355                    s
356                ))
357            }
358        }
359    }
360}
361
362pub fn resolve_input_color(
363    input: Option<InputColor>,
364    cmap: &Colormap,
365    transparent: bool,
366) -> Rgba<u8> {
367    match input.unwrap_or(InputColor::Gray) {
368        InputColor::Underflow => {
369            let c = cmap.under();
370            Rgba([c[0], c[1], c[2], if transparent { 0 } else { 255 }])
371        }
372        InputColor::Overflow => {
373            let c = cmap.over();
374            Rgba([c[0], c[1], c[2], if transparent { 0 } else { 255 }])
375        }
376        InputColor::Gray => Rgba([128, 128, 128, if transparent { 0 } else { 255 }]),
377        InputColor::Transparent => Rgba([255, 255, 255, 0]),
378        InputColor::Rgba(r, g, b, a) => Rgba([r, g, b, a]),
379        InputColor::Hex(hex_str) => {
380            match parse_hex_color(&hex_str, if transparent { 0 } else { 255 }) {
381                Ok(color) => color,
382                Err(e) => {
383                    eprintln!(
384                        "Warning: Failed to parse hex color '{}': {}, using gray",
385                        hex_str, e
386                    );
387                    Rgba([128, 128, 128, if transparent { 0 } else { 255 }])
388                }
389            }
390        }
391    }
392}
393
394/// Parse hex color string (e.g., "#FFFF00" or "FFFF00") to RGBA
395pub fn parse_hex_color(hex: &str, alpha: u8) -> Result<Rgba<u8>, String> {
396    let hex = hex.trim_start_matches('#');
397
398    if hex.len() != 6 {
399        return Err(format!("Hex color must be 6 digits, got: {}", hex));
400    }
401
402    let r =
403        u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
404    let g =
405        u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
406    let b =
407        u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
408
409    Ok(Rgba([r, g, b, alpha]))
410}
411
412/// Resolve an InputColor to RGBA with specified alpha value
413/// This is useful for overlay colors where you want specific transparency
414pub fn resolve_color_with_alpha(color: &InputColor, alpha: u8) -> Result<Rgba<u8>, String> {
415    match color {
416        InputColor::Hex(hex_str) => parse_hex_color(hex_str, alpha),
417        InputColor::Rgba(r, g, b, _) => Ok(Rgba([*r, *g, *b, alpha])), // Override alpha
418        InputColor::Gray => Ok(Rgba([128, 128, 128, alpha])),
419        InputColor::Transparent => Ok(Rgba([255, 255, 255, 0])), // Transparent is always fully transparent
420        InputColor::Underflow => Ok(Rgba([100, 100, 100, alpha])), // Fallback colors
421        InputColor::Overflow => Ok(Rgba([200, 200, 200, alpha])),
422    }
423}
424
425/// Resolved configuration for plotting
426pub struct PlotConfig {
427    pub scale: Scale,
428    pub colormap: &'static Colormap,
429    pub neg_mode: NegMode,
430    pub bad_color_rgba: image::Rgba<u8>,
431    pub bg_color_rgba: image::Rgba<u8>,
432    pub latex_rendering: bool,
433    pub units: Option<String>,
434}
435
436impl Args {
437    /// Validate that projection-specific arguments are only used with their respective projection
438    pub fn validate_projection_args(&self) -> Result<(), String> {
439        let projection = self.projection.to_lowercase();
440
441        // Check gnomonic-only args (--lon and --lat are shared, used for rotation in mollweide)
442        let gnom_only_provided = self.fov != 300.0
443            || self.res != 1.0
444            || self.local_graticule
445            || self.local_grat_dlat != 1.0
446            || self.local_grat_dlon != 1.0;
447
448        if gnom_only_provided && projection != "gnomonic" {
449            return Err(
450                "Gnomonic-specific arguments (--fov, --res, --local-graticule, \
451                 --local-grat-dlat, --local-grat-dlon) can only be used with \
452                 --projection gnomonic"
453                    .to_string(),
454            );
455        }
456
457        // Check mollweide-specific args (also applies to hammer)
458        let mollweide_args_provided = self.graticule;
459
460        if mollweide_args_provided && projection != "mollweide" && projection != "hammer" {
461            return Err(
462                "Mollweide/Hammer projection arguments (--graticule, --grat-coord, --grat-par, \
463                 --grat-mer) can only be used with --projection mollweide or hammer"
464                    .to_string(),
465            );
466        }
467
468        Ok(())
469    }
470
471    pub fn resolve_config(&self) -> Result<PlotConfig, String> {
472        // Validate projection-specific arguments first
473        self.validate_projection_args()?;
474
475        // Resolve scale
476        let (scale, cmap_name) = if self.planck_log {
477            (
478                Scale::PlanckLog {
479                    linthresh: self.linthresh.unwrap_or(300.0),
480                },
481                "planck-log",
482            )
483        } else {
484            let scale = if self.symlog {
485                Scale::Symlog {
486                    linthresh: self.linthresh.unwrap_or(1.0),
487                }
488            } else if self.asinh {
489                Scale::Asinh {
490                    scale: self.linthresh.unwrap_or(1.0),
491                }
492            } else if self.log {
493                Scale::Log
494            } else if self.hist {
495                Scale::Histogram
496            } else {
497                Scale::Linear
498            };
499
500            (scale, self.cmap.as_str())
501        };
502
503        // Validate scale configuration
504        validate_scale_config(&scale, self.min, self.max);
505
506        // Get colormap
507        let colormap = get_colormap(cmap_name);
508
509        // Resolve negative mode
510        let neg_mode = match self.neg_mode.as_str() {
511            "zero" => NegMode::Zero,
512            "unseen" => NegMode::Unseen,
513            _ => return Err("--neg-mode must be 'zero' or 'unseen'".to_string()),
514        };
515
516        // Resolve colors
517        let bad_color_rgba = resolve_input_color(
518            self.bad_color.clone().or(Some(InputColor::Gray)),
519            colormap,
520            self.transparent,
521        );
522        let bg_color_rgba = resolve_input_color(
523            self.bg_color.clone().or(Some(InputColor::Transparent)),
524            colormap,
525            self.transparent,
526        );
527
528        Ok(PlotConfig {
529            scale,
530            colormap,
531            neg_mode,
532            bad_color_rgba,
533            bg_color_rgba,
534            latex_rendering: self.latex,
535            units: self.units.clone(),
536        })
537    }
538
539    /// Determine output filename from --out flag or input FITS filename.
540    ///
541    /// # Logic
542    /// 1. If --out is explicitly provided, use it
543    /// 2. If input FITS file is provided, use it with .fits → .pdf extension
544    /// 3. If neither, use "map2fig_test.pdf" as fallback
545    pub fn get_output_filename(&self) -> String {
546        if let Some(ref out) = self.out {
547            out.clone()
548        } else if let Some(ref fits) = self.fits {
549            let path = std::path::Path::new(fits);
550            if let Some(stem) = path.file_stem() {
551                format!("{}.pdf", stem.to_string_lossy())
552            } else {
553                "map2fig_test.pdf".to_string()
554            }
555        } else {
556            "map2fig_test.pdf".to_string()
557        }
558    }
559
560    /// Check if coordinate transformation is needed.
561    ///
562    /// Returns Some(from_to_string) if input_coord != output_coord
563    pub fn describe_coord_transform(&self) -> Option<String> {
564        if self.input_coord != self.output_coord {
565            Some(format!(
566                "Rotating from {} to {} coordinates",
567                self.describe_coord(&self.input_coord),
568                self.describe_coord(&self.output_coord)
569            ))
570        } else {
571            None
572        }
573    }
574
575    /// Get human-readable coordinate system name
576    fn describe_coord(&self, coord: &str) -> String {
577        match coord.to_lowercase().as_str() {
578            "gal" | "galactic" => "Galactic".to_string(),
579            "eq" | "equatorial" => "Equatorial".to_string(),
580            "ecl" | "ecliptic" => "Ecliptic".to_string(),
581            _ => coord.to_string(),
582        }
583    }
584
585    pub fn resolve_view_transform(&self) -> Result<ViewTransform, String> {
586        let input = CoordSystem::from_str(&self.input_coord)?;
587        let output = CoordSystem::from_str(&self.output_coord)?;
588
589        // Note: view_rotation is ONLY applied explicitly via --rotate-to
590        // For gnomonic: --lon/--lat are projection center (no view rotation)
591        // For mollweide: use --rotate-to to apply rotation if needed
592        let view = if let Some(ref s) = self.rotate_to {
593            let parts: Vec<_> = s.split(',').collect();
594            if parts.len() != 2 {
595                return Err("--rotate-to expects lon,lat".into());
596            }
597            let lon = parts[0].parse::<f64>().map_err(|_| "bad lon")? * DEG2RAD;
598            let lat = parts[1].parse::<f64>().map_err(|_| "bad lat")? * DEG2RAD;
599            let roll = self.roll * DEG2RAD;
600            Some(view_rotation(lon, lat, roll))
601        } else {
602            None
603        };
604
605        Ok(ViewTransform::new(input, output, view))
606    }
607}