maia_wasm/
ui.rs

1//! User interface.
2//!
3//! This module implements the user interface by linking HTML form elements
4//! (buttons, input elements, etc.) with the RESTful API of maia-httpd and with
5//! other operations that are performed client-side (such as changing the
6//! waterfall levels or colormap).
7
8use serde::Deserialize;
9use std::{
10    cell::{Cell, Ref, RefCell},
11    rc::Rc,
12};
13use wasm_bindgen::{closure::Closure, JsCast, JsValue};
14use wasm_bindgen_futures::{future_to_promise, JsFuture};
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;
30// For the time being preferences is not made public because we lack a good way
31// to allow an external crate to define preferences for a custom UI.
32mod 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/// User interface.
46///
47/// This structure is used to create and set up the appropriate callbacks that
48/// implement all the UI interactions.
49#[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
63// Defines the 'struct Elements' and its constructor
64ui_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    maia_wasm_version: HtmlSpanElement => Rc<HtmlSpanElement>,
123}
124
125#[derive(Default)]
126struct LocalSettings {
127    waterfall_show_ddc: bool,
128}
129
130impl Ui {
131    /// Creates a new user interface.
132    pub fn new(
133        window: Rc<Window>,
134        document: Rc<Document>,
135        render_engine: Rc<RefCell<RenderEngine>>,
136        waterfall: Rc<RefCell<Waterfall>>,
137    ) -> Result<Ui, JsValue> {
138        let elements = Elements::new(&document)?;
139        let preferences = Rc::new(RefCell::new(preferences::Preferences::new(&window)?));
140        let ui = Ui {
141            window,
142            document,
143            elements,
144            api_state: Rc::new(RefCell::new(None)),
145            geolocation: Rc::new(RefCell::new(None)),
146            geolocation_watch_id: Rc::new(Cell::new(None)),
147            local_settings: Rc::new(RefCell::new(LocalSettings::default())),
148            preferences,
149            render_engine,
150            waterfall,
151        };
152        ui.elements
153            .maia_wasm_version
154            .set_text_content(Some(&format!(
155                "v{} git {}",
156                crate::version::maia_wasm_version(),
157                crate::version::maia_wasm_git_version()
158            )));
159        ui.set_callbacks()?;
160        ui.preferences.borrow().apply(&ui)?;
161        ui.set_callbacks_post_apply()?;
162        Ok(ui)
163    }
164
165    fn set_callbacks(&self) -> Result<(), JsValue> {
166        self.set_api_get_periodic(1000)?;
167
168        set_on!(
169            change,
170            self,
171            colormap_select,
172            waterfall_show_waterfall,
173            waterfall_show_spectrum,
174            waterfall_show_ddc,
175            waterfall_min,
176            waterfall_max,
177            ad9361_rx_lo_frequency,
178            ad9361_sampling_frequency,
179            ad9361_rx_rf_bandwidth,
180            ad9361_rx_gain_mode,
181            ddc_frequency,
182            spectrometer_input,
183            spectrometer_output_sampling_frequency,
184            spectrometer_mode,
185            recording_metadata_filename,
186            recorder_prepend_timestamp,
187            recording_metadata_description,
188            recording_metadata_author,
189            recorder_mode,
190            recorder_maximum_duration,
191            geolocation_watch
192        );
193
194        // This uses a custom onchange function that calls the macro-generated one.
195        self.elements.ad9361_rx_gain.set_onchange(Some(
196            self.ad9361_rx_gain_onchange_manual()
197                .into_js_value()
198                .unchecked_ref(),
199        ));
200
201        set_on!(
202            click,
203            self,
204            recorder_button,
205            settings_button,
206            close_alert,
207            close_settings,
208            recording_metadata_geolocation_update,
209            recording_metadata_geolocation_clear,
210            geolocation_update,
211            geolocation_clear,
212            recording_tab,
213            ddc_tab,
214            waterfall_tab,
215            geolocation_tab,
216            other_tab
217        );
218        self.elements
219            .recorder_button_replica
220            .set_onclick(self.elements.recorder_button.onclick().as_ref());
221
222        Ok(())
223    }
224
225    fn set_callbacks_post_apply(&self) -> Result<(), JsValue> {
226        // onchange closure for DDC settings; they all use the same closure
227        // this closure is here to prevent preferences.apply from calling
228        // it multiple times, since the PUT request can be expensive to
229        // execute by maia-httpd.
230        let put_ddc_design = self.ddc_put_design_closure().into_js_value();
231        let ddc_onchange = put_ddc_design.unchecked_ref();
232        self.elements
233            .ddc_decimation
234            .set_onchange(Some(ddc_onchange));
235        self.elements
236            .ddc_transition_bandwidth
237            .set_onchange(Some(ddc_onchange));
238        self.elements
239            .ddc_passband_ripple
240            .set_onchange(Some(ddc_onchange));
241        self.elements
242            .ddc_stopband_attenuation_db
243            .set_onchange(Some(ddc_onchange));
244        self.elements
245            .ddc_stopband_one_over_f
246            .set_onchange(Some(ddc_onchange));
247        // call the closure now to apply any preferences for the DDC
248        ddc_onchange.call0(&JsValue::NULL)?;
249        Ok(())
250    }
251}
252
253// Alert
254impl Ui {
255    fn alert(&self, message: &str) -> Result<(), JsValue> {
256        self.elements.alert_message.set_text_content(Some(message));
257        self.elements.alert_dialog.show_modal()?;
258        Ok(())
259    }
260
261    fn close_alert_onclick(&self) -> Closure<dyn Fn()> {
262        let ui = self.clone();
263        Closure::new(move || ui.elements.alert_dialog.close())
264    }
265}
266
267// Settings
268impl Ui {
269    fn settings_button_onclick(&self) -> Closure<dyn Fn()> {
270        let ui = self.clone();
271        Closure::new(move || {
272            if ui.elements.settings.open() {
273                ui.elements.settings.close();
274            } else {
275                ui.elements.settings.show();
276            }
277        })
278    }
279
280    fn close_settings_onclick(&self) -> Closure<dyn Fn()> {
281        let ui = self.clone();
282        Closure::new(move || ui.elements.settings.close())
283    }
284
285    impl_tabs!(recording, ddc, waterfall, geolocation, other);
286}
287
288// API methods
289impl Ui {
290    fn set_api_get_periodic(&self, interval_ms: i32) -> Result<(), JsValue> {
291        let ui = self.clone();
292        let handler = Closure::<dyn Fn() -> js_sys::Promise>::new(move || {
293            let ui = ui.clone();
294            future_to_promise(async move {
295                ui.get_api_update_elements().await?;
296                Ok(JsValue::NULL)
297            })
298        });
299        let handler_ = handler.into_js_value();
300        let handler: &js_sys::Function = handler_.unchecked_ref();
301        // call handler immediately
302        handler.call0(&JsValue::NULL)?;
303        // call handler every interval_ms
304        self.window
305            .set_interval_with_callback_and_timeout_and_arguments_0(handler, interval_ms)?;
306        Ok(())
307    }
308
309    async fn get_api_update_elements(&self) -> Result<(), JsValue> {
310        let json = self.get_api().await?;
311        self.api_state.replace(Some(json.clone()));
312        self.update_ad9361_inactive_elements(&json.ad9361)?;
313        self.update_ddc_inactive_elements(&json.ddc)?;
314        self.update_spectrometer_inactive_elements(&json.spectrometer)?;
315        self.update_waterfall_rate(&json.spectrometer);
316        self.update_recorder_button(&json.recorder);
317        self.update_recording_metadata_inactive_elements(&json.recording_metadata)?;
318        self.update_recorder_inactive_elements(&json.recorder)?;
319        self.update_geolocation_elements(&json.geolocation)?;
320
321        // This potentially takes some time to complete, since it might have to
322        // do a fetch call to PATCH the server time. We do this last.
323        self.update_server_time(&json.time).await?;
324
325        Ok(())
326    }
327
328    async fn get_api(&self) -> Result<maia_json::Api, JsValue> {
329        let response = JsFuture::from(self.window.fetch_with_str(API_URL))
330            .await?
331            .dyn_into::<Response>()?;
332        request::response_to_json(&response).await
333    }
334}
335
336// AD9361 methods
337impl Ui {
338    /// Sets the value of the RX frequency.
339    ///
340    /// This is accomplished either by changing the DDC frequency when the DDC
341    /// is the input of the waterfall and the frequency can still be changed, or
342    /// by changing the AD9361 frequency otherwise.
343    pub fn set_rx_frequency(&self, freq: u64) -> Result<(), JsValue> {
344        let mut ad9361_freq = Some(freq);
345        let state = self.api_state.borrow();
346        let Some(state) = state.as_ref() else {
347            return Err("set_rx_frequency: api_state not available yet".into());
348        };
349        if matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC) {
350            // Change the DDC frequency if possible
351            let samp_rate = state.ad9361.sampling_frequency as f64;
352            let mut ddc_freq = freq as f64 - state.ad9361.rx_lo_frequency as f64;
353            // Assume that 15% of the edges of the AD9361 spectrum is not usable
354            // due to aliasing.
355            const MARGIN: f64 = 0.5 * (1.0 - 0.15);
356            let ddc_samp_rate = state.ddc.output_sampling_frequency;
357            let limit = samp_rate * MARGIN - 0.5 * ddc_samp_rate;
358            if ddc_freq.abs() > limit {
359                ddc_freq = if ddc_freq < 0.0 { limit } else { -limit }.round();
360                ad9361_freq = Some(u64::try_from(freq as i64 - ddc_freq as i64).unwrap());
361            } else {
362                ad9361_freq = None;
363            }
364            self.set_ddc_frequency(ddc_freq)?;
365        }
366        if let Some(freq) = ad9361_freq {
367            // Change the AD9361 frequency
368            self.elements.ad9361_rx_lo_frequency.set(&freq);
369            self.elements
370                .ad9361_rx_lo_frequency
371                .onchange()
372                .unwrap()
373                .call0(&JsValue::NULL)?;
374        }
375        Ok(())
376    }
377
378    impl_section_custom!(
379        ad9361,
380        maia_json::Ad9361,
381        maia_json::PatchAd9361,
382        AD9361_URL,
383        rx_lo_frequency,
384        sampling_frequency,
385        rx_rf_bandwidth,
386        rx_gain,
387        rx_gain_mode
388    );
389    impl_onchange_patch_modify_noop!(ad9361, maia_json::PatchAd9361);
390
391    fn post_update_ad9361_elements(&self, json: &maia_json::Ad9361) -> Result<(), JsValue> {
392        self.update_rx_gain_disabled_status(json);
393        self.update_waterfall_ad9361(json)
394    }
395
396    fn post_patch_ad9361_update_elements(
397        &self,
398        json: &maia_json::PatchAd9361,
399    ) -> Result<(), JsValue> {
400        if json.sampling_frequency.is_some() {
401            self.update_spectrometer_settings()?;
402        }
403        Ok(())
404    }
405
406    fn update_rx_gain_disabled_status(&self, json: &maia_json::Ad9361) {
407        let disabled = match json.rx_gain_mode {
408            maia_json::Ad9361GainMode::Manual => false,
409            maia_json::Ad9361GainMode::FastAttack => true,
410            maia_json::Ad9361GainMode::SlowAttack => true,
411            maia_json::Ad9361GainMode::Hybrid => true,
412        };
413        self.elements.ad9361_rx_gain.set_disabled(disabled);
414    }
415
416    // Custom onchange function for the RX gain. This avoids trying to change
417    // the gain when the AGC is not in manual mode, which would give an HTTP 500
418    // error in the PATCH request.
419    fn ad9361_rx_gain_onchange_manual(&self) -> Closure<dyn Fn() -> JsValue> {
420        let closure = self.ad9361_rx_gain_onchange();
421        let ui = self.clone();
422        Closure::new(move || {
423            let state = ui.api_state.borrow();
424            let Some(state) = state.as_ref() else {
425                return JsValue::NULL;
426            };
427            if !matches!(state.ad9361.rx_gain_mode, maia_json::Ad9361GainMode::Manual) {
428                return JsValue::NULL;
429            }
430            // Run macro-generated closure to parse the entry value and make a FETCH request
431            closure
432                .as_ref()
433                .unchecked_ref::<js_sys::Function>()
434                .call0(&JsValue::NULL)
435                .unwrap()
436        })
437    }
438}
439
440// DDC methods
441impl Ui {
442    impl_update_elements!(
443        ddc,
444        maia_json::DDCConfigSummary,
445        frequency,
446        decimation,
447        output_sampling_frequency,
448        max_input_sampling_frequency
449    );
450    impl_onchange!(ddc, maia_json::PatchDDCConfig, frequency);
451    impl_onchange_patch_modify_noop!(ddc, maia_json::PatchDDCConfig);
452    impl_patch!(
453        ddc,
454        maia_json::PatchDDCConfig,
455        maia_json::DDCConfig,
456        DDC_CONFIG_URL
457    );
458    impl_put!(
459        ddc,
460        maia_json::PutDDCDesign,
461        maia_json::DDCConfig,
462        DDC_DESIGN_URL
463    );
464
465    fn ddc_put_design_closure(&self) -> Closure<dyn Fn() -> JsValue> {
466        let ui = self.clone();
467        Closure::new(move || {
468            if !ui.elements.ddc_frequency.report_validity()
469                || !ui.elements.ddc_decimation.report_validity()
470                || !ui.elements.ddc_passband_ripple.report_validity()
471                || !ui.elements.ddc_stopband_attenuation_db.report_validity()
472            {
473                return JsValue::NULL;
474            }
475            let Some(frequency) = ui.elements.ddc_frequency.get() else {
476                return JsValue::NULL;
477            };
478            let Some(decimation) = ui.elements.ddc_decimation.get() else {
479                return JsValue::NULL;
480            };
481            // These calls can return None if the value cannot be parsed to the
482            // appropriate type, in which case the entries will be missing from
483            // the PUT request and maia-http will use default values.
484            let transition_bandwidth = ui.elements.ddc_transition_bandwidth.get();
485            let passband_ripple = ui.elements.ddc_passband_ripple.get();
486            let stopband_attenuation_db = ui.elements.ddc_stopband_attenuation_db.get();
487            let stopband_one_over_f = ui.elements.ddc_stopband_one_over_f.get();
488            // try_borrow_mut prevents trying to update the
489            // preferences as a consequence of the
490            // Preferences::apply_client calling this closure
491            if let Ok(mut prefs) = ui.preferences.try_borrow_mut() {
492                if let Err(e) = prefs.update_ddc_decimation(&decimation) {
493                    web_sys::console::error_1(&e);
494                }
495                if let Some(value) = transition_bandwidth {
496                    if let Err(e) = prefs.update_ddc_transition_bandwidth(&value) {
497                        web_sys::console::error_1(&e);
498                    }
499                }
500                if let Some(value) = passband_ripple {
501                    if let Err(e) = prefs.update_ddc_passband_ripple(&value) {
502                        web_sys::console::error_1(&e);
503                    }
504                }
505                if let Some(value) = stopband_attenuation_db {
506                    if let Err(e) = prefs.update_ddc_stopband_attenuation_db(&value) {
507                        web_sys::console::error_1(&e);
508                    }
509                }
510                if let Some(value) = stopband_one_over_f {
511                    if let Err(e) = prefs.update_ddc_stopband_one_over_f(&value) {
512                        web_sys::console::error_1(&e);
513                    }
514                }
515            }
516            let put = maia_json::PutDDCDesign {
517                frequency,
518                decimation,
519                transition_bandwidth,
520                passband_ripple,
521                stopband_attenuation_db,
522                stopband_one_over_f,
523            };
524            let ui = ui.clone();
525            future_to_promise(async move {
526                request::ignore_request_failed(ui.put_ddc(&put).await)?;
527                ui.update_spectrometer_settings()?;
528                Ok(JsValue::NULL)
529            })
530            .into()
531        })
532    }
533
534    fn post_update_ddc_elements(&self, json: &maia_json::DDCConfigSummary) -> Result<(), JsValue> {
535        self.update_waterfall_ddc(json)
536    }
537
538    async fn patch_ddc_update_elements(
539        &self,
540        patch_json: &maia_json::PatchDDCConfig,
541    ) -> Result<(), JsValue> {
542        if let Some(json_output) = request::ignore_request_failed(self.patch_ddc(patch_json).await)?
543        {
544            let json = maia_json::DDCConfigSummary::from(json_output.clone());
545            if let Some(state) = self.api_state.borrow_mut().as_mut() {
546                state.ddc.clone_from(&json);
547            }
548            self.update_ddc_all_elements(&json)?;
549        }
550        Ok(())
551    }
552
553    /// Sets the DDC frequency.
554    pub fn set_ddc_frequency(&self, frequency: f64) -> Result<(), JsValue> {
555        self.elements.ddc_frequency.set(&frequency);
556        self.elements
557            .ddc_frequency
558            .onchange()
559            .unwrap()
560            .call0(&JsValue::NULL)?;
561        Ok(())
562    }
563}
564
565// Geolocation methods
566
567// the fields are required for Deserialize, but not all of them are read
568#[allow(dead_code)]
569#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
570struct GeolocationPosition {
571    coords: GeolocationCoordinates,
572    timestamp: f64,
573}
574
575// the fields are required for Deserialize, but not all of them are read
576#[allow(dead_code, non_snake_case)]
577#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
578struct GeolocationCoordinates {
579    latitude: f64,
580    longitude: f64,
581    altitude: Option<f64>,
582    accuracy: f64,
583    altitudeAccuracy: Option<f64>,
584    heading: Option<f64>,
585    speed: Option<f64>,
586}
587
588impl From<GeolocationCoordinates> for maia_json::Geolocation {
589    fn from(value: GeolocationCoordinates) -> maia_json::Geolocation {
590        maia_json::Geolocation {
591            latitude: value.latitude,
592            longitude: value.longitude,
593            altitude: value.altitude,
594        }
595    }
596}
597
598impl Ui {
599    impl_put!(
600        geolocation,
601        maia_json::DeviceGeolocation,
602        maia_json::DeviceGeolocation,
603        GEOLOCATION_URL
604    );
605
606    fn html_span_set_geolocation(element: &HtmlSpanElement, json: &maia_json::DeviceGeolocation) {
607        if let Some(geolocation) = &json.point {
608            element.set_text_content(Some(&format!(
609                "{:.6}°{} {:.6}°{}{}",
610                geolocation.latitude.abs(),
611                if geolocation.latitude >= 0.0 {
612                    "N"
613                } else {
614                    "S"
615                },
616                geolocation.longitude.abs(),
617                if geolocation.longitude >= 0.0 {
618                    "E"
619                } else {
620                    "W"
621                },
622                if let Some(altitude) = geolocation.altitude {
623                    format!(" {altitude:.1}m")
624                } else {
625                    String::new()
626                }
627            )));
628        } else {
629            element.set_text_content(None);
630        }
631    }
632
633    fn update_geolocation_elements(
634        &self,
635        json: &maia_json::DeviceGeolocation,
636    ) -> Result<(), JsValue> {
637        Self::html_span_set_geolocation(&self.elements.geolocation_point, json);
638        Ok(())
639    }
640
641    fn geolocation_api(&self) -> Result<Ref<'_, Geolocation>, JsValue> {
642        {
643            let geolocation = self.geolocation.borrow();
644            if geolocation.is_some() {
645                // Geolocation object has been previously obtained. Return it.
646                return Ok(Ref::map(geolocation, |opt| opt.as_ref().unwrap()));
647            }
648        }
649        // No Geolocation object previously obtained. Get one from
650        // Navigator. This will prompt the user for authorization.
651        let geolocation = self.window.navigator().geolocation()?;
652        self.geolocation.borrow_mut().replace(geolocation);
653        Ok(Ref::map(self.geolocation.borrow(), |opt| {
654            opt.as_ref().unwrap()
655        }))
656    }
657
658    fn geolocation_update(
659        &self,
660        success_callback: Closure<dyn Fn(JsValue) -> JsValue>,
661    ) -> Closure<dyn Fn()> {
662        let success_callback = success_callback.into_js_value();
663        let error_callback = self.geolocation_error().into_js_value();
664        let ui = self.clone();
665        Closure::new(move || {
666            let geolocation_api = match ui.geolocation_api() {
667                Ok(g) => g,
668                Err(err) => {
669                    web_sys::console::error_2(&"could not get Geolocation API".into(), &err);
670                    return;
671                }
672            };
673            let options = PositionOptions::new();
674            options.set_enable_high_accuracy(true);
675            if let Err(err) = geolocation_api.get_current_position_with_error_callback_and_options(
676                success_callback.unchecked_ref(),
677                Some(error_callback.unchecked_ref()),
678                &options,
679            ) {
680                web_sys::console::error_2(&"error getting current position".into(), &err);
681            }
682        })
683    }
684
685    fn geolocation_update_onclick(&self) -> Closure<dyn Fn()> {
686        self.geolocation_update(self.geolocation_success())
687    }
688
689    fn geolocation_watch_onchange(&self) -> Closure<dyn Fn()> {
690        let success_callback = self.geolocation_success().into_js_value();
691        let error_callback = self.geolocation_error().into_js_value();
692        let ui = self.clone();
693        Closure::new(move || {
694            let geolocation_api = match ui.geolocation_api() {
695                Ok(g) => g,
696                Err(err) => {
697                    web_sys::console::error_2(&"could not get Geolocation API".into(), &err);
698                    return;
699                }
700            };
701            let enabled = ui.elements.geolocation_watch.get().unwrap();
702            if let Ok(mut prefs) = ui.preferences.try_borrow_mut() {
703                if let Err(e) = prefs.update_geolocation_watch(&enabled) {
704                    web_sys::console::error_1(&e);
705                }
706            }
707            if enabled {
708                if ui.geolocation_watch_id.get().is_some() {
709                    // This shouldn't typically happend, but just in case, do
710                    // nothing if we already have a watch_id.
711                    return;
712                }
713                let options = PositionOptions::new();
714                options.set_enable_high_accuracy(true);
715                let id = match geolocation_api.watch_position_with_error_callback_and_options(
716                    success_callback.unchecked_ref(),
717                    Some(error_callback.unchecked_ref()),
718                    &options,
719                ) {
720                    Ok(id) => id,
721                    Err(err) => {
722                        web_sys::console::error_2(&"error watching position".into(), &err);
723                        return;
724                    }
725                };
726                ui.geolocation_watch_id.set(Some(id));
727            } else {
728                // It can happen that geolocation_watch_id contains None, for
729                // instance if this onchange closure is called by
730                // preferences.apply at initialization.
731                if let Some(id) = ui.geolocation_watch_id.take() {
732                    geolocation_api.clear_watch(id);
733                }
734            }
735        })
736    }
737
738    fn parse_geolocation(&self, position: JsValue) -> Result<Option<GeolocationPosition>, JsValue> {
739        let position = serde_json::from_str::<GeolocationPosition>(
740            &js_sys::JSON::stringify(&position)?.as_string().unwrap(),
741        )
742        .map_err(|e| -> JsValue { format!("{e}").into() })?;
743        const MAXIMUM_ACCURACY: f64 = 10e3; // 10 km
744        if position.coords.accuracy > MAXIMUM_ACCURACY {
745            if let Err(err) = self.alert(&format!(
746                "Geolocation position accuracy worse than {:.0} km. Ignoring.",
747                MAXIMUM_ACCURACY * 1e-3
748            )) {
749                web_sys::console::error_2(&"alert error:".into(), &err);
750            }
751            return Ok(None);
752        }
753        Ok(Some(position))
754    }
755
756    fn geolocation_success(&self) -> Closure<dyn Fn(JsValue) -> JsValue> {
757        let ui = self.clone();
758        Closure::new(move |position| {
759            let position = match ui.parse_geolocation(position) {
760                Ok(Some(p)) => p,
761                Ok(None) => return JsValue::NULL,
762                Err(err) => {
763                    web_sys::console::error_1(&err);
764                    return JsValue::NULL;
765                }
766            };
767            let put = maia_json::DeviceGeolocation {
768                point: Some(position.coords.into()),
769            };
770            let ui = ui.clone();
771            future_to_promise(async move {
772                if let Some(response) =
773                    request::ignore_request_failed(ui.put_geolocation(&put).await)?
774                {
775                    ui.update_geolocation_elements(&response)?;
776                }
777                Ok(JsValue::NULL)
778            })
779            .into()
780        })
781    }
782
783    fn geolocation_error(&self) -> Closure<dyn Fn(JsValue)> {
784        let ui = self.clone();
785        Closure::new(move |_| {
786            if let Err(err) = ui.alert("Error obtaining geolocation") {
787                web_sys::console::error_2(&"alert error:".into(), &err);
788            }
789        })
790    }
791
792    fn geolocation_clear_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
793        let ui = self.clone();
794        Closure::new(move || {
795            // force geolocation_watch to disabled
796            ui.elements.geolocation_watch.set(&false);
797            let _ = ui
798                .elements
799                .geolocation_watch
800                .onchange()
801                .unwrap()
802                .call0(&JsValue::NULL);
803
804            let put = maia_json::DeviceGeolocation { point: None };
805            let ui = ui.clone();
806            future_to_promise(async move {
807                if let Some(response) =
808                    request::ignore_request_failed(ui.put_geolocation(&put).await)?
809                {
810                    ui.update_geolocation_elements(&response)?;
811                }
812                Ok(JsValue::NULL)
813            })
814            .into()
815        })
816    }
817}
818
819// Recorder methods
820impl Ui {
821    impl_section_custom!(
822        recording_metadata,
823        maia_json::RecordingMetadata,
824        maia_json::PatchRecordingMetadata,
825        RECORDING_METADATA_URL,
826        filename,
827        description,
828        author
829    );
830    impl_post_patch_update_elements_noop!(recording_metadata, maia_json::PatchRecordingMetadata);
831    impl_onchange_patch_modify_noop!(recording_metadata, maia_json::PatchRecordingMetadata);
832
833    fn post_update_recording_metadata_elements(
834        &self,
835        json: &maia_json::RecordingMetadata,
836    ) -> Result<(), JsValue> {
837        Self::html_span_set_geolocation(
838            &self.elements.recording_metadata_geolocation,
839            &json.geolocation,
840        );
841        Ok(())
842    }
843
844    impl_section!(
845        recorder,
846        maia_json::Recorder,
847        maia_json::PatchRecorder,
848        RECORDER_URL,
849        prepend_timestamp,
850        mode,
851        maximum_duration
852    );
853
854    fn update_recorder_button(&self, json: &maia_json::Recorder) {
855        let text = match json.state {
856            maia_json::RecorderState::Stopped => "Record",
857            maia_json::RecorderState::Running => "Stop",
858            maia_json::RecorderState::Stopping => "Stopping",
859        };
860        for button in [
861            &self.elements.recorder_button,
862            &self.elements.recorder_button_replica,
863        ] {
864            if button.inner_html() != text {
865                button.set_text_content(Some(text));
866                button.set_class_name(&format!("{}_button", text.to_lowercase()));
867            }
868        }
869    }
870
871    fn patch_recorder_promise(&self, patch: maia_json::PatchRecorder) -> JsValue {
872        let ui = self.clone();
873        future_to_promise(async move {
874            if let Some(json_output) =
875                request::ignore_request_failed(ui.patch_recorder(&patch).await)?
876            {
877                ui.update_recorder_button(&json_output);
878            }
879            Ok(JsValue::NULL)
880        })
881        .into()
882    }
883
884    fn recorder_button_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
885        let ui = self.clone();
886        Closure::new(move || {
887            let action = match ui.elements.recorder_button.text_content().as_deref() {
888                Some("Record") => maia_json::RecorderStateChange::Start,
889                Some("Stop") => maia_json::RecorderStateChange::Stop,
890                Some("Stopping") => {
891                    // ignore click
892                    return JsValue::NULL;
893                }
894                content => {
895                    web_sys::console::error_1(
896                        &format!("recorder_button has unexpecte text_content: {content:?}").into(),
897                    );
898                    return JsValue::NULL;
899                }
900            };
901            let patch = maia_json::PatchRecorder {
902                state_change: Some(action),
903                ..Default::default()
904            };
905            ui.patch_recorder_promise(patch)
906        })
907    }
908
909    fn recording_metadata_geolocation_update_onclick(&self) -> Closure<dyn Fn()> {
910        self.geolocation_update(self.recording_metadata_geolocation_success())
911    }
912
913    fn recording_metadata_geolocation_success(&self) -> Closure<dyn Fn(JsValue) -> JsValue> {
914        let ui = self.clone();
915        Closure::new(move |position| {
916            let position = match ui.parse_geolocation(position) {
917                Ok(Some(p)) => p,
918                Ok(None) => return JsValue::NULL,
919                Err(err) => {
920                    web_sys::console::error_1(&err);
921                    return JsValue::NULL;
922                }
923            };
924            let patch = maia_json::PatchRecordingMetadata {
925                geolocation: Some(maia_json::DeviceGeolocation {
926                    point: Some(position.coords.into()),
927                }),
928                ..Default::default()
929            };
930            let ui = ui.clone();
931            future_to_promise(async move {
932                ui.patch_recording_metadata_update_elements(&patch).await?;
933                Ok(JsValue::NULL)
934            })
935            .into()
936        })
937    }
938
939    fn recording_metadata_geolocation_clear_onclick(&self) -> Closure<dyn Fn() -> JsValue> {
940        let ui = self.clone();
941        Closure::new(move || {
942            let patch = maia_json::PatchRecordingMetadata {
943                geolocation: Some(maia_json::DeviceGeolocation { point: None }),
944                ..Default::default()
945            };
946            let ui = ui.clone();
947            future_to_promise(async move {
948                ui.patch_recording_metadata_update_elements(&patch).await?;
949                Ok(JsValue::NULL)
950            })
951            .into()
952        })
953    }
954}
955
956// Spectrometer methods
957impl Ui {
958    impl_section_custom!(
959        spectrometer,
960        maia_json::Spectrometer,
961        maia_json::PatchSpectrometer,
962        SPECTROMETER_URL,
963        input,
964        output_sampling_frequency,
965        mode
966    );
967    impl_post_patch_update_elements_noop!(spectrometer, maia_json::PatchSpectrometer);
968
969    fn post_update_spectrometer_elements(
970        &self,
971        json: &maia_json::Spectrometer,
972    ) -> Result<(), JsValue> {
973        self.update_waterfall_spectrometer(json)
974    }
975
976    fn spectrometer_onchange_patch_modify(&self, json: &mut maia_json::PatchSpectrometer) {
977        if json.input.is_some() {
978            // add output_sampling_frequency to the patch to maintain this
979            // parameter across the sample rate change
980            if let Some(freq) = self
981                .api_state
982                .borrow()
983                .as_ref()
984                .map(|s| s.spectrometer.output_sampling_frequency)
985            {
986                // if the format of the element fails, there is not much we can
987                // do
988                json.output_sampling_frequency = Some(freq);
989            }
990        }
991    }
992
993    // This function fakes an onchange event for the spectrometer_rate in order
994    // to update the spectrometer settings maintaining the current rate.
995    fn update_spectrometer_settings(&self) -> Result<(), JsValue> {
996        self.elements
997            .spectrometer_output_sampling_frequency
998            .onchange()
999            .unwrap()
1000            .call0(&JsValue::NULL)?;
1001        Ok(())
1002    }
1003}
1004
1005// Time methods
1006impl Ui {
1007    impl_patch!(time, maia_json::PatchTime, maia_json::Time, TIME_URL);
1008
1009    async fn update_server_time(&self, json: &maia_json::Time) -> Result<(), JsValue> {
1010        let threshold = 1000.0; // update server time if off by more than 1 sec
1011        let milliseconds = js_sys::Date::now();
1012        if (milliseconds - json.time).abs() >= threshold {
1013            let patch = maia_json::PatchTime {
1014                time: Some(milliseconds),
1015            };
1016            request::ignore_request_failed(self.patch_time(&patch).await)?;
1017        }
1018        Ok(())
1019    }
1020}
1021
1022// Waterfall methods
1023impl Ui {
1024    onchange_apply!(
1025        colormap_select,
1026        waterfall_min,
1027        waterfall_max,
1028        waterfall_show_waterfall,
1029        waterfall_show_spectrum,
1030        waterfall_show_ddc
1031    );
1032
1033    fn colormap_select_apply(&self, value: colormap::Colormap) {
1034        let mut render_engine = self.render_engine.borrow_mut();
1035        self.waterfall
1036            .borrow()
1037            .load_colormap(&mut render_engine, value.colormap_as_slice())
1038            .unwrap();
1039    }
1040
1041    fn waterfall_min_apply(&self, value: f32) {
1042        self.waterfall.borrow_mut().set_waterfall_min(value);
1043    }
1044
1045    fn waterfall_max_apply(&self, value: f32) {
1046        self.waterfall.borrow_mut().set_waterfall_max(value);
1047    }
1048
1049    fn waterfall_show_waterfall_apply(&self, value: bool) {
1050        self.waterfall.borrow_mut().set_waterfall_visible(value);
1051    }
1052
1053    fn waterfall_show_spectrum_apply(&self, value: bool) {
1054        self.waterfall.borrow_mut().set_spectrum_visible(value);
1055    }
1056
1057    fn waterfall_show_ddc_apply(&self, value: bool) {
1058        self.local_settings.borrow_mut().waterfall_show_ddc = value;
1059        let state = self.api_state.borrow();
1060        let Some(state) = state.as_ref() else {
1061            web_sys::console::error_1(
1062                &"waterfall_show_ddc_apply: api_state not available yet".into(),
1063            );
1064            return;
1065        };
1066        let input_is_ddc = matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC);
1067        self.waterfall
1068            .borrow_mut()
1069            .set_channel_visible(value && !input_is_ddc);
1070    }
1071
1072    fn update_waterfall_ad9361(&self, json: &maia_json::Ad9361) -> Result<(), JsValue> {
1073        // updates only the frequency
1074        let mut waterfall = self.waterfall.borrow_mut();
1075        let samp_rate = waterfall.get_freq_samprate().1;
1076        let freq = json.rx_lo_frequency as f64 + self.waterfall_ddc_tuning();
1077        waterfall.set_freq_samprate(freq, samp_rate, &mut self.render_engine.borrow_mut())
1078    }
1079
1080    fn waterfall_ddc_tuning(&self) -> f64 {
1081        let state = self.api_state.borrow();
1082        let Some(state) = state.as_ref() else {
1083            return 0.0;
1084        };
1085        if !matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC) {
1086            return 0.0;
1087        }
1088        state.ddc.frequency
1089    }
1090
1091    fn update_waterfall_ddc(&self, json: &maia_json::DDCConfigSummary) -> Result<(), JsValue> {
1092        // updates the center frequency and channel frequency
1093        let mut waterfall = self.waterfall.borrow_mut();
1094        let state = self.api_state.borrow();
1095        let Some(state) = state.as_ref() else {
1096            return Err("update_waterfall_ddc: api_state not available yet".into());
1097        };
1098        let input_is_ddc = matches!(state.spectrometer.input, maia_json::SpectrometerInput::DDC);
1099        if input_is_ddc {
1100            // update the center frequency
1101            let samp_rate = waterfall.get_freq_samprate().1;
1102            let freq = state.ad9361.rx_lo_frequency as f64 + json.frequency;
1103            waterfall.set_freq_samprate(freq, samp_rate, &mut self.render_engine.borrow_mut())?;
1104        }
1105        // update the DDC channel settings
1106        let show_ddc = self.local_settings.borrow().waterfall_show_ddc;
1107        waterfall.set_channel_visible(show_ddc && !input_is_ddc);
1108        waterfall.set_channel_frequency(json.frequency);
1109        waterfall.set_channel_decimation(json.decimation);
1110        Ok(())
1111    }
1112
1113    fn update_waterfall_spectrometer(&self, json: &maia_json::Spectrometer) -> Result<(), JsValue> {
1114        let mut waterfall = self.waterfall.borrow_mut();
1115        let state = self.api_state.borrow();
1116        let Some(state) = state.as_ref() else {
1117            return Err("update_waterfall_spectrometer: api_state not available yet".into());
1118        };
1119        let input_is_ddc = matches!(json.input, maia_json::SpectrometerInput::DDC);
1120        let ddc_tuning = if input_is_ddc {
1121            state.ddc.frequency
1122        } else {
1123            0.0
1124        };
1125        let freq = state.ad9361.rx_lo_frequency as f64 + ddc_tuning;
1126        waterfall.set_freq_samprate(
1127            freq,
1128            json.input_sampling_frequency,
1129            &mut self.render_engine.borrow_mut(),
1130        )?;
1131        let show_ddc = self.local_settings.borrow().waterfall_show_ddc;
1132        waterfall.set_channel_visible(show_ddc && !input_is_ddc);
1133        waterfall.set_channel_frequency(state.ddc.frequency);
1134        Ok(())
1135    }
1136
1137    fn update_waterfall_rate(&self, json: &maia_json::Spectrometer) {
1138        self.waterfall
1139            .borrow_mut()
1140            .set_waterfall_update_rate(json.output_sampling_frequency as f32);
1141    }
1142}