Skip to main content

wowlab_wasm/
analytics.rs

1use std::{error::Error, fmt};
2
3use wasm_bindgen::prelude::*;
4use wowlab_types::wasm::{WasmCommonError, decode_hex_to_bytes};
5
6/// Analytics failure converted to a named JavaScript error at the export boundary.
7#[derive(Debug)]
8pub struct WasmAnalyticsError {
9    kind: WasmAnalyticsErrorKind,
10}
11
12#[derive(Debug)]
13enum WasmAnalyticsErrorKind {
14    Input(WasmCommonError),
15    Analytics(wowlab_analytics::AnalyticsDecodeError),
16}
17
18impl fmt::Display for WasmAnalyticsError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match &self.kind {
21            WasmAnalyticsErrorKind::Input(error) => fmt::Display::fmt(error, f),
22            WasmAnalyticsErrorKind::Analytics(error) => fmt::Display::fmt(error, f),
23        }
24    }
25}
26
27impl From<WasmCommonError> for WasmAnalyticsError {
28    fn from(error: WasmCommonError) -> Self {
29        Self {
30            kind: WasmAnalyticsErrorKind::Input(error),
31        }
32    }
33}
34
35impl From<wowlab_analytics::AnalyticsDecodeError> for WasmAnalyticsError {
36    fn from(error: wowlab_analytics::AnalyticsDecodeError) -> Self {
37        Self {
38            kind: WasmAnalyticsErrorKind::Analytics(error),
39        }
40    }
41}
42
43impl Error for WasmAnalyticsError {
44    fn source(&self) -> Option<&(dyn Error + 'static)> {
45        match &self.kind {
46            WasmAnalyticsErrorKind::Input(_) => None,
47            WasmAnalyticsErrorKind::Analytics(error) => Some(error),
48        }
49    }
50}
51
52impl From<WasmAnalyticsError> for JsValue {
53    fn from(error: WasmAnalyticsError) -> Self {
54        let js_error = js_sys::Error::new(&error.to_string());
55
56        js_error.set_name("AnalyticsDecodeError");
57
58        js_error.into()
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use std::error::Error as _;
65
66    use googletest::prelude::*;
67
68    use super::*;
69
70    #[gtest]
71    fn analytics_error_retains_the_prost_source_until_javascript_conversion() -> Result<()> {
72        let analytics_error = wowlab_analytics::decode_and_derive(&[0x80], &[])
73            .err()
74            .or_fail()?;
75        let error = WasmAnalyticsError::from(analytics_error);
76
77        let analytics_source = error.source().or_fail()?;
78        let analytics_kind = analytics_source.source().or_fail()?;
79        let prost_source = analytics_kind.source().or_fail()?;
80
81        verify_that!(
82            prost_source.to_string(),
83            eq("failed to decode Protobuf message: invalid varint")
84        )
85    }
86}
87
88#[wasm_bindgen(js_name = "computeChartOverlay")]
89#[must_use]
90pub fn wasm_compute_chart_overlay(y_values: &[f64]) -> wowlab_types::stats::ChartOverlayView {
91    wowlab_types::stats::compute_chart_overlay(y_values)
92}
93
94#[wasm_bindgen(js_name = "computeScatterOverlay")]
95#[must_use]
96pub fn wasm_compute_scatter_overlay(
97    xs: &[f64],
98    ys: &[f64],
99) -> wowlab_types::stats::ScatterOverlayView {
100    wowlab_types::stats::compute_scatter_overlay(xs, ys)
101}
102
103#[wasm_bindgen(js_name = "computeTimelineMetrics")]
104#[must_use]
105pub fn wasm_compute_timeline_metrics(
106    geometry: &wowlab_types::stats::TimelineGeometry,
107) -> wowlab_types::stats::TimelineMetrics {
108    wowlab_types::stats::compute_timeline_metrics(geometry)
109}
110
111/// Compute the new viewport after a cursor-anchored zoom step (`local_x` is pixels from the track's left edge).
112#[wasm_bindgen(js_name = "zoomTimelineAt")]
113#[must_use]
114pub fn wasm_zoom_timeline_at(
115    geometry: &wowlab_types::stats::TimelineGeometry,
116    local_x: f64,
117    zoom_in: bool,
118    zoom_factor: f64,
119) -> wowlab_types::stats::TimelineViewport {
120    wowlab_types::stats::zoom_timeline_at(geometry, local_x, zoom_in, zoom_factor)
121}
122
123#[wasm_bindgen(js_name = "panTimelineBy")]
124#[must_use]
125pub fn wasm_pan_timeline_by(
126    geometry: &wowlab_types::stats::TimelineGeometry,
127    start_pan_ms: f64,
128    delta_px: f64,
129) -> wowlab_types::stats::TimelineViewport {
130    wowlab_types::stats::pan_timeline_by(geometry, start_pan_ms, delta_px)
131}
132
133/// Decode `ResultViewV1` + `TimelineViewV1` and derive `AnalyticsView`.
134#[wasm_bindgen(js_name = decodeAndDerive)]
135pub fn wasm_decode_and_derive(
136    result_hex: &str,
137    timeline_hex: &str,
138) -> Result<wowlab_analytics::AnalyticsView, WasmAnalyticsError> {
139    let result_bytes = decode_hex_to_bytes(result_hex)?;
140    let timeline_bytes = decode_hex_to_bytes(timeline_hex)?;
141
142    Ok(wowlab_analytics::decode_and_derive(
143        &result_bytes,
144        &timeline_bytes,
145    )?)
146}
147
148/// Decode the `JobResult` + `JobTimeline` envelope and return a tagged view.
149#[wasm_bindgen(js_name = decodeJobResult)]
150pub fn wasm_decode_job_result(
151    result_hex: &str,
152    timeline_hex: &str,
153) -> Result<wowlab_analytics::JobResultView, WasmAnalyticsError> {
154    let result_bytes = decode_hex_to_bytes(result_hex)?;
155    let timeline_bytes = decode_hex_to_bytes(timeline_hex)?;
156
157    Ok(wowlab_analytics::decode_job_result(
158        &result_bytes,
159        &timeline_bytes,
160    )?)
161}