use crate::artist::Line2D;
use crate::core::color::{Rgba, to_rgba};
use crate::figure::Figure;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::{JsCast, JsValue};
#[derive(Debug, Default, PartialEq)]
struct LineStyleSpec {
color: Option<Rgba>,
linewidth: Option<f64>,
dashes: Option<Option<(f64, Vec<f64>)>>,
}
fn dash_pattern(ls: &str) -> Result<Option<(f64, Vec<f64>)>, String> {
match ls {
"-" | "solid" => Ok(None),
"--" | "dashed" => Ok(Some((0.0, vec![3.7, 1.6]))),
":" | "dotted" => Ok(Some((0.0, vec![1.0, 1.65]))),
"-." | "dashdot" => Ok(Some((0.0, vec![6.4, 1.6, 1.0, 1.6]))),
other => Err(format!(
"unknown linestyle '{other}' (expected '-', '--', ':', '-.', or a long name)"
)),
}
}
fn build_line_style(
color: Option<&str>,
lw: Option<f64>,
ls: Option<&str>,
) -> Result<LineStyleSpec, String> {
let color = match color {
Some(spec) => {
Some(to_rgba(spec, None).ok_or_else(|| format!("unrecognized color spec '{spec}'"))?)
}
None => None,
};
if let Some(w) = lw
&& !(w.is_finite() && w >= 0.0)
{
return Err(format!(
"linewidth must be finite and non-negative, got {w}"
));
}
let dashes = match ls {
Some(token) => Some(dash_pattern(token)?),
None => None,
};
Ok(LineStyleSpec {
color,
linewidth: lw,
dashes,
})
}
fn line_style_from_js(style: &JsValue) -> Result<LineStyleSpec, JsValue> {
if style.is_undefined() || style.is_null() {
return Ok(LineStyleSpec::default());
}
let obj: &js_sys::Object = style
.dyn_ref()
.ok_or_else(|| JsValue::from_str("style must be a plain object like {color, lw, ls}"))?;
let (mut color, mut lw, mut ls) = (None, None, None);
for key in js_sys::Object::keys(obj).iter() {
let key = key
.as_string()
.ok_or_else(|| JsValue::from_str("style keys must be strings"))?;
let value = js_sys::Reflect::get(style, &JsValue::from_str(&key))?;
match key.as_str() {
"color" => {
color =
Some(value.as_string().ok_or_else(|| {
JsValue::from_str("style.color must be a string color spec")
})?);
}
"lw" => {
lw = Some(
value
.as_f64()
.ok_or_else(|| JsValue::from_str("style.lw must be a number"))?,
);
}
"ls" => {
ls = Some(
value
.as_string()
.ok_or_else(|| JsValue::from_str("style.ls must be a linestyle string"))?,
);
}
other => {
return Err(JsValue::from_str(&format!(
"unknown style key '{other}' (expected color, lw, ls)"
)));
}
}
}
build_line_style(color.as_deref(), lw, ls.as_deref()).map_err(|e| JsValue::from_str(&e))
}
#[must_use]
pub fn sample_figure() -> Figure {
use std::f64::consts::TAU;
let mut fig = Figure::new(4.0, 3.0);
let ax = fig.add_axes(0.15, 0.15, 0.78, 0.74);
let n = 200;
let xs: Vec<f64> = (0..n).map(|i| TAU * i as f64 / (n as f64 - 1.0)).collect();
let ys: Vec<f64> = xs.iter().map(|&x| x.sin()).collect();
let blue = Rgba::new(0.121_568_63, 0.466_666_67, 0.705_882_35, 1.0);
ax.plot_with_color(&xs, &ys, blue);
ax.set_title("sin(x)");
ax.set_xlabel("x");
ax.set_ylabel("y");
fig
}
#[must_use]
pub fn figure_to_rgba(fig: &Figure) -> (Vec<u8>, u32, u32) {
figure_to_rgba_scaled(fig, 1.0)
}
#[must_use]
pub fn figure_to_rgba_scaled(fig: &Figure, scale: f64) -> (Vec<u8>, u32, u32) {
let renderer = fig.render_scaled(scale);
let pixmap = renderer.pixmap();
let width = pixmap.width();
let height = pixmap.height();
let mut rgba = Vec::with_capacity((width as usize) * (height as usize) * 4);
for px in pixmap.pixels() {
let straight = px.demultiply();
rgba.push(straight.red());
rgba.push(straight.green());
rgba.push(straight.blue());
rgba.push(straight.alpha());
}
(rgba, width, height)
}
#[wasm_bindgen]
pub struct WasmFigure {
fig: Figure,
}
#[wasm_bindgen]
impl WasmFigure {
#[wasm_bindgen(constructor)]
#[must_use]
pub fn new(width_in: f64, height_in: f64) -> WasmFigure {
WasmFigure {
fig: Figure::new(width_in, height_in),
}
}
#[must_use]
pub fn sample() -> WasmFigure {
WasmFigure {
fig: sample_figure(),
}
}
pub fn add_axes(&mut self, l: f64, b: f64, w: f64, h: f64) -> usize {
self.fig.add_axes(l, b, w, h);
self.fig.axes().len() - 1
}
pub fn add_subplot(
&mut self,
nrows: usize,
ncols: usize,
index: usize,
) -> Result<usize, JsValue> {
self.add_subplot_impl(nrows, ncols, index).map_err(js_err)
}
pub fn plot(&mut self, axes: usize, x: &[f64], y: &[f64]) -> Result<(), JsValue> {
self.plot_impl(axes, x, y).map_err(js_err)
}
pub fn plot_styled(
&mut self,
axes: usize,
x: &[f64],
y: &[f64],
style: &JsValue,
) -> Result<(), JsValue> {
let spec = line_style_from_js(style)?;
self.plot_styled_impl(axes, x, y, spec).map_err(js_err)
}
pub fn scatter(&mut self, axes: usize, x: &[f64], y: &[f64]) -> Result<(), JsValue> {
self.scatter_impl(axes, x, y).map_err(js_err)
}
pub fn set_title(&mut self, axes: usize, title: &str) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_title(title);
})
.map_err(js_err)
}
pub fn set_xlabel(&mut self, axes: usize, label: &str) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_xlabel(label);
})
.map_err(js_err)
}
pub fn set_ylabel(&mut self, axes: usize, label: &str) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_ylabel(label);
})
.map_err(js_err)
}
pub fn set_xlim(&mut self, axes: usize, lo: f64, hi: f64) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_xlim(lo, hi);
})
.map_err(js_err)
}
pub fn set_ylim(&mut self, axes: usize, lo: f64, hi: f64) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_ylim(lo, hi);
})
.map_err(js_err)
}
pub fn limits(&self, axes: usize) -> Result<Box<[f64]>, JsValue> {
self.limits_impl(axes)
.map(|l| Box::new(l) as Box<[f64]>)
.map_err(js_err)
}
pub fn set_xscale_log(&mut self, axes: usize, base: f64) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_xscale_log(base);
})
.map_err(js_err)
}
pub fn set_yscale_log(&mut self, axes: usize, base: f64) -> Result<(), JsValue> {
self.with_axes(axes, |ax| {
ax.set_yscale_log(base);
})
.map_err(js_err)
}
pub fn legend(&mut self, axes: usize, labels: Vec<String>) -> Result<(), JsValue> {
self.legend_impl(axes, labels).map_err(js_err)
}
#[wasm_bindgen(getter)]
#[must_use]
pub fn size(&self) -> Box<[f64]> {
let (w, h) = self.fig.size_px();
Box::new([w, h])
}
#[must_use]
pub fn data_at(&self, px: f64, py: f64) -> Option<Box<[f64]>> {
self.fig
.pixel_to_data(0, px, py)
.map(|(x, y)| Box::new([x, y]) as Box<[f64]>)
}
}
impl WasmFigure {
fn with_axes<T>(
&mut self,
axes: usize,
f: impl FnOnce(&mut crate::figure::Axes) -> T,
) -> Result<T, String> {
let count = self.fig.axes().len();
self.fig
.axes_mut()
.get_mut(axes)
.map(f)
.ok_or_else(|| format!("axes index {axes} out of range (figure has {count} axes)"))
}
fn add_subplot_impl(
&mut self,
nrows: usize,
ncols: usize,
index: usize,
) -> Result<usize, String> {
if index == 0 || index > nrows * ncols {
return Err(format!(
"subplot index {index} out of range for a {nrows}x{ncols} grid (1-based)"
));
}
self.fig.add_subplot(nrows, ncols, index);
Ok(self.fig.axes().len() - 1)
}
fn plot_impl(&mut self, axes: usize, x: &[f64], y: &[f64]) -> Result<(), String> {
self.with_axes(axes, |ax| {
ax.plot(x, y);
})
}
fn plot_styled_impl(
&mut self,
axes: usize,
x: &[f64],
y: &[f64],
spec: LineStyleSpec,
) -> Result<(), String> {
self.with_axes(axes, |ax| {
let color = spec.color.unwrap_or_else(|| ax.next_cycle_color());
let mut line = Line2D::new(x.to_vec(), y.to_vec()).with_color(color);
if let Some(w) = spec.linewidth {
line = line.with_linewidth(w);
}
if let Some(dashes) = spec.dashes {
line = line.with_dashes(dashes);
}
ax.add_line(line);
})
}
fn scatter_impl(&mut self, axes: usize, x: &[f64], y: &[f64]) -> Result<(), String> {
self.with_axes(axes, |ax| {
ax.scatter(x, y);
})
}
fn limits_impl(&self, axes: usize) -> Result<[f64; 4], String> {
let count = self.fig.axes().len();
let ax =
self.fig.axes().get(axes).ok_or_else(|| {
format!("axes index {axes} out of range (figure has {count} axes)")
})?;
let ((xlo, xhi), (ylo, yhi)) = ax.effective_limits();
Ok([xlo, xhi, ylo, yhi])
}
fn legend_impl(&mut self, axes: usize, labels: Vec<String>) -> Result<(), String> {
self.with_axes(axes, |ax| {
if labels.len() > ax.lines.len() {
return Err(format!(
"{} legend labels but only {} lines",
labels.len(),
ax.lines.len()
));
}
let entries = labels
.into_iter()
.enumerate()
.map(|(i, label)| (ax.lines[i].color(), label))
.collect();
ax.legend(entries);
Ok(())
})?
}
}
fn js_err(msg: String) -> JsValue {
JsValue::from_str(&msg)
}
#[cfg(target_arch = "wasm32")]
pub use canvas::{WasmSession, draw_sample_to_canvas, render_rgba_to_canvas};
#[cfg(target_arch = "wasm32")]
mod canvas {
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen};
use web_sys::{
AddEventListenerOptions, CanvasRenderingContext2d, HtmlCanvasElement, ImageData,
PointerEvent, WheelEvent,
};
use crate::figure::{Event, Interactor, MouseButton, Outcome};
use crate::wasm::{WasmFigure, figure_to_rgba_scaled, sample_figure};
#[wasm_bindgen]
impl WasmFigure {
pub fn render(&self, canvas_id: &str) -> Result<(), JsValue> {
let (canvas, context) = canvas_context(canvas_id)?;
let scale = device_scale();
let (rgba, width, height) = figure_to_rgba_scaled(&self.fig, scale);
set_css_size(&canvas, self.fig.size_px())?;
blit(&canvas, &context, &rgba, width, height)
}
pub fn bind(self, canvas_id: &str) -> Result<WasmSession, JsValue> {
WasmSession::attach(self.fig, canvas_id)
}
}
struct SessionState {
interactor: Interactor,
canvas: HtmlCanvasElement,
context: CanvasRenderingContext2d,
scale: f64,
raf_pending: bool,
hover_cb: Option<js_sys::Function>,
}
#[wasm_bindgen]
pub struct WasmSession {
state: Rc<RefCell<SessionState>>,
_listeners: Vec<Closure<dyn FnMut(web_sys::Event)>>,
}
#[wasm_bindgen]
impl WasmSession {
#[wasm_bindgen(getter)]
#[must_use]
pub fn size(&self) -> Box<[f64]> {
let (w, h) = self.state.borrow().interactor.figure().size_px();
Box::new([w, h])
}
pub fn render(&self) -> Result<(), JsValue> {
render_now(&self.state)
}
pub fn limits(&self, axes: usize) -> Result<Box<[f64]>, JsValue> {
let state = self.state.borrow();
let fig = state.interactor.figure();
let ax = fig.axes().get(axes).ok_or_else(|| {
JsValue::from_str(&format!(
"axes index {axes} out of range (figure has {} axes)",
fig.axes().len()
))
})?;
let ((xlo, xhi), (ylo, yhi)) = ax.effective_limits();
Ok(Box::new([xlo, xhi, ylo, yhi]))
}
#[must_use]
pub fn data_at(&self, px: f64, py: f64) -> Option<Box<[f64]>> {
let state = self.state.borrow();
let fig = state.interactor.figure();
let axes = fig.axes_at(px, py)?;
let (x, y) = fig.pixel_to_data(axes, px, py)?;
Some(Box::new([axes as f64, x, y]))
}
pub fn on_hover(&self, cb: js_sys::Function) {
self.state.borrow_mut().hover_cb = Some(cb);
}
fn attach(fig: crate::figure::Figure, canvas_id: &str) -> Result<WasmSession, JsValue> {
let (canvas, context) = canvas_context(canvas_id)?;
set_css_size(&canvas, fig.size_px())?;
let state = Rc::new(RefCell::new(SessionState {
interactor: Interactor::new(fig),
canvas,
context,
scale: device_scale(),
raf_pending: false,
hover_cb: None,
}));
render_now(&state)?;
let mut listeners = Vec::new();
add_pointer_listeners(&state, &mut listeners)?;
add_wheel_listener(&state, &mut listeners)?;
Ok(WasmSession {
state,
_listeners: listeners,
})
}
}
fn dom_button(code: i16) -> Option<MouseButton> {
match code {
0 => Some(MouseButton::Left),
1 => Some(MouseButton::Middle),
2 => Some(MouseButton::Right),
_ => None,
}
}
fn wheel_lines(ev: &WheelEvent) -> f64 {
match ev.delta_mode() {
WheelEvent::DOM_DELTA_PIXEL => ev.delta_y() / 120.0,
WheelEvent::DOM_DELTA_PAGE => ev.delta_y() * 10.0,
_ => ev.delta_y(),
}
}
fn add_pointer_listeners(
state: &Rc<RefCell<SessionState>>,
listeners: &mut Vec<Closure<dyn FnMut(web_sys::Event)>>,
) -> Result<(), JsValue> {
add_listener(state, listeners, "pointerdown", |ev| {
let pe: &PointerEvent = ev.dyn_ref()?;
let button = dom_button(pe.button())?;
if let Some(canvas) = ev
.current_target()
.and_then(|t| t.dyn_into::<HtmlCanvasElement>().ok())
{
let _ = canvas.set_pointer_capture(pe.pointer_id());
}
Some(Event::MouseDown {
x: f64::from(pe.offset_x()),
y: f64::from(pe.offset_y()),
button,
})
})?;
add_listener(state, listeners, "pointermove", |ev| {
let pe: &PointerEvent = ev.dyn_ref()?;
Some(Event::MouseMove {
x: f64::from(pe.offset_x()),
y: f64::from(pe.offset_y()),
})
})?;
add_listener(state, listeners, "pointerup", |ev| {
let pe: &PointerEvent = ev.dyn_ref()?;
let button = dom_button(pe.button())?;
if let Some(canvas) = ev
.current_target()
.and_then(|t| t.dyn_into::<HtmlCanvasElement>().ok())
{
let _ = canvas.release_pointer_capture(pe.pointer_id());
}
Some(Event::MouseUp {
x: f64::from(pe.offset_x()),
y: f64::from(pe.offset_y()),
button,
})
})?;
add_listener(state, listeners, "pointerleave", |_| Some(Event::Leave))?;
add_listener(state, listeners, "pointercancel", |_| Some(Event::Leave))?;
add_listener(state, listeners, "lostpointercapture", |_| {
Some(Event::Leave)
})?;
add_listener(state, listeners, "dblclick", |ev| {
let me: &web_sys::MouseEvent = ev.dyn_ref()?;
Some(Event::DoubleClick {
x: f64::from(me.offset_x()),
y: f64::from(me.offset_y()),
})
})
}
fn add_wheel_listener(
state: &Rc<RefCell<SessionState>>,
listeners: &mut Vec<Closure<dyn FnMut(web_sys::Event)>>,
) -> Result<(), JsValue> {
let st = state.clone();
let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |ev: web_sys::Event| {
let Some(we) = ev.dyn_ref::<WheelEvent>() else {
return;
};
ev.prevent_default();
let event = Event::Wheel {
x: f64::from(we.offset_x()),
y: f64::from(we.offset_y()),
dy: wheel_lines(we),
};
dispatch(&st, event);
});
let canvas = state.borrow().canvas.clone();
let opts = AddEventListenerOptions::new();
opts.set_passive(false);
canvas.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
cb.as_ref().unchecked_ref(),
&opts,
)?;
listeners.push(cb);
Ok(())
}
fn add_listener(
state: &Rc<RefCell<SessionState>>,
listeners: &mut Vec<Closure<dyn FnMut(web_sys::Event)>>,
name: &str,
map: impl Fn(&web_sys::Event) -> Option<Event> + 'static,
) -> Result<(), JsValue> {
let st = state.clone();
let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |ev: web_sys::Event| {
if let Some(event) = map(&ev) {
dispatch(&st, event);
}
});
let canvas = state.borrow().canvas.clone();
canvas.add_event_listener_with_callback(name, cb.as_ref().unchecked_ref())?;
listeners.push(cb);
Ok(())
}
fn dispatch(state: &Rc<RefCell<SessionState>>, event: Event) {
let outcome = state.borrow_mut().interactor.handle(event);
match outcome {
Outcome::NeedsRedraw => schedule_redraw(state),
Outcome::Hover { axes, x, y } => {
let cb = state.borrow().hover_cb.clone();
if let Some(cb) = cb {
let _ = cb.call3(
&JsValue::NULL,
&JsValue::from_f64(axes as f64),
&JsValue::from_f64(x),
&JsValue::from_f64(y),
);
}
}
Outcome::Unchanged => {
if matches!(event, Event::Leave) {
let cb = state.borrow().hover_cb.clone();
if let Some(cb) = cb {
let _ = cb.call1(&JsValue::NULL, &JsValue::NULL);
}
}
}
}
}
fn schedule_redraw(state: &Rc<RefCell<SessionState>>) {
{
let mut st = state.borrow_mut();
if st.raf_pending {
return;
}
st.raf_pending = true;
}
let st = state.clone();
let cb = Closure::once_into_js(move || {
st.borrow_mut().raf_pending = false;
let _ = render_now(&st);
});
let scheduled = web_sys::window()
.map(|win| win.request_animation_frame(cb.unchecked_ref()).is_ok())
.unwrap_or(false);
if !scheduled {
state.borrow_mut().raf_pending = false;
let _ = render_now(state);
}
}
fn render_now(state: &Rc<RefCell<SessionState>>) -> Result<(), JsValue> {
state.borrow_mut().scale = device_scale();
let st = state.borrow();
let (rgba, width, height) = figure_to_rgba_scaled(st.interactor.figure(), st.scale);
blit(&st.canvas, &st.context, &rgba, width, height)
}
fn device_scale() -> f64 {
web_sys::window()
.map(|w| w.device_pixel_ratio())
.filter(|s| s.is_finite() && *s > 0.0)
.unwrap_or(1.0)
}
fn set_css_size(canvas: &HtmlCanvasElement, logical: (f64, f64)) -> Result<(), JsValue> {
let style = canvas.style();
style.set_property("width", &format!("{}px", logical.0))?;
style.set_property("height", &format!("{}px", logical.1))
}
fn blit(
canvas: &HtmlCanvasElement,
context: &CanvasRenderingContext2d,
rgba: &[u8],
width: u32,
height: u32,
) -> Result<(), JsValue> {
if canvas.width() != width {
canvas.set_width(width);
}
if canvas.height() != height {
canvas.set_height(height);
}
let clamped = wasm_bindgen::Clamped(rgba);
let image_data = ImageData::new_with_u8_clamped_array_and_sh(clamped, width, height)?;
context.put_image_data(&image_data, 0.0, 0.0)
}
fn canvas_context(
canvas_id: &str,
) -> Result<(HtmlCanvasElement, CanvasRenderingContext2d), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no global window"))?;
let document = window
.document()
.ok_or_else(|| JsValue::from_str("window has no document"))?;
let element = document
.get_element_by_id(canvas_id)
.ok_or_else(|| JsValue::from_str(&format!("no element with id '{canvas_id}'")))?;
let canvas: HtmlCanvasElement = element
.dyn_into()
.map_err(|_| JsValue::from_str(&format!("element '{canvas_id}' is not a canvas")))?;
let context = canvas
.get_context("2d")?
.ok_or_else(|| JsValue::from_str("canvas has no 2d context"))?
.dyn_into::<CanvasRenderingContext2d>()
.map_err(|_| JsValue::from_str("2d context has unexpected type"))?;
Ok((canvas, context))
}
#[wasm_bindgen]
pub fn draw_sample_to_canvas(canvas_id: &str) -> Result<(), JsValue> {
WasmFigure {
fig: sample_figure(),
}
.render(canvas_id)
}
#[wasm_bindgen]
pub fn render_rgba_to_canvas(
canvas_id: &str,
rgba: &[u8],
width: u32,
height: u32,
) -> Result<(), JsValue> {
let (canvas, context) = canvas_context(canvas_id)?;
blit(&canvas, &context, rgba, width, height)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgba_matches_figure_dimensions_and_has_ink() {
let fig = sample_figure();
let (rgba, width, height) = figure_to_rgba(&fig);
let (w_px, h_px) = fig.size_px();
assert_eq!(width, w_px as u32);
assert_eq!(height, h_px as u32);
assert!(!rgba.is_empty(), "rgba buffer is empty");
assert_eq!(
rgba.len(),
(width as usize) * (height as usize) * 4,
"buffer length must be w * h * 4"
);
assert!(
rgba.iter().any(|&b| b != 0),
"expected non-zero pixels (something drawn)"
);
}
#[test]
fn dash_patterns_map_matplotlib_tokens() {
assert_eq!(dash_pattern("-").unwrap(), None);
assert_eq!(dash_pattern("solid").unwrap(), None);
assert_eq!(dash_pattern("--").unwrap(), Some((0.0, vec![3.7, 1.6])));
assert_eq!(dash_pattern(":").unwrap(), Some((0.0, vec![1.0, 1.65])));
assert_eq!(
dash_pattern("-.").unwrap(),
Some((0.0, vec![6.4, 1.6, 1.0, 1.6]))
);
assert!(dash_pattern("~~").is_err());
}
#[test]
fn build_line_style_validates_parts() {
let spec = build_line_style(Some("tab:orange"), Some(2.5), Some("--")).unwrap();
assert!(spec.color.is_some());
assert_eq!(spec.linewidth, Some(2.5));
assert_eq!(spec.dashes, Some(Some((0.0, vec![3.7, 1.6]))));
assert_eq!(
build_line_style(None, None, None).unwrap(),
LineStyleSpec::default()
);
assert!(build_line_style(Some("not-a-color"), None, None).is_err());
assert!(build_line_style(None, Some(-1.0), None).is_err());
assert!(build_line_style(None, Some(f64::NAN), None).is_err());
assert!(build_line_style(None, None, Some("wavy")).is_err());
}
#[test]
fn js_surface_builds_a_real_figure() {
let mut wf = WasmFigure::new(4.0, 3.0);
let ax = wf.add_axes(0.1, 0.1, 0.8, 0.8);
assert_eq!(ax, 0);
wf.plot_impl(0, &[0.0, 1.0, 2.0], &[0.0, 1.0, 4.0]).unwrap();
let styled = build_line_style(Some("tab:red"), Some(3.0), Some("--")).unwrap();
wf.plot_styled_impl(0, &[0.0, 2.0], &[4.0, 0.0], styled)
.unwrap();
wf.scatter_impl(0, &[0.5, 1.5], &[0.2, 2.0]).unwrap();
wf.with_axes(0, |ax| {
ax.set_title("t");
ax.set_xlabel("x");
ax.set_ylabel("y");
ax.set_xlim(0.0, 2.0);
ax.set_ylim(0.0, 4.0);
})
.unwrap();
wf.legend_impl(0, vec!["line".into(), "styled".into()])
.unwrap();
assert_eq!(wf.limits_impl(0).unwrap(), [0.0, 2.0, 0.0, 4.0]);
assert!(wf.plot_impl(3, &[0.0], &[0.0]).is_err());
assert!(wf.limits_impl(3).is_err());
assert!(
wf.legend_impl(0, vec!["a".into(), "b".into(), "c".into()])
.is_err()
);
let (rgba, w, h) = figure_to_rgba(&wf.fig);
assert_eq!(rgba.len(), (w as usize) * (h as usize) * 4);
assert!(rgba.chunks_exact(4).any(|px| px != [255, 255, 255, 255]));
}
#[test]
fn subplot_index_validation() {
let mut wf = WasmFigure::new(4.0, 3.0);
assert!(wf.add_subplot_impl(2, 2, 0).is_err());
assert!(wf.add_subplot_impl(2, 2, 5).is_err());
assert_eq!(wf.add_subplot_impl(2, 2, 1).unwrap(), 0);
assert_eq!(wf.add_subplot_impl(2, 2, 4).unwrap(), 1);
}
#[test]
fn scaled_render_matches_double_dpi_render() {
let fig = sample_figure();
let scaled = fig.render_scaled(2.0);
let mut hidpi = sample_figure();
let dpi = hidpi.dpi();
hidpi = hidpi.with_dpi(dpi * 2.0);
let reference = hidpi.render();
assert_eq!(scaled.pixmap().width(), reference.pixmap().width());
assert_eq!(scaled.pixmap().height(), reference.pixmap().height());
assert_eq!(
scaled.pixmap().data(),
reference.pixmap().data(),
"scale-2 render must be bit-identical to a 2x-DPI render"
);
}
#[test]
fn scaled_rgba_doubles_dimensions() {
let fig = sample_figure();
let (rgba1, w1, h1) = figure_to_rgba_scaled(&fig, 1.0);
let (rgba2, w2, h2) = figure_to_rgba_scaled(&fig, 2.0);
assert_eq!((w2, h2), (w1 * 2, h1 * 2));
assert_eq!(rgba2.len(), rgba1.len() * 4);
}
#[test]
fn straight_alpha_channels_stay_in_range() {
let (rgba, _w, _h) = figure_to_rgba(&sample_figure());
let mut saw_white = false;
for px in rgba.chunks_exact(4) {
if px[3] == 255 {
if px[0] == 255 && px[1] == 255 && px[2] == 255 {
saw_white = true;
}
}
}
assert!(
saw_white,
"expected the opaque white facecolor to survive un-premultiply as 255,255,255"
);
}
#[test]
fn data_at_round_trips_a_known_in_axes_pixel() {
let wf = WasmFigure::sample();
let (data_x, data_y) = (std::f64::consts::PI, 0.0);
let (px, py) = wf
.fig
.data_to_pixel(0, data_x, data_y)
.expect("axes 0 exists");
let got = wf.data_at(px, py).expect("pixel is inside the axes");
assert_eq!(got.len(), 2, "data_at returns [x, y]");
assert!(got[0].is_finite() && got[1].is_finite());
assert!((got[0] - data_x).abs() < 1e-6, "x round-trips: {}", got[0]);
assert!((got[1] - data_y).abs() < 1e-6, "y round-trips: {}", got[1]);
}
#[test]
fn data_at_center_is_within_data_range() {
let wf = WasmFigure::sample();
let size = wf.size();
let (cx, cy) = (size[0] / 2.0, size[1] / 2.0);
let got = wf
.data_at(cx, cy)
.expect("canvas center sits over the axes");
assert!(
(0.0..=std::f64::consts::TAU).contains(&got[0]),
"x near center should be in [0, TAU]: {}",
got[0]
);
assert!(
got[1].abs() <= 2.0,
"y near center should be near the sine range: {}",
got[1]
);
}
#[test]
fn data_at_off_canvas_is_none() {
let wf = WasmFigure::sample();
assert!(wf.data_at(-100.0, -100.0).is_none());
let size = wf.size();
assert!(
wf.data_at(size[0] + 1000.0, size[1] + 1000.0).is_none(),
"a pixel far past the canvas is off-axes"
);
}
}