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::{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;
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    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    /// Creates a new user interface.
135    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        // This uses a custom onchange function that calls the macro-generated one.
198        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        // onchange closure for DDC settings; they all use the same closure
230        // this closure is here to prevent preferences.apply from calling
231        // it multiple times, since the PUT request can be expensive to
232        // execute by maia-httpd.
233        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        // call the closure now to apply any preferences for the DDC
251        ddc_onchange.call0(&JsValue::NULL)?;
252        Ok(())
253    }
254}
255
256// Alert
257impl 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
270// Settings
271impl 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
291// API methods
292impl 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        // call handler immediately
305        handler.call0(&JsValue::NULL)?;
306        // call handler every interval_ms
307        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        // This potentially takes some time to complete, since it might have to
326        // do a fetch call to PATCH the server time. We do this last.
327        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
340// AD9361 methods
341impl Ui {
342    /// Sets the value of the RX frequency.
343    ///
344    /// This is accomplished either by changing the DDC frequency when the DDC
345    /// is the input of the waterfall and the frequency can still be changed, or
346    /// by changing the AD9361 frequency otherwise.
347    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            // Change the DDC frequency if possible
355            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            // Assume that 15% of the edges of the AD9361 spectrum is not usable
358            // due to aliasing.
359            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            // Change the AD9361 frequency
372            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    // Custom onchange function for the RX gain. This avoids trying to change
421    // the gain when the AGC is not in manual mode, which would give an HTTP 500
422    // error in the PATCH request.
423    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            // Run macro-generated closure to parse the entry value and make a FETCH request
435            closure
436                .as_ref()
437                .unchecked_ref::<js_sys::Function>()
438                .call0(&JsValue::NULL)
439                .unwrap()
440        })
441    }
442}
443
444// DDC methods
445impl 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            // These calls can return None if the value cannot be parsed to the
486            // appropriate type, in which case the entries will be missing from
487            // the PUT request and maia-http will use default values.
488            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            // try_borrow_mut prevents trying to update the
493            // preferences as a consequence of the
494            // Preferences::apply_client calling this closure
495            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    /// Sets the DDC frequency.
558    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// Geolocation methods
570
571// the fields are required for Deserialize, but not all of them are read
572#[allow(dead_code)]
573#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
574struct GeolocationPosition {
575    coords: GeolocationCoordinates,
576    timestamp: f64,
577}
578
579// the fields are required for Deserialize, but not all of them are read
580#[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                // Geolocation object has been previously obtained. Return it.
650                return Ok(Ref::map(geolocation, |opt| opt.as_ref().unwrap()));
651            }
652        }
653        // No Geolocation object previously obtained. Get one from
654        // Navigator. This will prompt the user for authorization.
655        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                    // This shouldn't typically happend, but just in case, do
714                    // nothing if we already have a watch_id.
715                    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                // It can happen that geolocation_watch_id contains None, for
733                // instance if this onchange closure is called by
734                // preferences.apply at initialization.
735                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; // 10 km
748        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            // force geolocation_watch to disabled
800            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
823// Recorder methods
824impl 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                    // ignore click
896                    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
960// Spectrometer methods
961impl 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            // add output_sampling_frequency to the patch to maintain this
983            // parameter across the sample rate change
984            if let Some(freq) = self
985                .api_state
986                .borrow()
987                .as_ref()
988                .map(|s| s.spectrometer.output_sampling_frequency)
989            {
990                // if the format of the element fails, there is not much we can
991                // do
992                json.output_sampling_frequency = Some(freq);
993            }
994        }
995    }
996
997    // This function fakes an onchange event for the spectrometer_rate in order
998    // to update the spectrometer settings maintaining the current rate.
999    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
1009// Time methods
1010impl 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; // update server time if off by more than 1 sec
1015        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
1026// Versions methods
1027impl 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
1044// Waterfall methods
1045impl 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        // updates only the frequency
1096        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        // updates the center frequency and channel frequency
1115        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            // update the center frequency
1123            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        // update the DDC channel settings
1128        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}