Skip to main content

ccalc_plot/
colormap.rs

1//! Colormap LUT data and imagesc rendering (ASCII and SVG/PNG).
2
3#[cfg(feature = "plot-svg")]
4use plotters::prelude::*;
5
6#[cfg(any(feature = "plot", feature = "plot-svg"))]
7use crate::FigureState;
8
9// ── Public API ─────────────────────────────────────────────────────────────
10
11/// All supported colormap names.
12pub const VALID_COLORMAPS: &[&str] = &[
13    "viridis", "inferno", "magma", "plasma", "hot", "cool", "jet", "gray",
14];
15
16/// Validates a colormap name.
17///
18/// Returns `Ok(())` when `name` is a recognised colormap, otherwise returns an
19/// error string listing the valid choices.
20pub fn validate_colormap(name: &str) -> Result<(), String> {
21    if VALID_COLORMAPS.contains(&name) {
22        Ok(())
23    } else {
24        Err(format!(
25            "colormap: '{}' is not a recognised colormap. Valid colormaps: {}",
26            name,
27            VALID_COLORMAPS.join(", ")
28        ))
29    }
30}
31
32/// A colormap specification: either a built-in named colormap or a custom
33/// N×3 look-up table supplied by the user.
34#[derive(Clone, Debug, PartialEq)]
35pub enum ColormapSpec {
36    /// One of the built-in named colormaps (e.g. `"viridis"`, `"hot"`).
37    ///
38    /// Valid names are listed in [`VALID_COLORMAPS`].
39    Named(String),
40    /// Custom LUT: a vector of `(R, G, B)` triplets (at least two entries).
41    ///
42    /// Component values are in `[0, 255]`; entries are linearly interpolated.
43    Custom(Vec<(u8, u8, u8)>),
44}
45
46/// Maps a normalised value `t ∈ [0, 1]` to an `(R, G, B)` triple.
47///
48/// Values outside `[0, 1]` are clamped.  Unrecognised names fall back to
49/// `"viridis"`.
50///
51/// # Examples
52///
53/// ```
54/// use ccalc_plot::colormap::apply_colormap;
55/// let (r, g, b) = apply_colormap(0.0, "gray");
56/// assert_eq!((r, g, b), (0, 0, 0));
57/// let (r, g, b) = apply_colormap(1.0, "gray");
58/// assert_eq!((r, g, b), (255, 255, 255));
59/// ```
60pub fn apply_colormap(t: f64, name: &str) -> (u8, u8, u8) {
61    let t = t.clamp(0.0, 1.0);
62    match name {
63        "viridis" => lut_lerp(t, &VIRIDIS),
64        "inferno" => lut_lerp(t, &INFERNO),
65        "magma" => lut_lerp(t, &MAGMA),
66        "plasma" => lut_lerp(t, &PLASMA),
67        "hot" => lut_lerp(t, &HOT),
68        "cool" => lut_lerp(t, &COOL),
69        "jet" => lut_lerp(t, &JET),
70        "gray" => {
71            let v = (t * 255.0).round() as u8;
72            (v, v, v)
73        }
74        _ => lut_lerp(t, &VIRIDIS),
75    }
76}
77
78/// Maps a normalised value `t ∈ [0, 1]` to an `(R, G, B)` triple using `spec`.
79///
80/// Delegates to [`apply_colormap`] for [`ColormapSpec::Named`] and to the
81/// built-in LUT interpolator for [`ColormapSpec::Custom`].
82///
83/// # Examples
84///
85/// ```
86/// use ccalc_plot::colormap::{apply_colormap_spec, ColormapSpec};
87/// let spec = ColormapSpec::Named("gray".to_string());
88/// assert_eq!(apply_colormap_spec(0.0, &spec), (0, 0, 0));
89/// assert_eq!(apply_colormap_spec(1.0, &spec), (255, 255, 255));
90/// ```
91pub fn apply_colormap_spec(t: f64, spec: &ColormapSpec) -> (u8, u8, u8) {
92    match spec {
93        ColormapSpec::Named(name) => apply_colormap(t, name),
94        ColormapSpec::Custom(lut) => lut_lerp(t, lut),
95    }
96}
97
98/// Validates a [`ColormapSpec`], returning an error string on failure.
99///
100/// Named variants are checked against [`VALID_COLORMAPS`].  Custom variants
101/// require at least two LUT entries.
102pub fn validate_colormap_spec(spec: &ColormapSpec) -> Result<(), String> {
103    match spec {
104        ColormapSpec::Named(name) => validate_colormap(name),
105        ColormapSpec::Custom(lut) => {
106            if lut.len() < 2 {
107                Err("colormap: custom colormap must have at least 2 rows".into())
108            } else {
109                Ok(())
110            }
111        }
112    }
113}
114
115// ── LUT interpolation ──────────────────────────────────────────────────────
116
117fn lut_lerp(t: f64, lut: &[(u8, u8, u8)]) -> (u8, u8, u8) {
118    let n = lut.len();
119    if n == 1 {
120        return lut[0];
121    }
122    let ts = t * (n - 1) as f64;
123    let lo = (ts as usize).min(n - 2);
124    let hi = lo + 1;
125    let f = ts - lo as f64;
126    let lerp = |a: u8, b: u8| (a as f64 + f * (b as f64 - a as f64)).round() as u8;
127    (
128        lerp(lut[lo].0, lut[hi].0),
129        lerp(lut[lo].1, lut[hi].1),
130        lerp(lut[lo].2, lut[hi].2),
131    )
132}
133
134// ── LUT data ───────────────────────────────────────────────────────────────
135
136const VIRIDIS: [(u8, u8, u8); 8] = [
137    (68, 1, 84),
138    (72, 40, 120),
139    (62, 83, 160),
140    (49, 104, 142),
141    (53, 183, 121),
142    (101, 203, 94),
143    (180, 222, 44),
144    (253, 231, 37),
145];
146const INFERNO: [(u8, u8, u8); 8] = [
147    (0, 0, 4),
148    (40, 11, 84),
149    (101, 21, 110),
150    (159, 42, 99),
151    (212, 72, 66),
152    (245, 125, 21),
153    (252, 190, 44),
154    (252, 255, 164),
155];
156const MAGMA: [(u8, u8, u8); 8] = [
157    (0, 0, 4),
158    (28, 16, 68),
159    (79, 18, 123),
160    (129, 37, 129),
161    (181, 55, 122),
162    (229, 89, 104),
163    (251, 143, 107),
164    (252, 253, 191),
165];
166const PLASMA: [(u8, u8, u8); 8] = [
167    (13, 8, 135),
168    (84, 2, 163),
169    (139, 10, 165),
170    (185, 50, 137),
171    (219, 92, 104),
172    (243, 135, 72),
173    (253, 182, 44),
174    (240, 249, 33),
175];
176const HOT: [(u8, u8, u8); 8] = [
177    (0, 0, 0),
178    (96, 0, 0),
179    (192, 0, 0),
180    (255, 48, 0),
181    (255, 144, 0),
182    (255, 216, 0),
183    (255, 255, 96),
184    (255, 255, 255),
185];
186const COOL: [(u8, u8, u8); 8] = [
187    (0, 255, 255),
188    (36, 219, 255),
189    (73, 182, 255),
190    (109, 146, 255),
191    (146, 109, 255),
192    (182, 73, 255),
193    (219, 36, 255),
194    (255, 0, 255),
195];
196const JET: [(u8, u8, u8); 8] = [
197    (0, 0, 143),
198    (0, 0, 255),
199    (0, 218, 255),
200    (0, 255, 36),
201    (146, 255, 0),
202    (255, 218, 0),
203    (255, 36, 0),
204    (143, 0, 0),
205];
206
207// ── Data helpers ───────────────────────────────────────────────────────────
208
209/// Returns `(min, max)` of finite values in `z`.  Falls back to `(0, 1)` on
210/// all-NaN input; expands a degenerate range by 1.
211pub(crate) fn data_range(z: &[f64]) -> (f64, f64) {
212    let mut lo = f64::INFINITY;
213    let mut hi = f64::NEG_INFINITY;
214    for &v in z {
215        if v.is_finite() {
216            lo = lo.min(v);
217            hi = hi.max(v);
218        }
219    }
220    if !lo.is_finite() {
221        lo = 0.0;
222        hi = 1.0;
223    }
224    if (hi - lo).abs() < f64::EPSILON {
225        hi = lo + 1.0;
226    }
227    (lo, hi)
228}
229
230// ── ASCII renderer ─────────────────────────────────────────────────────────
231
232/// Renders `imagesc` as character art to stdout.
233///
234/// Uses a 10-level density palette `" .:-=+*#@█"` to approximate intensity.
235/// A one-line colorbar showing the data range is appended when
236/// `state.colorbar` is `true`.
237#[cfg(feature = "plot")]
238pub fn render_imagesc_ascii(z: &[f64], nrows: usize, ncols: usize, state: &FigureState) {
239    const DENSITY: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '@', '█'];
240
241    if nrows == 0 || ncols == 0 {
242        return;
243    }
244
245    let (z_min, z_max) = data_range(z);
246    let range = z_max - z_min;
247
248    if let Some(t) = &state.title {
249        println!("{t}");
250    }
251
252    for r in 0..nrows {
253        for c in 0..ncols {
254            let v = z[r * ncols + c];
255            let t = if range > 0.0 {
256                ((v - z_min) / range).clamp(0.0, 1.0)
257            } else {
258                0.5
259            };
260            let idx = ((t * 9.0) as usize).min(9);
261            print!("{}", DENSITY[idx]);
262        }
263        println!();
264    }
265
266    if state.colorbar {
267        let steps = 20_usize;
268        let gradient: String = (0..steps)
269            .map(|i| {
270                let t = i as f64 / (steps - 1).max(1) as f64;
271                let idx = ((t * 9.0) as usize).min(9);
272                DENSITY[idx]
273            })
274            .collect();
275        println!("{z_min:.4} [{gradient}] {z_max:.4}");
276    }
277    if let Some(xl) = &state.xlabel {
278        println!("x: {xl}");
279    }
280    if let Some(yl) = &state.ylabel {
281        println!("y: {yl}");
282    }
283}
284
285// ── imshow ASCII renderers ─────────────────────────────────────────────────
286
287/// Computes per-pixel luminance `L = 0.299·R + 0.587·G + 0.114·B`.
288///
289/// All three channel slices must have the same length `nrows * ncols`.
290/// Returns a flat row-major luminance vector of the same length, with each
291/// value clamped to `[0, 1]`.
292pub fn compute_luminance(r: &[f64], g: &[f64], b: &[f64]) -> Vec<f64> {
293    r.iter()
294        .zip(g.iter())
295        .zip(b.iter())
296        .map(|((&rv, &gv), &bv)| (0.299 * rv + 0.587 * gv + 0.114 * bv).clamp(0.0, 1.0))
297        .collect()
298}
299
300/// Renders `imshow(Z)` as character art to stdout.
301///
302/// Unlike `render_imagesc_ascii`, pixel values are clamped to `[0, 1]`
303/// directly; no min/max normalisation is applied.  Values above 1.0 map to
304/// white; values below 0.0 map to black.
305#[cfg(feature = "plot")]
306pub fn render_imshow_gray_ascii(z: &[f64], nrows: usize, ncols: usize, state: &FigureState) {
307    const DENSITY: [char; 10] = [' ', '.', ':', '-', '=', '+', '*', '#', '@', '█'];
308
309    if nrows == 0 || ncols == 0 {
310        return;
311    }
312    if let Some(t) = &state.title {
313        println!("{t}");
314    }
315    for r in 0..nrows {
316        for c in 0..ncols {
317            let v = z[r * ncols + c].clamp(0.0, 1.0);
318            let idx = ((v * 9.0) as usize).min(9);
319            print!("{}", DENSITY[idx]);
320        }
321        println!();
322    }
323    if let Some(xl) = &state.xlabel {
324        println!("x: {xl}");
325    }
326    if let Some(yl) = &state.ylabel {
327        println!("y: {yl}");
328    }
329}
330
331/// Renders `imshow(R, G, B)` as luminance character art to stdout.
332///
333/// Computes `L = 0.299·R + 0.587·G + 0.114·B` per pixel and maps the
334/// result through the 10-level density palette.  Equivalent to calling
335/// `imshow(L)` where `L` is the luminance matrix.
336#[cfg(feature = "plot")]
337pub fn render_imshow_rgb_ascii(
338    r: &[f64],
339    g: &[f64],
340    b: &[f64],
341    nrows: usize,
342    ncols: usize,
343    state: &FigureState,
344) {
345    let lum = compute_luminance(r, g, b);
346    render_imshow_gray_ascii(&lum, nrows, ncols, state);
347}
348
349// ── SVG/PNG file renderer ──────────────────────────────────────────────────
350
351/// Width reserved for the colorbar strip (pixels).
352#[cfg(feature = "plot-svg")]
353const CB_WIDTH: u32 = 80;
354
355/// Writes a false-colour image of `z` to an SVG or PNG file.
356///
357/// The active colormap is taken from `state.colormap` (default `"viridis"`).
358/// If `state.colorbar` is `true`, a gradient strip with value labels is
359/// appended on the right side of the image.
360/// Canvas size is taken from [`FigureState::canvas_size`] (default 800 × 600).
361#[cfg(feature = "plot-svg")]
362pub fn render_imagesc_file(
363    z: &[f64],
364    nrows: usize,
365    ncols: usize,
366    path: &str,
367    state: FigureState,
368) -> Result<(), String> {
369    let (width, height) = state.canvas_size();
370    if path.ends_with(".svg") {
371        let root = SVGBackend::new(path, (width, height)).into_drawing_area();
372        draw_imagesc(z, nrows, ncols, &state, root, width)
373    } else if path.ends_with(".png") {
374        let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
375        draw_imagesc(z, nrows, ncols, &state, root, width)
376    } else {
377        Err(format!("imagesc: unsupported format '{path}'"))
378    }
379}
380
381#[cfg(feature = "plot-svg")]
382fn draw_imagesc<DB: DrawingBackend>(
383    z: &[f64],
384    nrows: usize,
385    ncols: usize,
386    state: &FigureState,
387    root: DrawingArea<DB, plotters::coord::Shift>,
388    width: u32,
389) -> Result<(), String>
390where
391    DB::ErrorType: std::fmt::Display,
392{
393    let (r, g, b) = state.effective_bg_rgb();
394    root.fill(&RGBColor(r, g, b)).map_err(|e| e.to_string())?;
395
396    if nrows == 0 || ncols == 0 {
397        return root.present().map_err(|e| e.to_string());
398    }
399
400    let default_spec = ColormapSpec::Named("viridis".to_string());
401    let cmap_spec = state.colormap.as_ref().unwrap_or(&default_spec);
402    let (z_min, z_max) = data_range(z);
403    let range = z_max - z_min;
404
405    if state.colorbar {
406        let split = (width.saturating_sub(CB_WIDTH)) as i32;
407        let (img_area, cb_area) = root.split_horizontally(split);
408        draw_imagesc_cells(&img_area, z, nrows, ncols, state, cmap_spec, z_min, range)?;
409        draw_colorbar(&cb_area, z_min, z_max, cmap_spec)?;
410    } else {
411        draw_imagesc_cells(&root, z, nrows, ncols, state, cmap_spec, z_min, range)?;
412    }
413
414    root.present().map_err(|e| e.to_string())?;
415    Ok(())
416}
417
418#[cfg(feature = "plot-svg")]
419#[allow(clippy::too_many_arguments)]
420fn draw_imagesc_cells<DB: DrawingBackend>(
421    area: &DrawingArea<DB, plotters::coord::Shift>,
422    z: &[f64],
423    nrows: usize,
424    ncols: usize,
425    state: &FigureState,
426    spec: &ColormapSpec,
427    z_min: f64,
428    range: f64,
429) -> Result<(), String>
430where
431    DB::ErrorType: std::fmt::Display,
432{
433    let title = state.title.as_deref().unwrap_or("");
434    let xlabel = state.xlabel.as_deref().unwrap_or("");
435    let ylabel = state.ylabel.as_deref().unwrap_or("");
436
437    let mut chart = ChartBuilder::on(area)
438        .caption(title, ("sans-serif", 20))
439        .margin(30)
440        .x_label_area_size(40)
441        .y_label_area_size(50)
442        .build_cartesian_2d(0.0..(ncols as f64), 0.0..(nrows as f64))
443        .map_err(|e| e.to_string())?;
444
445    chart
446        .configure_mesh()
447        .x_desc(xlabel)
448        .y_desc(ylabel)
449        .disable_mesh()
450        .draw()
451        .map_err(|e| e.to_string())?;
452
453    // Row 0 of Z is the top row; map it to y ∈ [nrows-1, nrows].
454    for r in 0..nrows {
455        let y_lo = (nrows - 1 - r) as f64;
456        let y_hi = y_lo + 1.0;
457        for c in 0..ncols {
458            let v = z[r * ncols + c];
459            let t = if range > 0.0 {
460                ((v - z_min) / range).clamp(0.0, 1.0)
461            } else {
462                0.5
463            };
464            let (rr, gg, bb) = apply_colormap_spec(t, spec);
465            chart
466                .draw_series(std::iter::once(Rectangle::new(
467                    [(c as f64, y_lo), ((c + 1) as f64, y_hi)],
468                    RGBColor(rr, gg, bb).filled(),
469                )))
470                .map_err(|e| e.to_string())?;
471        }
472    }
473    Ok(())
474}
475
476#[cfg(feature = "plot-svg")]
477fn draw_colorbar<DB: DrawingBackend>(
478    area: &DrawingArea<DB, plotters::coord::Shift>,
479    z_min: f64,
480    z_max: f64,
481    spec: &ColormapSpec,
482) -> Result<(), String>
483where
484    DB::ErrorType: std::fmt::Display,
485{
486    let n_steps: usize = 64;
487    let step_h = (z_max - z_min) / n_steps as f64;
488
489    // Horizontal margins must be small: CB_WIDTH = 80 px, y_label_area = 40 px.
490    // margin_left=0 + margin_right=4 + y_label_area=40 → 36 px for the gradient strip.
491    let mut chart = ChartBuilder::on(area)
492        .margin_top(30)
493        .margin_bottom(30)
494        .margin_left(0)
495        .margin_right(4)
496        .x_label_area_size(0)
497        .y_label_area_size(40)
498        .build_cartesian_2d(0.0..1.0, z_min..z_max)
499        .map_err(|e| e.to_string())?;
500
501    // Draw the axis ticks / labels first (fills chart area with white background).
502    chart
503        .configure_mesh()
504        .disable_x_mesh()
505        .disable_y_mesh()
506        .draw()
507        .map_err(|e| e.to_string())?;
508
509    // Draw gradient on top of the white background.
510    chart
511        .draw_series((0..n_steps).map(|i| {
512            let t = i as f64 / (n_steps - 1).max(1) as f64;
513            let y_lo = z_min + i as f64 * step_h;
514            let y_hi = (y_lo + step_h).min(z_max);
515            let (r, g, b) = apply_colormap_spec(t, spec);
516            Rectangle::new([(0.0, y_lo), (1.0, y_hi)], RGBColor(r, g, b).filled())
517        }))
518        .map_err(|e| e.to_string())?;
519
520    Ok(())
521}
522
523// ── imshow file renderers ──────────────────────────────────────────────────
524
525/// Writes a grayscale image of `z` to an SVG or PNG file.
526///
527/// Each pixel value is clamped to `[0, 1]` and used directly as gray
528/// intensity — **no min/max normalisation** is applied (contrast with
529/// [`render_imagesc_file`] which normalises via [`data_range`]).
530#[cfg(feature = "plot-svg")]
531pub fn render_imshow_gray_file(
532    z: &[f64],
533    nrows: usize,
534    ncols: usize,
535    path: &str,
536    state: FigureState,
537) -> Result<(), String> {
538    let (width, height) = state.canvas_size();
539    if path.ends_with(".svg") {
540        let root = SVGBackend::new(path, (width, height)).into_drawing_area();
541        draw_imshow_gray(z, nrows, ncols, &state, root)
542    } else if path.ends_with(".png") {
543        let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
544        draw_imshow_gray(z, nrows, ncols, &state, root)
545    } else {
546        Err(format!("imshow: unsupported format '{path}'"))
547    }
548}
549
550#[cfg(feature = "plot-svg")]
551fn draw_imshow_gray<DB: DrawingBackend>(
552    z: &[f64],
553    nrows: usize,
554    ncols: usize,
555    state: &FigureState,
556    root: DrawingArea<DB, plotters::coord::Shift>,
557) -> Result<(), String>
558where
559    DB::ErrorType: std::fmt::Display,
560{
561    let (rb, gb, bb) = state.effective_bg_rgb();
562    root.fill(&RGBColor(rb, gb, bb))
563        .map_err(|e| e.to_string())?;
564
565    if nrows == 0 || ncols == 0 {
566        return root.present().map_err(|e| e.to_string());
567    }
568
569    let title = state.title.as_deref().unwrap_or("");
570    let xlabel = state.xlabel.as_deref().unwrap_or("");
571    let ylabel = state.ylabel.as_deref().unwrap_or("");
572
573    let mut chart = ChartBuilder::on(&root)
574        .caption(title, ("sans-serif", 20))
575        .margin(30)
576        .x_label_area_size(40)
577        .y_label_area_size(50)
578        .build_cartesian_2d(0.0..(ncols as f64), 0.0..(nrows as f64))
579        .map_err(|e| e.to_string())?;
580
581    chart
582        .configure_mesh()
583        .x_desc(xlabel)
584        .y_desc(ylabel)
585        .disable_mesh()
586        .draw()
587        .map_err(|e| e.to_string())?;
588
589    for r in 0..nrows {
590        let y_lo = (nrows - 1 - r) as f64;
591        let y_hi = y_lo + 1.0;
592        for c in 0..ncols {
593            let v = z[r * ncols + c].clamp(0.0, 1.0);
594            let gray = (v * 255.0).round() as u8;
595            chart
596                .draw_series(std::iter::once(Rectangle::new(
597                    [(c as f64, y_lo), ((c + 1) as f64, y_hi)],
598                    RGBColor(gray, gray, gray).filled(),
599                )))
600                .map_err(|e| e.to_string())?;
601        }
602    }
603
604    root.present().map_err(|e| e.to_string())?;
605    Ok(())
606}
607
608/// Writes an RGB image to an SVG or PNG file.
609///
610/// `r`, `g`, `b` are flat row-major channel slices (same length `nrows *
611/// ncols`); each component is clamped to `[0, 1]` before conversion to `u8`.
612/// One filled [`Rectangle`] is drawn per pixel.
613#[cfg(feature = "plot-svg")]
614pub fn render_imshow_rgb_file(
615    r: &[f64],
616    g: &[f64],
617    b: &[f64],
618    nrows: usize,
619    ncols: usize,
620    path: &str,
621    state: FigureState,
622) -> Result<(), String> {
623    let (width, height) = state.canvas_size();
624    if path.ends_with(".svg") {
625        let root = SVGBackend::new(path, (width, height)).into_drawing_area();
626        draw_imshow_rgb(r, g, b, nrows, ncols, &state, root)
627    } else if path.ends_with(".png") {
628        let root = BitMapBackend::new(path, (width, height)).into_drawing_area();
629        draw_imshow_rgb(r, g, b, nrows, ncols, &state, root)
630    } else {
631        Err(format!("imshow: unsupported format '{path}'"))
632    }
633}
634
635#[cfg(feature = "plot-svg")]
636#[allow(clippy::too_many_arguments)]
637fn draw_imshow_rgb<DB: DrawingBackend>(
638    r_ch: &[f64],
639    g_ch: &[f64],
640    b_ch: &[f64],
641    nrows: usize,
642    ncols: usize,
643    state: &FigureState,
644    root: DrawingArea<DB, plotters::coord::Shift>,
645) -> Result<(), String>
646where
647    DB::ErrorType: std::fmt::Display,
648{
649    let (rb, gb, bb) = state.effective_bg_rgb();
650    root.fill(&RGBColor(rb, gb, bb))
651        .map_err(|e| e.to_string())?;
652
653    if nrows == 0 || ncols == 0 {
654        return root.present().map_err(|e| e.to_string());
655    }
656
657    let title = state.title.as_deref().unwrap_or("");
658    let xlabel = state.xlabel.as_deref().unwrap_or("");
659    let ylabel = state.ylabel.as_deref().unwrap_or("");
660
661    let mut chart = ChartBuilder::on(&root)
662        .caption(title, ("sans-serif", 20))
663        .margin(30)
664        .x_label_area_size(40)
665        .y_label_area_size(50)
666        .build_cartesian_2d(0.0..(ncols as f64), 0.0..(nrows as f64))
667        .map_err(|e| e.to_string())?;
668
669    chart
670        .configure_mesh()
671        .x_desc(xlabel)
672        .y_desc(ylabel)
673        .disable_mesh()
674        .draw()
675        .map_err(|e| e.to_string())?;
676
677    let clamp_u8 = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
678
679    for r in 0..nrows {
680        let y_lo = (nrows - 1 - r) as f64;
681        let y_hi = y_lo + 1.0;
682        for c in 0..ncols {
683            let idx = r * ncols + c;
684            let rc = clamp_u8(r_ch[idx]);
685            let gc = clamp_u8(g_ch[idx]);
686            let bc = clamp_u8(b_ch[idx]);
687            chart
688                .draw_series(std::iter::once(Rectangle::new(
689                    [(c as f64, y_lo), ((c + 1) as f64, y_hi)],
690                    RGBColor(rc, gc, bc).filled(),
691                )))
692                .map_err(|e| e.to_string())?;
693        }
694    }
695
696    root.present().map_err(|e| e.to_string())?;
697    Ok(())
698}
699
700// ── Tests ──────────────────────────────────────────────────────────────────
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn test_apply_colormap_gray_extremes() {
708        assert_eq!(apply_colormap(0.0, "gray"), (0, 0, 0));
709        assert_eq!(apply_colormap(1.0, "gray"), (255, 255, 255));
710    }
711
712    #[test]
713    fn test_colormap_custom_2pt() {
714        let lut = vec![(0u8, 0, 0), (255u8, 255, 255)];
715        let spec = ColormapSpec::Custom(lut);
716        assert_eq!(apply_colormap_spec(0.0, &spec), (0, 0, 0));
717        assert_eq!(apply_colormap_spec(1.0, &spec), (255, 255, 255));
718    }
719
720    #[test]
721    fn test_colormap_custom_midpt() {
722        let lut = vec![(0u8, 0, 0), (200u8, 100, 50)];
723        let spec = ColormapSpec::Custom(lut);
724        let (r, g, b) = apply_colormap_spec(0.5, &spec);
725        assert_eq!(r, 100);
726        assert_eq!(g, 50);
727        assert_eq!(b, 25);
728    }
729
730    #[test]
731    fn test_colormap_custom_too_short() {
732        let spec = ColormapSpec::Custom(vec![(128u8, 0, 0)]);
733        assert!(validate_colormap_spec(&spec).is_err());
734    }
735
736    #[test]
737    fn test_colormap_spec_named_viridis() {
738        let spec = ColormapSpec::Named("viridis".to_string());
739        assert!(validate_colormap_spec(&spec).is_ok());
740        assert_eq!(
741            apply_colormap_spec(0.0, &spec),
742            apply_colormap(0.0, "viridis")
743        );
744        assert_eq!(
745            apply_colormap_spec(1.0, &spec),
746            apply_colormap(1.0, "viridis")
747        );
748    }
749
750    #[test]
751    fn test_apply_colormap_clamp() {
752        // Values outside [0,1] are clamped, not panicked.
753        let lo = apply_colormap(-1.0, "hot");
754        let hi = apply_colormap(2.0, "hot");
755        assert_eq!(lo, apply_colormap(0.0, "hot"));
756        assert_eq!(hi, apply_colormap(1.0, "hot"));
757    }
758
759    #[test]
760    fn test_apply_colormap_fallback() {
761        // Unknown colormap falls back to viridis — no panic.
762        let _ = apply_colormap(0.5, "unknown_colormap_xyz");
763    }
764
765    #[test]
766    fn test_validate_colormap_valid() {
767        for name in VALID_COLORMAPS {
768            assert!(validate_colormap(name).is_ok(), "'{name}' should be valid");
769        }
770    }
771
772    #[test]
773    fn test_validate_colormap_invalid() {
774        let result = validate_colormap("rainbow");
775        assert!(result.is_err());
776        let msg = result.unwrap_err();
777        assert!(
778            msg.contains("colormap"),
779            "error should mention colormap: {msg}"
780        );
781    }
782
783    #[cfg(any(feature = "plot", feature = "plot-svg"))]
784    #[test]
785    fn test_data_range_normal() {
786        let (lo, hi) = data_range(&[3.0, 1.0, 4.0, 1.5]);
787        assert!((lo - 1.0).abs() < 1e-9);
788        assert!((hi - 4.0).abs() < 1e-9);
789    }
790
791    #[cfg(any(feature = "plot", feature = "plot-svg"))]
792    #[test]
793    fn test_data_range_all_nan() {
794        let (lo, hi) = data_range(&[f64::NAN]);
795        assert_eq!((lo, hi), (0.0, 1.0));
796    }
797
798    #[cfg(any(feature = "plot", feature = "plot-svg"))]
799    #[test]
800    fn test_data_range_constant() {
801        // Constant input gets expanded so range > 0.
802        let (lo, hi) = data_range(&[5.0, 5.0, 5.0]);
803        assert!(hi > lo);
804    }
805
806    // ── imshow helpers ─────────────────────────────────────────────────────
807
808    #[test]
809    fn test_compute_luminance_known() {
810        // Pure red: L = 0.299*1 + 0.587*0 + 0.114*0 = 0.299
811        let lum = compute_luminance(&[1.0], &[0.0], &[0.0]);
812        assert!((lum[0] - 0.299).abs() < 1e-9);
813    }
814
815    #[test]
816    fn test_compute_luminance_clamps_above_1() {
817        // Components > 1.0: luminance is clamped to 1.0
818        let lum = compute_luminance(&[2.0], &[2.0], &[2.0]);
819        assert_eq!(lum[0], 1.0);
820    }
821
822    #[test]
823    fn test_compute_luminance_clamps_below_0() {
824        let lum = compute_luminance(&[-1.0], &[-1.0], &[-1.0]);
825        assert_eq!(lum[0], 0.0);
826    }
827
828    #[test]
829    fn test_imshow_gray_clamp_vs_imagesc_scale() {
830        // A value of 2.0 should clamp to 1.0 (white) in imshow,
831        // but not in imagesc (which scales min/max).
832        // Verify compute_luminance is not called; test the clamp logic directly.
833        // imshow clamps: 2.0.clamp(0,1) = 1.0 → gray = 255
834        let v: f64 = 2.0;
835        let gray = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
836        assert_eq!(gray, 255);
837        // imagesc would scale: (2.0 - 2.0) / 0.0 = fallback 0.5 → 127
838        // (degenerate case, but the point is imshow does NOT call data_range)
839        let v2: f64 = -0.5;
840        let gray2 = (v2.clamp(0.0, 1.0) * 255.0).round() as u8;
841        assert_eq!(gray2, 0);
842    }
843}