1use serde::Deserialize;
9use std::{
10 cell::{Cell, Ref, RefCell},
11 rc::Rc,
12};
13use wasm_bindgen::{JsCast, JsValue, closure::Closure};
14use wasm_bindgen_futures::{JsFuture, future_to_promise};
15use web_sys::{
16 Document, Geolocation, HtmlButtonElement, HtmlDialogElement, HtmlElement, HtmlInputElement,
17 HtmlParagraphElement, HtmlSelectElement, HtmlSpanElement, PositionOptions, Response, Window,
18};
19
20use crate::render::RenderEngine;
21use crate::waterfall::Waterfall;
22
23use input::{CheckboxInput, EnumInput, InputElement, NumberInput, NumberSpan, TextInput};
24
25pub mod active;
26pub mod colormap;
27pub mod input;
28#[macro_use]
29mod macros;
30mod preferences;
33pub mod request;
34
35const API_URL: &str = "/api";
36const AD9361_URL: &str = "/api/ad9361";
37const DDC_CONFIG_URL: &str = "/api/ddc/config";
38const DDC_DESIGN_URL: &str = "/api/ddc/design";
39const GEOLOCATION_URL: &str = "/api/geolocation";
40const RECORDER_URL: &str = "/api/recorder";
41const RECORDING_METADATA_URL: &str = "/api/recording/metadata";
42const SPECTROMETER_URL: &str = "/api/spectrometer";
43const TIME_URL: &str = "/api/time";
44
45#[derive(Clone)]
50pub struct Ui {
51 window: Rc<Window>,
52 document: Rc<Document>,
53 elements: Elements,
54 api_state: Rc<RefCell<Option<maia_json::Api>>>,
55 geolocation: Rc<RefCell<Option<Geolocation>>>,
56 geolocation_watch_id: Rc<Cell<Option<i32>>>,
57 local_settings: Rc<RefCell<LocalSettings>>,
58 preferences: Rc<RefCell<preferences::Preferences>>,
59 render_engine: Rc<RefCell<RenderEngine>>,
60 waterfall: Rc<RefCell<Waterfall>>,
61}
62
63ui_elements! {
65 colormap_select: HtmlSelectElement => EnumInput<colormap::Colormap>,
66 waterfall_show_waterfall: HtmlInputElement => CheckboxInput,
67 waterfall_show_spectrum: HtmlInputElement => CheckboxInput,
68 waterfall_show_ddc: HtmlInputElement => CheckboxInput,
69 recorder_button: HtmlButtonElement => Rc<HtmlButtonElement>,
70 recorder_button_replica: HtmlButtonElement => Rc<HtmlButtonElement>,
71 settings_button: HtmlButtonElement => Rc<HtmlButtonElement>,
72 alert_dialog: HtmlDialogElement => Rc<HtmlDialogElement>,
73 alert_message: HtmlParagraphElement => Rc<HtmlParagraphElement>,
74 close_alert: HtmlButtonElement => Rc<HtmlButtonElement>,
75 settings: HtmlDialogElement => Rc<HtmlDialogElement>,
76 close_settings: HtmlButtonElement => Rc<HtmlButtonElement>,
77 recording_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
78 ddc_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
79 waterfall_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
80 geolocation_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
81 other_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
82 recording_panel: HtmlElement => Rc<HtmlElement>,
83 ddc_panel: HtmlElement => Rc<HtmlElement>,
84 waterfall_panel: HtmlElement => Rc<HtmlElement>,
85 geolocation_panel: HtmlElement => Rc<HtmlElement>,
86 other_panel: HtmlElement => Rc<HtmlElement>,
87 waterfall_min: HtmlInputElement => NumberInput<f32>,
88 waterfall_max: HtmlInputElement => NumberInput<f32>,
89 ad9361_rx_lo_frequency: HtmlInputElement
90 => NumberInput<u64, input::MHzPresentation>,
91 ad9361_sampling_frequency: HtmlInputElement
92 => NumberInput<u32, input::MHzPresentation>,
93 ad9361_rx_rf_bandwidth: HtmlInputElement
94 => NumberInput<u32, input::MHzPresentation>,
95 ad9361_rx_gain_mode: HtmlSelectElement => EnumInput<maia_json::Ad9361GainMode>,
96 ad9361_rx_gain: HtmlInputElement => NumberInput<f64>,
97 ddc_frequency: HtmlInputElement => NumberInput<f64, input::KHzPresentation>,
98 ddc_decimation: HtmlInputElement => NumberInput<u32>,
99 ddc_transition_bandwidth: HtmlInputElement => NumberInput<f64>,
100 ddc_passband_ripple: HtmlInputElement => NumberInput<f64>,
101 ddc_stopband_attenuation_db: HtmlInputElement => NumberInput<f64>,
102 ddc_stopband_one_over_f: HtmlInputElement => CheckboxInput,
103 ddc_output_sampling_frequency: HtmlSpanElement => NumberSpan<f64, input::MHzPresentation>,
104 ddc_max_input_sampling_frequency: HtmlSpanElement => NumberSpan<f64, input::MHzPresentation>,
105 spectrometer_input: HtmlSelectElement => EnumInput<maia_json::SpectrometerInput>,
106 spectrometer_output_sampling_frequency: HtmlInputElement
107 => NumberInput<f64, input::IntegerPresentation>,
108 spectrometer_mode: HtmlSelectElement => EnumInput<maia_json::SpectrometerMode>,
109 recording_metadata_filename: HtmlInputElement => TextInput,
110 recorder_prepend_timestamp: HtmlInputElement => CheckboxInput,
111 recording_metadata_description: HtmlInputElement => TextInput,
112 recording_metadata_author: HtmlInputElement => TextInput,
113 recorder_mode: HtmlSelectElement => EnumInput<maia_json::RecorderMode>,
114 recorder_maximum_duration: HtmlInputElement => NumberInput<f64>,
115 recording_metadata_geolocation: HtmlSpanElement => Rc<HtmlSpanElement>,
116 recording_metadata_geolocation_update: HtmlButtonElement => Rc<HtmlButtonElement>,
117 recording_metadata_geolocation_clear: HtmlButtonElement => Rc<HtmlButtonElement>,
118 geolocation_point: HtmlSpanElement => Rc<HtmlSpanElement>,
119 geolocation_update: HtmlButtonElement => Rc<HtmlButtonElement>,
120 geolocation_watch: HtmlInputElement => CheckboxInput,
121 geolocation_clear: HtmlButtonElement => Rc<HtmlButtonElement>,
122 firmware_version: HtmlSpanElement => Rc<HtmlSpanElement>,
123 maia_httpd_version: HtmlSpanElement => Rc<HtmlSpanElement>,
124 maia_hdl_version: HtmlSpanElement => Rc<HtmlSpanElement>,
125 maia_wasm_version: HtmlSpanElement => Rc<HtmlSpanElement>,
126}
127
128#[derive(Default)]
129struct LocalSettings {
130 waterfall_show_ddc: bool,
131}
132
133impl Ui {
134 pub fn new(
136 window: Rc<Window>,
137 document: Rc<Document>,
138 render_engine: Rc<RefCell<RenderEngine>>,
139 waterfall: Rc<RefCell<Waterfall>>,
140 ) -> Result<Ui, JsValue> {
141 let elements = Elements::new(&document)?;
142 let preferences = Rc::new(RefCell::new(preferences::Preferences::new(&window)?));
143 let ui = Ui {
144 window,
145 document,
146 elements,
147 api_state: Rc::new(RefCell::new(None)),
148 geolocation: Rc::new(RefCell::new(None)),
149 geolocation_watch_id: Rc::new(Cell::new(None)),
150 local_settings: Rc::new(RefCell::new(LocalSettings::default())),
151 preferences,
152 render_engine,
153 waterfall,
154 };
155 ui.elements
156 .maia_wasm_version
157 .set_text_content(Some(&format!(
158 "v{} git {}",
159 crate::version::maia_wasm_version(),
160 crate::version::maia_wasm_git_version()
161 )));
162 ui.set_callbacks()?;
163 ui.preferences.borrow().apply(&ui)?;
164 ui.set_callbacks_post_apply()?;
165 Ok(ui)
166 }
167
168 fn set_callbacks(&self) -> Result<(), JsValue> {
169 self.set_api_get_periodic(1000)?;
170
171 set_on!(
172 change,
173 self,
174 colormap_select,
175 waterfall_show_waterfall,
176 waterfall_show_spectrum,
177 waterfall_show_ddc,
178 waterfall_min,
179 waterfall_max,
180 ad9361_rx_lo_frequency,
181 ad9361_sampling_frequency,
182 ad9361_rx_rf_bandwidth,
183 ad9361_rx_gain_mode,
184 ddc_frequency,
185 spectrometer_input,
186 spectrometer_output_sampling_frequency,
187 spectrometer_mode,
188 recording_metadata_filename,
189 recorder_prepend_timestamp,
190 recording_metadata_description,
191 recording_metadata_author,
192 recorder_mode,
193 recorder_maximum_duration,
194 geolocation_watch
195 );
196
197 self.elements.ad9361_rx_gain.set_onchange(Some(
199 self.ad9361_rx_gain_onchange_manual()
200 .into_js_value()
201 .unchecked_ref(),
202 ));
203
204 set_on!(
205 click,
206 self,
207 recorder_button,
208 settings_button,
209 close_alert,
210 close_settings,
211 recording_metadata_geolocation_update,
212 recording_metadata_geolocation_clear,
213 geolocation_update,
214 geolocation_clear,
215 recording_tab,
216 ddc_tab,
217 waterfall_tab,
218 geolocation_tab,
219 other_tab
220 );
221 self.elements
222 .recorder_button_replica
223 .set_onclick(self.elements.recorder_button.onclick().as_ref());
224
225 Ok(())
226 }
227
228 fn set_callbacks_post_apply(&self) -> Result<(), JsValue> {
229 let put_ddc_design = self.ddc_put_design_closure().into_js_value();
234 let ddc_onchange = put_ddc_design.unchecked_ref();
235 self.elements
236 .ddc_decimation
237 .set_onchange(Some(ddc_onchange));
238 self.elements
239 .ddc_transition_bandwidth
240 .set_onchange(Some(ddc_onchange));
241 self.elements
242 .ddc_passband_ripple
243 .set_onchange(Some(ddc_onchange));
244 self.elements
245 .ddc_stopband_attenuation_db
246 .set_onchange(Some(ddc_onchange));
247 self.elements
248 .ddc_stopband_one_over_f
249 .set_onchange(Some(ddc_onchange));
250 ddc_onchange.call0(&JsValue::NULL)?;
252 Ok(())
253 }
254}
255
256impl Ui {
258 fn alert(&self, message: &str) -> Result<(), JsValue> {
259 self.elements.alert_message.set_text_content(Some(message));
260 self.elements.alert_dialog.show_modal()?;
261 Ok(())
262 }
263
264 fn close_alert_onclick(&self) -> Closure<dyn Fn()> {
265 let ui = self.clone();
266 Closure::new(move || ui.elements.alert_dialog.close())
267 }
268}
269
270impl Ui {
272 fn settings_button_onclick(&self) -> Closure<dyn Fn()> {
273 let ui = self.clone();
274 Closure::new(move || {
275 if ui.elements.settings.open() {
276 ui.elements.settings.close();
277 } else {
278 ui.elements.settings.show();
279 }
280 })
281 }
282
283 fn close_settings_onclick(&self) -> Closure<dyn Fn()> {
284 let ui = self.clone();
285 Closure::new(move || ui.elements.settings.close())
286 }
287
288 impl_tabs!(recording, ddc, waterfall, geolocation, other);
289}
290
291impl Ui {
293 fn set_api_get_periodic(&self, interval_ms: i32) -> Result<(), JsValue> {
294 let ui = self.clone();
295 let handler = Closure::<dyn Fn() -> js_sys::Promise>::new(move || {
296 let ui = ui.clone();
297 future_to_promise(async move {
298 ui.get_api_update_elements().await?;
299 Ok(JsValue::NULL)
300 })
301 });
302 let handler_ = handler.into_js_value();
303 let handler: &js_sys::Function = handler_.unchecked_ref();
304 handler.call0(&JsValue::NULL)?;
306 self.window
308 .set_interval_with_callback_and_timeout_and_arguments_0(handler, interval_ms)?;
309 Ok(())
310 }
311
312 async fn get_api_update_elements(&self) -> Result<(), JsValue> {
313 let json = self.get_api().await?;
314 self.api_state.replace(Some(json.clone()));
315 self.update_ad9361_inactive_elements(&json.ad9361)?;
316 self.update_ddc_inactive_elements(&json.ddc)?;
317 self.update_spectrometer_inactive_elements(&json.spectrometer)?;
318 self.update_waterfall_rate(&json.spectrometer);
319 self.update_recorder_button(&json.recorder);
320 self.update_recording_metadata_inactive_elements(&json.recording_metadata)?;
321 self.update_recorder_inactive_elements(&json.recorder)?;
322 self.update_geolocation_elements(&json.geolocation)?;
323 self.update_versions_elements(&json.versions);
324
325 self.update_server_time(&json.time).await?;
328
329 Ok(())
330 }
331
332 async fn get_api(&self) -> Result<maia_json::Api, JsValue> {
333 let response = JsFuture::from(self.window.fetch_with_str(API_URL))
334 .await?
335 .dyn_into::<Response>()?;
336 request::response_to_json(&response).await
337 }
338}
339
340impl Ui {
342 pub fn set_rx_frequency(&self, freq: u64) -> Result<(), JsValue> {
348 let mut ad9361_freq = Some(freq);
349 let state = self.api_state.borrow();
350 let Some(state) = state.as_ref() else {
351 return Err("set_rx_frequency: api_state not available yet".into());
352 };
353 if matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC) {
354 let samp_rate = state.ad9361.sampling_frequency as f64;
356 let mut ddc_freq = freq as f64 - state.ad9361.rx_lo_frequency as f64;
357 const MARGIN: f64 = 0.5 * (1.0 - 0.15);
360 let ddc_samp_rate = state.ddc.output_sampling_frequency;
361 let limit = samp_rate * MARGIN - 0.5 * ddc_samp_rate;
362 if ddc_freq.abs() > limit {
363 ddc_freq = if ddc_freq < 0.0 { limit } else { -limit }.round();
364 ad9361_freq = Some(u64::try_from(freq as i64 - ddc_freq as i64).unwrap());
365 } else {
366 ad9361_freq = None;
367 }
368 self.set_ddc_frequency(ddc_freq)?;
369 }
370 if let Some(freq) = ad9361_freq {
371 self.elements.ad9361_rx_lo_frequency.set(&freq);
373 self.elements
374 .ad9361_rx_lo_frequency
375 .onchange()
376 .unwrap()
377 .call0(&JsValue::NULL)?;
378 }
379 Ok(())
380 }
381
382 impl_section_custom!(
383 ad9361,
384 maia_json::Ad9361,
385 maia_json::PatchAd9361,
386 AD9361_URL,
387 rx_lo_frequency,
388 sampling_frequency,
389 rx_rf_bandwidth,
390 rx_gain,
391 rx_gain_mode
392 );
393 impl_onchange_patch_modify_noop!(ad9361, maia_json::PatchAd9361);
394
395 fn post_update_ad9361_elements(&self, json: &maia_json::Ad9361) -> Result<(), JsValue> {
396 self.update_rx_gain_disabled_status(json);
397 self.update_waterfall_ad9361(json)
398 }
399
400 fn post_patch_ad9361_update_elements(
401 &self,
402 json: &maia_json::PatchAd9361,
403 ) -> Result<(), JsValue> {
404 if json.sampling_frequency.is_some() {
405 self.update_spectrometer_settings()?;
406 }
407 Ok(())
408 }
409
410 fn update_rx_gain_disabled_status(&self, json: &maia_json::Ad9361) {
411 let disabled = match json.rx_gain_mode {
412 maia_json::Ad9361GainMode::Manual => false,
413 maia_json::Ad9361GainMode::FastAttack => true,
414 maia_json::Ad9361GainMode::SlowAttack => true,
415 maia_json::Ad9361GainMode::Hybrid => true,
416 };
417 self.elements.ad9361_rx_gain.set_disabled(disabled);
418 }
419
420 fn ad9361_rx_gain_onchange_manual(&self) -> Closure<dyn Fn() -> JsValue> {
424 let closure = self.ad9361_rx_gain_onchange();
425 let ui = self.clone();
426 Closure::new(move || {
427 let state = ui.api_state.borrow();
428 let Some(state) = state.as_ref() else {
429 return JsValue::NULL;
430 };
431 if !matches!(state.ad9361.rx_gain_mode, maia_json::Ad9361GainMode::Manual) {
432 return JsValue::NULL;
433 }
434 closure
436 .as_ref()
437 .unchecked_ref::<js_sys::Function>()
438 .call0(&JsValue::NULL)
439 .unwrap()
440 })
441 }
442}
443
444impl Ui {
446 impl_update_elements!(
447 ddc,
448 maia_json::DDCConfigSummary,
449 frequency,
450 decimation,
451 output_sampling_frequency,
452 max_input_sampling_frequency
453 );
454 impl_onchange!(ddc, maia_json::PatchDDCConfig, frequency);
455 impl_onchange_patch_modify_noop!(ddc, maia_json::PatchDDCConfig);
456 impl_patch!(
457 ddc,
458 maia_json::PatchDDCConfig,
459 maia_json::DDCConfig,
460 DDC_CONFIG_URL
461 );
462 impl_put!(
463 ddc,
464 maia_json::PutDDCDesign,
465 maia_json::DDCConfig,
466 DDC_DESIGN_URL
467 );
468
469 fn ddc_put_design_closure(&self) -> Closure<dyn Fn() -> JsValue> {
470 let ui = self.clone();
471 Closure::new(move || {
472 if !ui.elements.ddc_frequency.report_validity()
473 || !ui.elements.ddc_decimation.report_validity()
474 || !ui.elements.ddc_passband_ripple.report_validity()
475 || !ui.elements.ddc_stopband_attenuation_db.report_validity()
476 {
477 return JsValue::NULL;
478 }
479 let Some(frequency) = ui.elements.ddc_frequency.get() else {
480 return JsValue::NULL;
481 };
482 let Some(decimation) = ui.elements.ddc_decimation.get() else {
483 return JsValue::NULL;
484 };
485 let transition_bandwidth = ui.elements.ddc_transition_bandwidth.get();
489 let passband_ripple = ui.elements.ddc_passband_ripple.get();
490 let stopband_attenuation_db = ui.elements.ddc_stopband_attenuation_db.get();
491 let stopband_one_over_f = ui.elements.ddc_stopband_one_over_f.get();
492 if let Ok(mut prefs) = ui.preferences.try_borrow_mut() {
496 if let Err(e) = prefs.update_ddc_decimation(&decimation) {
497 web_sys::console::error_1(&e);
498 }
499 if let Some(value) = transition_bandwidth
500 && let Err(e) = prefs.update_ddc_transition_bandwidth(&value)
501 {
502 web_sys::console::error_1(&e);
503 }
504 if let Some(value) = passband_ripple
505 && let Err(e) = prefs.update_ddc_passband_ripple(&value)
506 {
507 web_sys::console::error_1(&e);
508 }
509 if let Some(value) = stopband_attenuation_db
510 && let Err(e) = prefs.update_ddc_stopband_attenuation_db(&value)
511 {
512 web_sys::console::error_1(&e);
513 }
514 if let Some(value) = stopband_one_over_f
515 && let Err(e) = prefs.update_ddc_stopband_one_over_f(&value)
516 {
517 web_sys::console::error_1(&e);
518 }
519 }
520 let put = maia_json::PutDDCDesign {
521 frequency,
522 decimation,
523 transition_bandwidth,
524 passband_ripple,
525 stopband_attenuation_db,
526 stopband_one_over_f,
527 };
528 let ui = ui.clone();
529 future_to_promise(async move {
530 request::ignore_request_failed(ui.put_ddc(&put).await)?;
531 ui.update_spectrometer_settings()?;
532 Ok(JsValue::NULL)
533 })
534 .into()
535 })
536 }
537
538 fn post_update_ddc_elements(&self, json: &maia_json::DDCConfigSummary) -> Result<(), JsValue> {
539 self.update_waterfall_ddc(json)
540 }
541
542 async fn patch_ddc_update_elements(
543 &self,
544 patch_json: &maia_json::PatchDDCConfig,
545 ) -> Result<(), JsValue> {
546 if let Some(json_output) = request::ignore_request_failed(self.patch_ddc(patch_json).await)?
547 {
548 let json = maia_json::DDCConfigSummary::from(json_output.clone());
549 if let Some(state) = self.api_state.borrow_mut().as_mut() {
550 state.ddc.clone_from(&json);
551 }
552 self.update_ddc_all_elements(&json)?;
553 }
554 Ok(())
555 }
556
557 pub fn set_ddc_frequency(&self, frequency: f64) -> Result<(), JsValue> {
559 self.elements.ddc_frequency.set(&frequency);
560 self.elements
561 .ddc_frequency
562 .onchange()
563 .unwrap()
564 .call0(&JsValue::NULL)?;
565 Ok(())
566 }
567}
568
569#[allow(dead_code)]
573#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
574struct GeolocationPosition {
575 coords: GeolocationCoordinates,
576 timestamp: f64,
577}
578
579#[allow(dead_code, non_snake_case)]
581#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
582struct GeolocationCoordinates {
583 latitude: f64,
584 longitude: f64,
585 altitude: Option<f64>,
586 accuracy: f64,
587 altitudeAccuracy: Option<f64>,
588 heading: Option<f64>,
589 speed: Option<f64>,
590}
591
592impl From<GeolocationCoordinates> for maia_json::Geolocation {
593 fn from(value: GeolocationCoordinates) -> maia_json::Geolocation {
594 maia_json::Geolocation {
595 latitude: value.latitude,
596 longitude: value.longitude,
597 altitude: value.altitude,
598 }
599 }
600}
601
602impl Ui {
603 impl_put!(
604 geolocation,
605 maia_json::DeviceGeolocation,
606 maia_json::DeviceGeolocation,
607 GEOLOCATION_URL
608 );
609
610 fn html_span_set_geolocation(element: &HtmlSpanElement, json: &maia_json::DeviceGeolocation) {
611 if let Some(geolocation) = &json.point {
612 element.set_text_content(Some(&format!(
613 "{:.6}°{} {:.6}°{}{}",
614 geolocation.latitude.abs(),
615 if geolocation.latitude >= 0.0 {
616 "N"
617 } else {
618 "S"
619 },
620 geolocation.longitude.abs(),
621 if geolocation.longitude >= 0.0 {
622 "E"
623 } else {
624 "W"
625 },
626 if let Some(altitude) = geolocation.altitude {
627 format!(" {altitude:.1}m")
628 } else {
629 String::new()
630 }
631 )));
632 } else {
633 element.set_text_content(None);
634 }
635 }
636
637 fn update_geolocation_elements(
638 &self,
639 json: &maia_json::DeviceGeolocation,
640 ) -> Result<(), JsValue> {
641 Self::html_span_set_geolocation(&self.elements.geolocation_point, json);
642 Ok(())
643 }
644
645 fn geolocation_api(&self) -> Result<Ref<'_, Geolocation>, JsValue> {
646 {
647 let geolocation = self.geolocation.borrow();
648 if geolocation.is_some() {
649 return Ok(Ref::map(geolocation, |opt| opt.as_ref().unwrap()));
651 }
652 }
653 let geolocation = self.window.navigator().geolocation()?;
656 self.geolocation.borrow_mut().replace(geolocation);
657 Ok(Ref::map(self.geolocation.borrow(), |opt| {
658 opt.as_ref().unwrap()
659 }))
660 }
661
662 fn geolocation_update(
663 &self,
664 success_callback: Closure<dyn Fn(JsValue) -> JsValue>,
665 ) -> Closure<dyn Fn()> {
666 let success_callback = success_callback.into_js_value();
667 let error_callback = self.geolocation_error().into_js_value();
668 let ui = self.clone();
669 Closure::new(move || {
670 let geolocation_api = match ui.geolocation_api() {
671 Ok(g) => g,
672 Err(err) => {
673 web_sys::console::error_2(&"could not get Geolocation API".into(), &err);
674 return;
675 }
676 };
677 let options = PositionOptions::new();
678 options.set_enable_high_accuracy(true);
679 if let Err(err) = geolocation_api.get_current_position_with_error_callback_and_options(
680 success_callback.unchecked_ref(),
681 Some(error_callback.unchecked_ref()),
682 &options,
683 ) {
684 web_sys::console::error_2(&"error getting current position".into(), &err);
685 }
686 })
687 }
688
689 fn geolocation_update_onclick(&self) -> Closure<dyn Fn()> {
690 self.geolocation_update(self.geolocation_success())
691 }
692
693 fn geolocation_watch_onchange(&self) -> Closure<dyn Fn()> {
694 let success_callback = self.geolocation_success().into_js_value();
695 let error_callback = self.geolocation_error().into_js_value();
696 let ui = self.clone();
697 Closure::new(move || {
698 let geolocation_api = match ui.geolocation_api() {
699 Ok(g) => g,
700 Err(err) => {
701 web_sys::console::error_2(&"could not get Geolocation API".into(), &err);
702 return;
703 }
704 };
705 let enabled = ui.elements.geolocation_watch.get().unwrap();
706 if let Ok(mut prefs) = ui.preferences.try_borrow_mut()
707 && let Err(e) = prefs.update_geolocation_watch(&enabled)
708 {
709 web_sys::console::error_1(&e);
710 }
711 if enabled {
712 if ui.geolocation_watch_id.get().is_some() {
713 return;
716 }
717 let options = PositionOptions::new();
718 options.set_enable_high_accuracy(true);
719 let id = match geolocation_api.watch_position_with_error_callback_and_options(
720 success_callback.unchecked_ref(),
721 Some(error_callback.unchecked_ref()),
722 &options,
723 ) {
724 Ok(id) => id,
725 Err(err) => {
726 web_sys::console::error_2(&"error watching position".into(), &err);
727 return;
728 }
729 };
730 ui.geolocation_watch_id.set(Some(id));
731 } else {
732 if let Some(id) = ui.geolocation_watch_id.take() {
736 geolocation_api.clear_watch(id);
737 }
738 }
739 })
740 }
741
742 fn parse_geolocation(&self, position: JsValue) -> Result<Option<GeolocationPosition>, JsValue> {
743 let position = serde_json::from_str::<GeolocationPosition>(
744 &js_sys::JSON::stringify(&position)?.as_string().unwrap(),
745 )
746 .map_err(|e| -> JsValue { format!("{e}").into() })?;
747 const MAXIMUM_ACCURACY: f64 = 10e3; if position.coords.accuracy > MAXIMUM_ACCURACY {
749 if let Err(err) = self.alert(&format!(
750 "Geolocation position accuracy worse than {:.0} km. Ignoring.",
751 MAXIMUM_ACCURACY * 1e-3
752 )) {
753 web_sys::console::error_2(&"alert error:".into(), &err);
754 }
755 return Ok(None);
756 }
757 Ok(Some(position))
758 }
759
760 fn geolocation_success(&self) -> Closure<dyn Fn(JsValue) -> JsValue> {
761 let ui = self.clone();
762 Closure::new(move |position| {
763 let position = match ui.parse_geolocation(position) {
764 Ok(Some(p)) => p,
765 Ok(None) => return JsValue::NULL,
766 Err(err) => {
767 web_sys::console::error_1(&err);
768 return JsValue::NULL;
769 }
770 };
771 let put = maia_json::DeviceGeolocation {
772 point: Some(position.coords.into()),
773 };
774 let ui = ui.clone();
775 future_to_promise(async move {
776 if let Some(response) =
777 request::ignore_request_failed(ui.put_geolocation(&put).await)?
778 {
779 ui.update_geolocation_elements(&response)?;
780 }
781 Ok(JsValue::NULL)
782 })
783 .into()
784 })
785 }
786
787 fn geolocation_error(&self) -> Closure<dyn Fn(JsValue)> {
788 let ui = self.clone();
789 Closure::new(move |_| {
790 if let Err(err) = ui.alert("Error obtaining geolocation") {
791 web_sys::console::error_2(&"alert error:".into(), &err);
792 }
793 })
794 }
795
796 fn geolocation_clear_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
797 let ui = self.clone();
798 Closure::new(move || {
799 ui.elements.geolocation_watch.set(&false);
801 let _ = ui
802 .elements
803 .geolocation_watch
804 .onchange()
805 .unwrap()
806 .call0(&JsValue::NULL);
807
808 let put = maia_json::DeviceGeolocation { point: None };
809 let ui = ui.clone();
810 future_to_promise(async move {
811 if let Some(response) =
812 request::ignore_request_failed(ui.put_geolocation(&put).await)?
813 {
814 ui.update_geolocation_elements(&response)?;
815 }
816 Ok(JsValue::NULL)
817 })
818 .into()
819 })
820 }
821}
822
823impl Ui {
825 impl_section_custom!(
826 recording_metadata,
827 maia_json::RecordingMetadata,
828 maia_json::PatchRecordingMetadata,
829 RECORDING_METADATA_URL,
830 filename,
831 description,
832 author
833 );
834 impl_post_patch_update_elements_noop!(recording_metadata, maia_json::PatchRecordingMetadata);
835 impl_onchange_patch_modify_noop!(recording_metadata, maia_json::PatchRecordingMetadata);
836
837 fn post_update_recording_metadata_elements(
838 &self,
839 json: &maia_json::RecordingMetadata,
840 ) -> Result<(), JsValue> {
841 Self::html_span_set_geolocation(
842 &self.elements.recording_metadata_geolocation,
843 &json.geolocation,
844 );
845 Ok(())
846 }
847
848 impl_section!(
849 recorder,
850 maia_json::Recorder,
851 maia_json::PatchRecorder,
852 RECORDER_URL,
853 prepend_timestamp,
854 mode,
855 maximum_duration
856 );
857
858 fn update_recorder_button(&self, json: &maia_json::Recorder) {
859 let text = match json.state {
860 maia_json::RecorderState::Stopped => "Record",
861 maia_json::RecorderState::Running => "Stop",
862 maia_json::RecorderState::Stopping => "Stopping",
863 };
864 for button in [
865 &self.elements.recorder_button,
866 &self.elements.recorder_button_replica,
867 ] {
868 if button.inner_html() != text {
869 button.set_text_content(Some(text));
870 button.set_class_name(&format!("{}_button", text.to_lowercase()));
871 }
872 }
873 }
874
875 fn patch_recorder_promise(&self, patch: maia_json::PatchRecorder) -> JsValue {
876 let ui = self.clone();
877 future_to_promise(async move {
878 if let Some(json_output) =
879 request::ignore_request_failed(ui.patch_recorder(&patch).await)?
880 {
881 ui.update_recorder_button(&json_output);
882 }
883 Ok(JsValue::NULL)
884 })
885 .into()
886 }
887
888 fn recorder_button_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
889 let ui = self.clone();
890 Closure::new(move || {
891 let action = match ui.elements.recorder_button.text_content().as_deref() {
892 Some("Record") => maia_json::RecorderStateChange::Start,
893 Some("Stop") => maia_json::RecorderStateChange::Stop,
894 Some("Stopping") => {
895 return JsValue::NULL;
897 }
898 content => {
899 web_sys::console::error_1(
900 &format!("recorder_button has unexpecte text_content: {content:?}").into(),
901 );
902 return JsValue::NULL;
903 }
904 };
905 let patch = maia_json::PatchRecorder {
906 state_change: Some(action),
907 ..Default::default()
908 };
909 ui.patch_recorder_promise(patch)
910 })
911 }
912
913 fn recording_metadata_geolocation_update_onclick(&self) -> Closure<dyn Fn()> {
914 self.geolocation_update(self.recording_metadata_geolocation_success())
915 }
916
917 fn recording_metadata_geolocation_success(&self) -> Closure<dyn Fn(JsValue) -> JsValue> {
918 let ui = self.clone();
919 Closure::new(move |position| {
920 let position = match ui.parse_geolocation(position) {
921 Ok(Some(p)) => p,
922 Ok(None) => return JsValue::NULL,
923 Err(err) => {
924 web_sys::console::error_1(&err);
925 return JsValue::NULL;
926 }
927 };
928 let patch = maia_json::PatchRecordingMetadata {
929 geolocation: Some(maia_json::DeviceGeolocation {
930 point: Some(position.coords.into()),
931 }),
932 ..Default::default()
933 };
934 let ui = ui.clone();
935 future_to_promise(async move {
936 ui.patch_recording_metadata_update_elements(&patch).await?;
937 Ok(JsValue::NULL)
938 })
939 .into()
940 })
941 }
942
943 fn recording_metadata_geolocation_clear_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
944 let ui = self.clone();
945 Closure::new(move || {
946 let patch = maia_json::PatchRecordingMetadata {
947 geolocation: Some(maia_json::DeviceGeolocation { point: None }),
948 ..Default::default()
949 };
950 let ui = ui.clone();
951 future_to_promise(async move {
952 ui.patch_recording_metadata_update_elements(&patch).await?;
953 Ok(JsValue::NULL)
954 })
955 .into()
956 })
957 }
958}
959
960impl Ui {
962 impl_section_custom!(
963 spectrometer,
964 maia_json::Spectrometer,
965 maia_json::PatchSpectrometer,
966 SPECTROMETER_URL,
967 input,
968 output_sampling_frequency,
969 mode
970 );
971 impl_post_patch_update_elements_noop!(spectrometer, maia_json::PatchSpectrometer);
972
973 fn post_update_spectrometer_elements(
974 &self,
975 json: &maia_json::Spectrometer,
976 ) -> Result<(), JsValue> {
977 self.update_waterfall_spectrometer(json)
978 }
979
980 fn spectrometer_onchange_patch_modify(&self, json: &mut maia_json::PatchSpectrometer) {
981 if json.input.is_some() {
982 if let Some(freq) = self
985 .api_state
986 .borrow()
987 .as_ref()
988 .map(|s| s.spectrometer.output_sampling_frequency)
989 {
990 json.output_sampling_frequency = Some(freq);
993 }
994 }
995 }
996
997 fn update_spectrometer_settings(&self) -> Result<(), JsValue> {
1000 self.elements
1001 .spectrometer_output_sampling_frequency
1002 .onchange()
1003 .unwrap()
1004 .call0(&JsValue::NULL)?;
1005 Ok(())
1006 }
1007}
1008
1009impl Ui {
1011 impl_patch!(time, maia_json::PatchTime, maia_json::Time, TIME_URL);
1012
1013 async fn update_server_time(&self, json: &maia_json::Time) -> Result<(), JsValue> {
1014 let threshold = 1000.0; let milliseconds = js_sys::Date::now();
1016 if (milliseconds - json.time).abs() >= threshold {
1017 let patch = maia_json::PatchTime {
1018 time: Some(milliseconds),
1019 };
1020 request::ignore_request_failed(self.patch_time(&patch).await)?;
1021 }
1022 Ok(())
1023 }
1024}
1025
1026impl Ui {
1028 fn update_versions_elements(&self, json: &maia_json::Versions) {
1029 self.elements
1030 .firmware_version
1031 .set_text_content(Some(&json.firmware_version));
1032 self.elements
1033 .maia_httpd_version
1034 .set_text_content(Some(&format!(
1035 "v{} git {}",
1036 json.maia_httpd_version, json.maia_httpd_git,
1037 )));
1038 self.elements
1039 .maia_hdl_version
1040 .set_text_content(Some(&json.maia_hdl_version));
1041 }
1042}
1043
1044impl Ui {
1046 onchange_apply!(
1047 colormap_select,
1048 waterfall_min,
1049 waterfall_max,
1050 waterfall_show_waterfall,
1051 waterfall_show_spectrum,
1052 waterfall_show_ddc
1053 );
1054
1055 fn colormap_select_apply(&self, value: colormap::Colormap) {
1056 let mut render_engine = self.render_engine.borrow_mut();
1057 self.waterfall
1058 .borrow()
1059 .load_colormap(&mut render_engine, value.colormap_as_slice())
1060 .unwrap();
1061 }
1062
1063 fn waterfall_min_apply(&self, value: f32) {
1064 self.waterfall.borrow_mut().set_waterfall_min(value);
1065 }
1066
1067 fn waterfall_max_apply(&self, value: f32) {
1068 self.waterfall.borrow_mut().set_waterfall_max(value);
1069 }
1070
1071 fn waterfall_show_waterfall_apply(&self, value: bool) {
1072 self.waterfall.borrow_mut().set_waterfall_visible(value);
1073 }
1074
1075 fn waterfall_show_spectrum_apply(&self, value: bool) {
1076 self.waterfall.borrow_mut().set_spectrum_visible(value);
1077 }
1078
1079 fn waterfall_show_ddc_apply(&self, value: bool) {
1080 self.local_settings.borrow_mut().waterfall_show_ddc = value;
1081 let state = self.api_state.borrow();
1082 let Some(state) = state.as_ref() else {
1083 web_sys::console::error_1(
1084 &"waterfall_show_ddc_apply: api_state not available yet".into(),
1085 );
1086 return;
1087 };
1088 let input_is_ddc = matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC);
1089 self.waterfall
1090 .borrow_mut()
1091 .set_channel_visible(value && !input_is_ddc);
1092 }
1093
1094 fn update_waterfall_ad9361(&self, json: &maia_json::Ad9361) -> Result<(), JsValue> {
1095 let mut waterfall = self.waterfall.borrow_mut();
1097 let samp_rate = waterfall.get_freq_samprate().1;
1098 let freq = json.rx_lo_frequency as f64 + self.waterfall_ddc_tuning();
1099 waterfall.set_freq_samprate(freq, samp_rate, &mut self.render_engine.borrow_mut())
1100 }
1101
1102 fn waterfall_ddc_tuning(&self) -> f64 {
1103 let state = self.api_state.borrow();
1104 let Some(state) = state.as_ref() else {
1105 return 0.0;
1106 };
1107 if !matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC) {
1108 return 0.0;
1109 }
1110 state.ddc.frequency
1111 }
1112
1113 fn update_waterfall_ddc(&self, json: &maia_json::DDCConfigSummary) -> Result<(), JsValue> {
1114 let mut waterfall = self.waterfall.borrow_mut();
1116 let state = self.api_state.borrow();
1117 let Some(state) = state.as_ref() else {
1118 return Err("update_waterfall_ddc: api_state not available yet".into());
1119 };
1120 let input_is_ddc = matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC);
1121 if input_is_ddc {
1122 let samp_rate = waterfall.get_freq_samprate().1;
1124 let freq = state.ad9361.rx_lo_frequency as f64 + json.frequency;
1125 waterfall.set_freq_samprate(freq, samp_rate, &mut self.render_engine.borrow_mut())?;
1126 }
1127 let show_ddc = self.local_settings.borrow().waterfall_show_ddc;
1129 waterfall.set_channel_visible(show_ddc && !input_is_ddc);
1130 waterfall.set_channel_frequency(json.frequency);
1131 waterfall.set_channel_decimation(json.decimation);
1132 Ok(())
1133 }
1134
1135 fn update_waterfall_spectrometer(&self, json: &maia_json::Spectrometer) -> Result<(), JsValue> {
1136 let mut waterfall = self.waterfall.borrow_mut();
1137 let state = self.api_state.borrow();
1138 let Some(state) = state.as_ref() else {
1139 return Err("update_waterfall_spectrometer: api_state not available yet".into());
1140 };
1141 let input_is_ddc = matches!(json.input, maia_json::SpectrometerInput::DDC);
1142 let ddc_tuning = if input_is_ddc {
1143 state.ddc.frequency
1144 } else {
1145 0.0
1146 };
1147 let freq = state.ad9361.rx_lo_frequency as f64 + ddc_tuning;
1148 waterfall.set_freq_samprate(
1149 freq,
1150 json.input_sampling_frequency,
1151 &mut self.render_engine.borrow_mut(),
1152 )?;
1153 let show_ddc = self.local_settings.borrow().waterfall_show_ddc;
1154 waterfall.set_channel_visible(show_ddc && !input_is_ddc);
1155 waterfall.set_channel_frequency(state.ddc.frequency);
1156 Ok(())
1157 }
1158
1159 fn update_waterfall_rate(&self, json: &maia_json::Spectrometer) {
1160 self.waterfall
1161 .borrow_mut()
1162 .set_waterfall_update_rate(json.output_sampling_frequency as f32);
1163 }
1164}