1use 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;
30mod preferences;
33pub mod request;
34
35const API_URL: &str = "/api";
36const AD9361_URL: &str = "/api/ad9361";
37const DDC_CONFIG_URL: &str = "/api/ddc/config";
38const DDC_DESIGN_URL: &str = "/api/ddc/design";
39const GEOLOCATION_URL: &str = "/api/geolocation";
40const RECORDER_URL: &str = "/api/recorder";
41const RECORDING_METADATA_URL: &str = "/api/recording/metadata";
42const SPECTROMETER_URL: &str = "/api/spectrometer";
43const TIME_URL: &str = "/api/time";
44
45#[derive(Clone)]
50pub struct Ui {
51 window: Rc<Window>,
52 document: Rc<Document>,
53 elements: Elements,
54 api_state: Rc<RefCell<Option<maia_json::Api>>>,
55 geolocation: Rc<RefCell<Option<Geolocation>>>,
56 geolocation_watch_id: Rc<Cell<Option<i32>>>,
57 local_settings: Rc<RefCell<LocalSettings>>,
58 preferences: Rc<RefCell<preferences::Preferences>>,
59 render_engine: Rc<RefCell<RenderEngine>>,
60 waterfall: Rc<RefCell<Waterfall>>,
61}
62
63ui_elements! {
65 colormap_select: HtmlSelectElement => EnumInput<colormap::Colormap>,
66 waterfall_show_waterfall: HtmlInputElement => CheckboxInput,
67 waterfall_show_spectrum: HtmlInputElement => CheckboxInput,
68 waterfall_show_ddc: HtmlInputElement => CheckboxInput,
69 recorder_button: HtmlButtonElement => Rc<HtmlButtonElement>,
70 recorder_button_replica: HtmlButtonElement => Rc<HtmlButtonElement>,
71 settings_button: HtmlButtonElement => Rc<HtmlButtonElement>,
72 alert_dialog: HtmlDialogElement => Rc<HtmlDialogElement>,
73 alert_message: HtmlParagraphElement => Rc<HtmlParagraphElement>,
74 close_alert: HtmlButtonElement => Rc<HtmlButtonElement>,
75 settings: HtmlDialogElement => Rc<HtmlDialogElement>,
76 close_settings: HtmlButtonElement => Rc<HtmlButtonElement>,
77 recording_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
78 ddc_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
79 waterfall_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
80 geolocation_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
81 other_tab: HtmlButtonElement => Rc<HtmlButtonElement>,
82 recording_panel: HtmlElement => Rc<HtmlElement>,
83 ddc_panel: HtmlElement => Rc<HtmlElement>,
84 waterfall_panel: HtmlElement => Rc<HtmlElement>,
85 geolocation_panel: HtmlElement => Rc<HtmlElement>,
86 other_panel: HtmlElement => Rc<HtmlElement>,
87 waterfall_min: HtmlInputElement => NumberInput<f32>,
88 waterfall_max: HtmlInputElement => NumberInput<f32>,
89 ad9361_rx_lo_frequency: HtmlInputElement
90 => NumberInput<u64, input::MHzPresentation>,
91 ad9361_sampling_frequency: HtmlInputElement
92 => NumberInput<u32, input::MHzPresentation>,
93 ad9361_rx_rf_bandwidth: HtmlInputElement
94 => NumberInput<u32, input::MHzPresentation>,
95 ad9361_rx_gain_mode: HtmlSelectElement => EnumInput<maia_json::Ad9361GainMode>,
96 ad9361_rx_gain: HtmlInputElement => NumberInput<f64>,
97 ddc_frequency: HtmlInputElement => NumberInput<f64, input::KHzPresentation>,
98 ddc_decimation: HtmlInputElement => NumberInput<u32>,
99 ddc_transition_bandwidth: HtmlInputElement => NumberInput<f64>,
100 ddc_passband_ripple: HtmlInputElement => NumberInput<f64>,
101 ddc_stopband_attenuation_db: HtmlInputElement => NumberInput<f64>,
102 ddc_stopband_one_over_f: HtmlInputElement => CheckboxInput,
103 ddc_output_sampling_frequency: HtmlSpanElement => NumberSpan<f64, input::MHzPresentation>,
104 ddc_max_input_sampling_frequency: HtmlSpanElement => NumberSpan<f64, input::MHzPresentation>,
105 spectrometer_input: HtmlSelectElement => EnumInput<maia_json::SpectrometerInput>,
106 spectrometer_output_sampling_frequency: HtmlInputElement
107 => NumberInput<f64, input::IntegerPresentation>,
108 spectrometer_mode: HtmlSelectElement => EnumInput<maia_json::SpectrometerMode>,
109 recording_metadata_filename: HtmlInputElement => TextInput,
110 recorder_prepend_timestamp: HtmlInputElement => CheckboxInput,
111 recording_metadata_description: HtmlInputElement => TextInput,
112 recording_metadata_author: HtmlInputElement => TextInput,
113 recorder_mode: HtmlSelectElement => EnumInput<maia_json::RecorderMode>,
114 recorder_maximum_duration: HtmlInputElement => NumberInput<f64>,
115 recording_metadata_geolocation: HtmlSpanElement => Rc<HtmlSpanElement>,
116 recording_metadata_geolocation_update: HtmlButtonElement => Rc<HtmlButtonElement>,
117 recording_metadata_geolocation_clear: HtmlButtonElement => Rc<HtmlButtonElement>,
118 geolocation_point: HtmlSpanElement => Rc<HtmlSpanElement>,
119 geolocation_update: HtmlButtonElement => Rc<HtmlButtonElement>,
120 geolocation_watch: HtmlInputElement => CheckboxInput,
121 geolocation_clear: HtmlButtonElement => Rc<HtmlButtonElement>,
122 maia_wasm_version: HtmlSpanElement => Rc<HtmlSpanElement>,
123}
124
125#[derive(Default)]
126struct LocalSettings {
127 waterfall_show_ddc: bool,
128}
129
130impl Ui {
131 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 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 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 ddc_onchange.call0(&JsValue::NULL)?;
249 Ok(())
250 }
251}
252
253impl 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
267impl 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
288impl 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 handler.call0(&JsValue::NULL)?;
303 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 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
336impl Ui {
338 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 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 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 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 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 closure
432 .as_ref()
433 .unchecked_ref::<js_sys::Function>()
434 .call0(&JsValue::NULL)
435 .unwrap()
436 })
437 }
438}
439
440impl 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 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 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 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#[allow(dead_code)]
569#[derive(Debug, Copy, Clone, PartialEq, Deserialize)]
570struct GeolocationPosition {
571 coords: GeolocationCoordinates,
572 timestamp: f64,
573}
574
575#[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 return Ok(Ref::map(geolocation, |opt| opt.as_ref().unwrap()));
647 }
648 }
649 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 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 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; 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 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
819impl 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 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
956impl 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 if let Some(freq) = self
981 .api_state
982 .borrow()
983 .as_ref()
984 .map(|s| s.spectrometer.output_sampling_frequency)
985 {
986 json.output_sampling_frequency = Some(freq);
989 }
990 }
991 }
992
993 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
1005impl 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; 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
1022impl 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 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 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 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 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}