nexrad_render/
color.rs

1//! Color scales for radar data visualization.
2//!
3//! This module provides types for mapping radar moment values to colors.
4//! The primary type is [`DiscreteColorScale`], which maps value ranges to
5//! specific colors based on threshold levels. For rendering, the scale is
6//! converted to a [`ColorLookupTable`] which provides O(1) color lookups.
7
8/// An RGBA color with components in the range 0.0 to 1.0.
9///
10/// This type is used for defining color scales. Colors are converted to
11/// 8-bit RGBA values during rendering.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct Color {
14    /// Red component (0.0 to 1.0)
15    pub r: f64,
16    /// Green component (0.0 to 1.0)
17    pub g: f64,
18    /// Blue component (0.0 to 1.0)
19    pub b: f64,
20    /// Alpha component (0.0 to 1.0)
21    pub a: f64,
22}
23
24impl Color {
25    /// Creates a new color from RGB components (alpha defaults to 1.0).
26    ///
27    /// Components should be in the range 0.0 to 1.0.
28    pub const fn rgb(r: f64, g: f64, b: f64) -> Self {
29        Self { r, g, b, a: 1.0 }
30    }
31
32    /// Creates a new color from RGBA components.
33    ///
34    /// Components should be in the range 0.0 to 1.0.
35    pub const fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
36        Self { r, g, b, a }
37    }
38
39    /// Converts the color to 8-bit RGBA values.
40    pub fn to_rgba8(&self) -> [u8; 4] {
41        [
42            (self.r * 255.0) as u8,
43            (self.g * 255.0) as u8,
44            (self.b * 255.0) as u8,
45            (self.a * 255.0) as u8,
46        ]
47    }
48
49    /// Black color.
50    pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0);
51
52    /// White color.
53    pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0);
54
55    /// Transparent (fully transparent black).
56    pub const TRANSPARENT: Color = Color::rgba(0.0, 0.0, 0.0, 0.0);
57}
58
59/// A single level in a discrete color scale.
60///
61/// Represents a threshold value and its associated color. Values at or above
62/// this threshold (but below the next higher threshold) will be rendered with
63/// this color.
64#[derive(Debug, Clone)]
65pub struct ColorScaleLevel {
66    value: f32,
67    color: Color,
68}
69
70impl ColorScaleLevel {
71    /// Creates a new color scale level.
72    ///
73    /// # Arguments
74    ///
75    /// * `value` - The threshold value
76    /// * `color` - The color to use for values at or above this threshold
77    pub fn new(value: f32, color: Color) -> Self {
78        Self { value, color }
79    }
80}
81
82/// A discrete color scale that maps value ranges to colors.
83///
84/// The scale works by finding the highest threshold that the input value
85/// exceeds, and returning the corresponding color. Levels are automatically
86/// sorted from highest to lowest threshold during construction.
87///
88/// # Example
89///
90/// ```
91/// use nexrad_render::{ColorScaleLevel, DiscreteColorScale, Color};
92///
93/// let scale = DiscreteColorScale::new(vec![
94///     ColorScaleLevel::new(0.0, Color::BLACK),
95///     ColorScaleLevel::new(30.0, Color::rgb(0.0, 1.0, 0.0)),
96///     ColorScaleLevel::new(50.0, Color::rgb(1.0, 0.0, 0.0)),
97/// ]);
98///
99/// // Values >= 50 return red, >= 30 return green, >= 0 return black
100/// ```
101#[derive(Debug, Clone)]
102pub struct DiscreteColorScale {
103    levels: Vec<ColorScaleLevel>,
104}
105
106impl DiscreteColorScale {
107    /// Creates a new discrete color scale from the given levels.
108    ///
109    /// Levels are automatically sorted from highest to lowest threshold.
110    pub fn new(mut levels: Vec<ColorScaleLevel>) -> Self {
111        levels.sort_by(|a, b| b.value.total_cmp(&a.value));
112        Self { levels }
113    }
114
115    /// Returns the color for the given value.
116    ///
117    /// Finds the highest threshold that the value exceeds and returns its color.
118    /// If the value is below all thresholds, returns the color of the lowest threshold.
119    pub fn get_color(&self, value: f32) -> Color {
120        let mut color = Color::BLACK;
121
122        for level in &self.levels {
123            if value >= level.value {
124                return level.color;
125            }
126
127            color = level.color;
128        }
129
130        color
131    }
132
133    /// Returns the levels in this color scale (sorted highest to lowest).
134    pub fn levels(&self) -> &[ColorScaleLevel] {
135        &self.levels
136    }
137}
138
139/// A pre-computed lookup table for O(1) color lookups.
140///
141/// This table maps a range of values to RGBA colors using a fixed-size array.
142/// It is created from a [`DiscreteColorScale`] and provides fast color lookups
143/// during rendering.
144///
145/// # Example
146///
147/// ```
148/// use nexrad_render::{ColorLookupTable, get_nws_reflectivity_scale};
149///
150/// let scale = get_nws_reflectivity_scale();
151/// let lut = ColorLookupTable::from_scale(&scale, -32.0, 95.0, 256);
152///
153/// // O(1) lookup returning [R, G, B, A] bytes
154/// let color = lut.get_color(45.0);
155/// ```
156#[derive(Debug, Clone)]
157pub struct ColorLookupTable {
158    /// RGBA color values indexed by quantized input value.
159    table: Vec<[u8; 4]>,
160    /// Minimum value in the mapped range.
161    min_value: f32,
162    /// Value range (max - min).
163    range: f32,
164}
165
166impl ColorLookupTable {
167    /// Creates a lookup table from a discrete color scale.
168    ///
169    /// # Arguments
170    ///
171    /// * `scale` - The color scale to sample from
172    /// * `min_value` - The minimum value to map
173    /// * `max_value` - The maximum value to map
174    /// * `size` - The number of entries in the lookup table (256 recommended)
175    ///
176    /// Values outside the min/max range will be clamped to the nearest entry.
177    pub fn from_scale(
178        scale: &DiscreteColorScale,
179        min_value: f32,
180        max_value: f32,
181        size: usize,
182    ) -> Self {
183        let range = max_value - min_value;
184        let mut table = Vec::with_capacity(size);
185
186        for i in 0..size {
187            let value = min_value + (i as f32 / (size - 1) as f32) * range;
188            let color = scale.get_color(value);
189            table.push(color.to_rgba8());
190        }
191
192        Self {
193            table,
194            min_value,
195            range,
196        }
197    }
198
199    /// Returns the RGBA color for the given value.
200    ///
201    /// This is an O(1) operation using direct array indexing.
202    #[inline]
203    pub fn get_color(&self, value: f32) -> [u8; 4] {
204        let normalized = (value - self.min_value) / self.range;
205        let index = (normalized * (self.table.len() - 1) as f32) as usize;
206        let index = index.min(self.table.len() - 1);
207        self.table[index]
208    }
209}
210
211/// Returns the standard NWS (National Weather Service) reflectivity color scale.
212///
213/// This scale uses colors commonly seen in weather radar displays, ranging
214/// from cyan/blue for light precipitation to magenta/white for extreme values.
215///
216/// | dBZ Range | Color | Meaning |
217/// |-----------|-------|---------|
218/// | 0-5 | Black | Below detection threshold |
219/// | 5-20 | Cyan/Blue | Light precipitation |
220/// | 20-35 | Green | Light to moderate precipitation |
221/// | 35-50 | Yellow/Orange | Moderate to heavy precipitation |
222/// | 50-65 | Red/Magenta | Heavy precipitation, possible hail |
223/// | 65+ | Purple/White | Extreme precipitation, likely hail |
224pub fn get_nws_reflectivity_scale() -> DiscreteColorScale {
225    DiscreteColorScale::new(vec![
226        ColorScaleLevel::new(0.0, Color::rgb(0.0000, 0.0000, 0.0000)),
227        ColorScaleLevel::new(5.0, Color::rgb(0.0000, 1.0000, 1.0000)),
228        ColorScaleLevel::new(10.0, Color::rgb(0.5294, 0.8078, 0.9216)),
229        ColorScaleLevel::new(15.0, Color::rgb(0.0000, 0.0000, 1.0000)),
230        ColorScaleLevel::new(20.0, Color::rgb(0.0000, 1.0000, 0.0000)),
231        ColorScaleLevel::new(25.0, Color::rgb(0.1961, 0.8039, 0.1961)),
232        ColorScaleLevel::new(30.0, Color::rgb(0.1333, 0.5451, 0.1333)),
233        ColorScaleLevel::new(35.0, Color::rgb(0.9333, 0.9333, 0.0000)),
234        ColorScaleLevel::new(40.0, Color::rgb(0.9333, 0.8627, 0.5098)),
235        ColorScaleLevel::new(45.0, Color::rgb(0.9333, 0.4627, 0.1294)),
236        ColorScaleLevel::new(50.0, Color::rgb(1.0000, 0.1882, 0.1882)),
237        ColorScaleLevel::new(55.0, Color::rgb(0.6902, 0.1882, 0.3765)),
238        ColorScaleLevel::new(60.0, Color::rgb(0.6902, 0.1882, 0.3765)),
239        ColorScaleLevel::new(65.0, Color::rgb(0.7294, 0.3333, 0.8275)),
240        ColorScaleLevel::new(70.0, Color::rgb(1.0000, 0.0000, 1.0000)),
241        ColorScaleLevel::new(75.0, Color::rgb(1.0000, 1.0000, 1.0000)),
242    ])
243}
244
245/// Returns a color scale for radial velocity data.
246///
247/// This divergent scale uses green for motion toward the radar (negative values)
248/// and red for motion away from the radar (positive values), with gray near zero.
249/// Range: -64 to +64 m/s (standard precipitation mode Doppler velocity).
250///
251/// | Velocity (m/s) | Color | Meaning |
252/// |----------------|-------|---------|
253/// | -64 to -48 | Dark Green | Strong inbound |
254/// | -48 to -32 | Green | Moderate inbound |
255/// | -32 to -16 | Light Green | Light inbound |
256/// | -16 to -4 | Pale Green | Very light inbound |
257/// | -4 to +4 | Gray | Near zero / RF |
258/// | +4 to +16 | Pale Red | Very light outbound |
259/// | +16 to +32 | Light Red/Pink | Light outbound |
260/// | +32 to +48 | Red | Moderate outbound |
261/// | +48 to +64 | Dark Red | Strong outbound |
262pub fn get_velocity_scale() -> DiscreteColorScale {
263    DiscreteColorScale::new(vec![
264        // Strong inbound (toward radar) - dark green
265        ColorScaleLevel::new(-64.0, Color::rgb(0.0000, 0.3922, 0.0000)),
266        ColorScaleLevel::new(-48.0, Color::rgb(0.0000, 0.5451, 0.0000)),
267        ColorScaleLevel::new(-32.0, Color::rgb(0.0000, 0.8039, 0.0000)),
268        ColorScaleLevel::new(-16.0, Color::rgb(0.5647, 0.9333, 0.5647)),
269        // Near zero - gray
270        ColorScaleLevel::new(-4.0, Color::rgb(0.6627, 0.6627, 0.6627)),
271        ColorScaleLevel::new(4.0, Color::rgb(0.6627, 0.6627, 0.6627)),
272        // Outbound (away from radar) - reds
273        ColorScaleLevel::new(16.0, Color::rgb(1.0000, 0.7529, 0.7961)),
274        ColorScaleLevel::new(32.0, Color::rgb(1.0000, 0.4118, 0.4118)),
275        ColorScaleLevel::new(48.0, Color::rgb(0.8039, 0.0000, 0.0000)),
276        ColorScaleLevel::new(64.0, Color::rgb(0.5451, 0.0000, 0.0000)),
277    ])
278}
279
280/// Returns a color scale for spectrum width data.
281///
282/// This sequential scale ranges from cool colors (low turbulence) to warm colors
283/// (high turbulence). Range: 0 to 30 m/s.
284///
285/// | Width (m/s) | Color | Meaning |
286/// |-------------|-------|---------|
287/// | 0-4 | Gray | Very low turbulence |
288/// | 4-8 | Blue | Low turbulence |
289/// | 8-12 | Cyan | Light turbulence |
290/// | 12-16 | Green | Moderate turbulence |
291/// | 16-20 | Yellow | Moderate-high turbulence |
292/// | 20-25 | Orange | High turbulence |
293/// | 25-30 | Red | Very high turbulence |
294pub fn get_spectrum_width_scale() -> DiscreteColorScale {
295    DiscreteColorScale::new(vec![
296        ColorScaleLevel::new(0.0, Color::rgb(0.5020, 0.5020, 0.5020)),
297        ColorScaleLevel::new(4.0, Color::rgb(0.0000, 0.0000, 0.8039)),
298        ColorScaleLevel::new(8.0, Color::rgb(0.0000, 0.8039, 0.8039)),
299        ColorScaleLevel::new(12.0, Color::rgb(0.0000, 0.8039, 0.0000)),
300        ColorScaleLevel::new(16.0, Color::rgb(0.9333, 0.9333, 0.0000)),
301        ColorScaleLevel::new(20.0, Color::rgb(1.0000, 0.6471, 0.0000)),
302        ColorScaleLevel::new(25.0, Color::rgb(1.0000, 0.0000, 0.0000)),
303    ])
304}
305
306/// Returns a color scale for differential reflectivity (ZDR) data.
307///
308/// This divergent scale shows negative values (vertically-oriented particles) in
309/// blue/purple, near-zero in gray, and positive values (horizontally-oriented
310/// particles like large raindrops) in yellow/orange/red.
311/// Range: -2 to +6 dB.
312///
313/// | ZDR (dB) | Color | Meaning |
314/// |----------|-------|---------|
315/// | -2 to -1 | Purple | Vertically oriented |
316/// | -1 to 0 | Blue | Slightly vertical |
317/// | 0 to 0.5 | Gray | Spherical particles |
318/// | 0.5 to 1.5 | Light Green | Slightly oblate |
319/// | 1.5 to 2.5 | Yellow | Oblate drops |
320/// | 2.5 to 4 | Orange | Large oblate drops |
321/// | 4 to 6 | Red | Very large drops/hail |
322pub fn get_differential_reflectivity_scale() -> DiscreteColorScale {
323    DiscreteColorScale::new(vec![
324        // Negative (vertically oriented)
325        ColorScaleLevel::new(-2.0, Color::rgb(0.5020, 0.0000, 0.5020)),
326        ColorScaleLevel::new(-1.0, Color::rgb(0.0000, 0.0000, 0.8039)),
327        // Near zero (spherical)
328        ColorScaleLevel::new(0.0, Color::rgb(0.6627, 0.6627, 0.6627)),
329        // Positive (horizontally oriented / oblate)
330        ColorScaleLevel::new(0.5, Color::rgb(0.5647, 0.9333, 0.5647)),
331        ColorScaleLevel::new(1.5, Color::rgb(0.9333, 0.9333, 0.0000)),
332        ColorScaleLevel::new(2.5, Color::rgb(1.0000, 0.6471, 0.0000)),
333        ColorScaleLevel::new(4.0, Color::rgb(1.0000, 0.0000, 0.0000)),
334    ])
335}
336
337/// Returns a color scale for correlation coefficient (CC/RhoHV) data.
338///
339/// This sequential scale emphasizes high values (0.9-1.0) which indicate
340/// meteorological targets. Lower values may indicate non-meteorological
341/// echoes, mixed precipitation, or tornadic debris.
342/// Range: 0.0 to 1.0.
343///
344/// | CC | Color | Meaning |
345/// |----|-------|---------|
346/// | 0.0-0.7 | Purple/Blue | Non-met or debris |
347/// | 0.7-0.85 | Cyan/Teal | Mixed phase/melting |
348/// | 0.85-0.92 | Light Green | Possible hail/graupel |
349/// | 0.92-0.96 | Green | Rain/snow mix |
350/// | 0.96-0.98 | Yellow | Pure rain or snow |
351/// | 0.98-1.0 | White/Light Gray | Uniform precipitation |
352pub fn get_correlation_coefficient_scale() -> DiscreteColorScale {
353    DiscreteColorScale::new(vec![
354        // Low CC - non-meteorological or debris
355        ColorScaleLevel::new(0.0, Color::rgb(0.0000, 0.0000, 0.0000)),
356        ColorScaleLevel::new(0.2, Color::rgb(0.3922, 0.0000, 0.5882)),
357        ColorScaleLevel::new(0.5, Color::rgb(0.0000, 0.0000, 0.8039)),
358        ColorScaleLevel::new(0.7, Color::rgb(0.0000, 0.5451, 0.5451)),
359        // Medium CC - mixed precipitation
360        ColorScaleLevel::new(0.85, Color::rgb(0.0000, 0.8039, 0.4000)),
361        ColorScaleLevel::new(0.92, Color::rgb(0.0000, 0.8039, 0.0000)),
362        // High CC - pure meteorological
363        ColorScaleLevel::new(0.96, Color::rgb(0.9333, 0.9333, 0.0000)),
364        ColorScaleLevel::new(0.98, Color::rgb(0.9020, 0.9020, 0.9020)),
365    ])
366}
367
368/// Returns a color scale for differential phase (PhiDP) data.
369///
370/// This sequential scale covers the full 0-360 degree range. Differential
371/// phase increases with propagation through precipitation.
372/// Range: 0 to 360 degrees.
373///
374/// | PhiDP (deg) | Color |
375/// |-------------|-------|
376/// | 0-45 | Purple |
377/// | 45-90 | Blue |
378/// | 90-135 | Cyan |
379/// | 135-180 | Green |
380/// | 180-225 | Yellow |
381/// | 225-270 | Orange |
382/// | 270-315 | Red |
383/// | 315-360 | Magenta |
384pub fn get_differential_phase_scale() -> DiscreteColorScale {
385    DiscreteColorScale::new(vec![
386        ColorScaleLevel::new(0.0, Color::rgb(0.5020, 0.0000, 0.5020)),
387        ColorScaleLevel::new(45.0, Color::rgb(0.0000, 0.0000, 0.8039)),
388        ColorScaleLevel::new(90.0, Color::rgb(0.0000, 0.8039, 0.8039)),
389        ColorScaleLevel::new(135.0, Color::rgb(0.0000, 0.8039, 0.0000)),
390        ColorScaleLevel::new(180.0, Color::rgb(0.9333, 0.9333, 0.0000)),
391        ColorScaleLevel::new(225.0, Color::rgb(1.0000, 0.6471, 0.0000)),
392        ColorScaleLevel::new(270.0, Color::rgb(1.0000, 0.0000, 0.0000)),
393        ColorScaleLevel::new(315.0, Color::rgb(1.0000, 0.0000, 1.0000)),
394    ])
395}
396
397/// Returns a color scale for specific differential phase (KDP) data.
398///
399/// This sequential scale shows the rate of differential phase change,
400/// which correlates with rainfall rate. Higher KDP indicates heavier rain.
401/// Range: 0 to 10 degrees/km.
402///
403/// | KDP (deg/km) | Color | Meaning |
404/// |--------------|-------|---------|
405/// | 0-0.5 | Gray | Very light/no rain |
406/// | 0.5-1.0 | Light Blue | Light rain |
407/// | 1.0-2.0 | Blue | Light-moderate rain |
408/// | 2.0-3.0 | Green | Moderate rain |
409/// | 3.0-4.5 | Yellow | Moderate-heavy rain |
410/// | 4.5-6.0 | Orange | Heavy rain |
411/// | 6.0-10.0 | Red | Very heavy rain |
412pub fn get_specific_diff_phase_scale() -> DiscreteColorScale {
413    DiscreteColorScale::new(vec![
414        ColorScaleLevel::new(0.0, Color::rgb(0.6627, 0.6627, 0.6627)),
415        ColorScaleLevel::new(0.5, Color::rgb(0.6784, 0.8471, 0.9020)),
416        ColorScaleLevel::new(1.0, Color::rgb(0.0000, 0.0000, 0.8039)),
417        ColorScaleLevel::new(2.0, Color::rgb(0.0000, 0.8039, 0.0000)),
418        ColorScaleLevel::new(3.0, Color::rgb(0.9333, 0.9333, 0.0000)),
419        ColorScaleLevel::new(4.5, Color::rgb(1.0000, 0.6471, 0.0000)),
420        ColorScaleLevel::new(6.0, Color::rgb(1.0000, 0.0000, 0.0000)),
421    ])
422}