1use crate::chart::{
28 ensure_finite_and_non_empty, ensure_valid_y_range, new_line_chart, set_y_range, write_png,
29};
30use crate::error::Error;
31use charts_rs::{LineChart, Series};
32use std::ops::Range;
33use std::path::Path;
34
35#[derive(Debug, Clone)]
58pub struct Spectrum<'a> {
59 data: &'a [(f32, f32)],
60 highlights: Vec<f32>,
61 y_range: Option<Range<f32>>,
62 width: u32,
63 height: u32,
64 title: String,
65}
66
67impl<'a> Spectrum<'a> {
68 #[must_use]
71 pub const fn new(data: &'a [(f32, f32)]) -> Self {
72 Self {
73 data,
74 highlights: vec![],
75 y_range: None,
76 width: 1400,
77 height: 500,
78 title: String::new(),
79 }
80 }
81
82 #[must_use]
85 pub fn highlight(mut self, frequency_hz: f32) -> Self {
86 self.highlights.push(frequency_hz);
87 self
88 }
89
90 #[must_use]
93 pub fn highlights(mut self, frequencies_hz: &[f32]) -> Self {
94 for frequency in frequencies_hz {
95 self.highlights.push(*frequency);
96 }
97 self
98 }
99
100 #[must_use]
105 pub const fn y_range(mut self, range: Range<f32>) -> Self {
106 self.y_range = Some(range);
107 self
108 }
109
110 #[must_use]
112 pub const fn size(mut self, width: u32, height: u32) -> Self {
113 self.width = width;
114 self.height = height;
115 self
116 }
117
118 #[must_use]
120 pub fn title(mut self, title: impl Into<String>) -> Self {
121 self.title = title.into();
122 self
123 }
124
125 pub fn to_svg(&self) -> Result<String, Error> {
127 Ok(self.chart()?.svg()?)
128 }
129
130 pub fn write_png(&self, path: impl AsRef<Path>) -> Result<(), Error> {
133 write_png(&self.to_svg()?, path.as_ref())
134 }
135
136 fn chart(&self) -> Result<LineChart, Error> {
137 ensure_finite_and_non_empty(self.data.iter().flat_map(|(f, m)| [*f, *m]))?;
138
139 let mut data = self.data.to_vec();
140 data.sort_by(|a, b| a.0.total_cmp(&b.0));
141 let y_range = self.y_axis_range(&data)?;
142 for (_, magnitude) in &mut data {
143 *magnitude = magnitude.clamp(y_range.start, y_range.end);
144 }
145
146 let x_labels = data.iter().map(|(f, _)| format_frequency(*f)).collect();
147 let magnitudes = Series::new(
148 "magnitude".to_string(),
149 data.iter().map(|(_, m)| *m).collect(),
150 );
151 let mut series_list = vec![magnitudes];
152 if !self.highlights.is_empty() {
153 series_list.push(Series::new_nullable(
154 "highlight".to_string(),
155 highlight_series(&data, &self.highlights),
156 ));
157 }
158
159 let mut chart = new_line_chart(series_list, x_labels, self.width, self.height, &self.title);
160 chart.series_colors[1] = (255, 0, 0).into();
161 set_y_range(&mut chart, &y_range);
162 Ok(chart)
163 }
164
165 fn y_axis_range(&self, data: &[(f32, f32)]) -> Result<Range<f32>, Error> {
168 if let Some(range) = &self.y_range {
169 ensure_valid_y_range(range)?;
170 return Ok(range.clone());
171 }
172 let (min, max) = data.iter().fold((0.0_f32, 0.0_f32), |(lo, hi), (_, m)| {
173 (lo.min(*m), hi.max(*m))
174 });
175 let max = if max == min { 1.0 } else { max };
177 Ok(min..max)
178 }
179}
180
181fn highlight_series(data: &[(f32, f32)], highlights: &[f32]) -> Vec<Option<f32>> {
185 let mut overlay = vec![None; data.len()];
186 for highlight in highlights {
187 let nearest = data
188 .iter()
189 .enumerate()
190 .min_by(|(_, a), (_, b)| (a.0 - highlight).abs().total_cmp(&(b.0 - highlight).abs()))
191 .map(|(i, _)| i)
192 .expect("data is non-empty");
193 let range = nearest.saturating_sub(1)..=(nearest + 1).min(data.len() - 1);
194 for i in range {
195 overlay[i] = Some(data[i].1);
196 }
197 }
198 overlay
199}
200
201fn format_frequency(frequency_hz: f32) -> String {
203 if frequency_hz.fract().abs() < 0.01 {
204 format!("{frequency_hz:.0}")
205 } else {
206 format!("{frequency_hz:.1}")
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::chart::numeric_labels;
214 use crate::tests::testutil::TEST_OUT_DIR;
215
216 fn peak_60hz_spectrum() -> Vec<(f32, f32)> {
218 [
219 (0, 0.0),
220 (10, 5.0),
221 (20, 20.0),
222 (30, 40.0),
223 (40, 80.0),
224 (50, 120.0),
225 (55, 130.0),
226 (60, 140.0),
227 (65, 130.0),
228 (70, 120.0),
229 (80, 80.0),
230 (90, 40.0),
231 (100, 20.0),
232 (110, 5.0),
233 (120, 0.0),
234 (130, 0.0),
235 ]
236 .iter()
237 .map(|(f, m)| (*f as f32, *m))
238 .collect()
239 }
240
241 #[test]
242 fn highlight_marks_nearest_entries() {
243 let data = peak_60hz_spectrum();
244 let overlay = highlight_series(&data, &[61.0]);
245 assert_eq!(overlay[6], Some(130.0));
247 assert_eq!(overlay[7], Some(140.0));
248 assert_eq!(overlay[8], Some(130.0));
249 assert_eq!(overlay.iter().filter(|v| v.is_some()).count(), 3);
250 }
251
252 #[test]
253 fn auto_y_axis_ends_at_the_peak() {
254 let svg = Spectrum::new(&peak_60hz_spectrum()).to_svg().unwrap();
255 let max_label = numeric_labels(&svg).into_iter().fold(f32::MIN, f32::max);
257 assert_eq!(max_label, 140.0);
258 }
259
260 #[test]
261 fn y_range_fixes_the_axis_and_clips() {
262 let data = [(1.0, 10.0), (2.0, 1000.0), (3.0, 10.0)];
264 let svg = Spectrum::new(&data).y_range(0.0..100.0).to_svg().unwrap();
265 let labels = numeric_labels(&svg);
266 let max_label = labels.iter().copied().fold(f32::MIN, f32::max);
268 assert_eq!(max_label, 100.0);
269 assert!(labels.iter().all(|l| l.is_sign_positive()), "{labels:?}");
271 }
272
273 #[test]
274 fn rejects_invalid_y_range() {
275 for range in [1.0..0.0, 0.0..0.0, f32::NAN..1.0, 0.0..f32::INFINITY] {
276 assert!(matches!(
277 Spectrum::new(&[(0.0, 0.0)]).y_range(range).to_svg(),
278 Err(Error::InvalidData(_))
279 ));
280 }
281 }
282
283 #[test]
284 fn rejects_nan() {
285 assert!(matches!(
286 Spectrum::new(&[(0.0, f32::NAN)]).to_svg(),
287 Err(Error::InvalidData(_))
288 ));
289 }
290
291 #[test]
292 fn rejects_empty_input() {
293 assert!(matches!(
294 Spectrum::new(&[]).to_svg(),
295 Err(Error::InvalidData(_))
296 ));
297 }
298
299 #[test]
300 fn writes_png_file() {
301 let data = peak_60hz_spectrum();
302 Spectrum::new(&data)
303 .highlight(60.0)
304 .title("Spectrum with peak at 60 Hz")
305 .write_png(format!("{TEST_OUT_DIR}/spectrum_60hz_peak.png"))
306 .unwrap();
307 }
308}