lodviz_components 0.3.0

Components for data visualization using lodviz_core
Documentation
/// Canvas rendering for large datasets
///
/// Provides hybrid SVG/Canvas rendering where Canvas is used for data-heavy
/// elements (lines, points) and SVG for UI elements (axes, grid, legend).
use leptos::html;
use leptos::prelude::*;
use lodviz_core::core::data::DataPoint;
use lodviz_core::core::scale::{LinearScale, Scale};
use std::ops::Deref;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};

/// Rendering mode for charts
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenderMode {
    /// Always use SVG (good for < 1000 points)
    Svg,
    /// Always use Canvas (better for > 5000 points)
    Canvas,
    /// Automatically choose based on data size
    #[default]
    Auto,
}

impl RenderMode {
    /// Determine actual render mode based on data characteristics
    pub fn resolve(&self, total_points: usize, series_count: usize) -> ResolvedRenderMode {
        match self {
            Self::Svg => ResolvedRenderMode::Svg,
            Self::Canvas => ResolvedRenderMode::Canvas,
            Self::Auto => {
                // Heuristic: Canvas for large datasets
                if total_points > 5000 || series_count > 10 {
                    ResolvedRenderMode::Canvas
                } else {
                    ResolvedRenderMode::Svg
                }
            }
        }
    }
}

/// Resolved render mode (no Auto)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedRenderMode {
    Svg,
    Canvas,
}

/// Type alias to reduce complexity of the series data signal.
pub type SeriesRenderData = Vec<(String, Vec<DataPoint>, String, bool)>;

/// Canvas line renderer component
///
/// Renders line series on HTML Canvas element for better performance with large datasets.
/// This component only renders the data lines - axes, grid, and legend should be
/// rendered as SVG overlays for crisp text rendering.
#[component]
pub fn CanvasLineRenderer(
    /// Canvas width in pixels
    width: Signal<f64>,
    /// Canvas height in pixels
    height: Signal<f64>,
    /// Series data: Vec<(series_name, points, color, visible)>
    series: Signal<SeriesRenderData>,
    /// X scale for mapping data to canvas coordinates
    x_scale: Signal<LinearScale>,
    /// Y scale for mapping data to canvas coordinates
    y_scale: Signal<LinearScale>,
    /// Line width in pixels
    #[prop(default = 2.0)]
    line_width: f64,
) -> impl IntoView {
    let canvas_ref = NodeRef::<html::Canvas>::new();

    // Get device pixel ratio for high DPI support (computed once)
    let dpr = web_sys::window()
        .and_then(|w| w.device_pixel_ratio().into())
        .unwrap_or(1.0);

    // Canvas pixel dimensions (considering DPI)
    let canvas_width = Signal::derive(move || (width.get() * dpr) as u32);
    let canvas_height = Signal::derive(move || (height.get() * dpr) as u32);

    // Redraw canvas whenever data/scales/size change
    Effect::new(move |_| {
        let Some(canvas_el) = canvas_ref.get() else {
            return;
        };

        let w = width.get();
        let h = height.get();

        // Get 2D context from canvas element
        // Cast using JsCast to web_sys type
        let canvas: HtmlCanvasElement = canvas_el.deref().clone().unchecked_into();
        let ctx: CanvasRenderingContext2d = canvas
            .get_context("2d")
            .ok()
            .flatten()
            .expect("Canvas 2D context")
            .unchecked_into();

        // Scale for high DPI
        let _ = ctx.scale(dpr, dpr);

        // Clear canvas
        ctx.clear_rect(0.0, 0.0, w, h);

        // Draw each series
        let x_scale_val = x_scale.get();
        let y_scale_val = y_scale.get();

        for (_, points, color, visible) in series.get() {
            if !visible || points.is_empty() {
                continue;
            }

            ctx.begin_path();
            ctx.set_stroke_style_str(&color);
            ctx.set_line_width(line_width);
            ctx.set_line_cap("round");
            ctx.set_line_join("round");

            // Draw line path
            for (i, point) in points.iter().enumerate() {
                let x = x_scale_val.map(point.x);
                let y = y_scale_val.map(point.y);

                if i == 0 {
                    ctx.move_to(x, y);
                } else {
                    ctx.line_to(x, y);
                }
            }

            ctx.stroke();
        }
    });

    view! {
        <canvas
            node_ref=canvas_ref
            width=move || canvas_width.get()
            height=move || canvas_height.get()
            style=move || {
                format!(
                    "width: {}px; height: {}px; position: absolute; top: 0; left: 0; pointer-events: none;",
                    width.get(),
                    height.get(),
                )
            }
        ></canvas>
    }
}

/// Canvas scatter renderer component
///
/// Renders scatter points on HTML Canvas for better performance with large datasets.
#[component]
pub fn CanvasScatterRenderer(
    /// Canvas width in pixels
    width: Signal<f64>,
    /// Canvas height in pixels
    height: Signal<f64>,
    /// Series data: Vec<(series_name, points, color, visible)>
    series: Signal<SeriesRenderData>,
    /// X scale for mapping data to canvas coordinates
    x_scale: Signal<LinearScale>,
    /// Y scale for mapping data to canvas coordinates
    y_scale: Signal<LinearScale>,
    /// Point radius in pixels
    #[prop(default = 4.0)]
    point_radius: f64,
    /// Point opacity (0.0 - 1.0)
    #[prop(default = 0.7)]
    opacity: f64,
) -> impl IntoView {
    let canvas_ref = NodeRef::<html::Canvas>::new();

    // Get device pixel ratio for high DPI support (computed once)
    let dpr = web_sys::window()
        .and_then(|w| w.device_pixel_ratio().into())
        .unwrap_or(1.0);

    // Canvas pixel dimensions (considering DPI)
    let canvas_width = Signal::derive(move || (width.get() * dpr) as u32);
    let canvas_height = Signal::derive(move || (height.get() * dpr) as u32);

    // Redraw canvas whenever data/scales/size change
    Effect::new(move |_| {
        let Some(canvas_el) = canvas_ref.get() else {
            return;
        };

        let w = width.get();
        let h = height.get();

        // Get 2D context from canvas element
        // Cast using JsCast to web_sys type
        let canvas: HtmlCanvasElement = canvas_el.deref().clone().unchecked_into();
        let ctx: CanvasRenderingContext2d = canvas
            .get_context("2d")
            .ok()
            .flatten()
            .expect("Canvas 2D context")
            .unchecked_into();

        // Scale for high DPI
        let _ = ctx.scale(dpr, dpr);

        // Clear canvas
        ctx.clear_rect(0.0, 0.0, w, h);

        // Draw each series
        let x_scale_val = x_scale.get();
        let y_scale_val = y_scale.get();

        ctx.set_global_alpha(opacity);

        for (_, points, color, visible) in series.get() {
            if !visible || points.is_empty() {
                continue;
            }

            ctx.set_fill_style_str(&color);

            // Draw points
            for point in points.iter() {
                let x = x_scale_val.map(point.x);
                let y = y_scale_val.map(point.y);

                ctx.begin_path();
                let _ = ctx.arc(x, y, point_radius, 0.0, std::f64::consts::TAU);
                ctx.fill();
            }
        }
    });

    view! {
        <canvas
            node_ref=canvas_ref
            width=move || canvas_width.get()
            height=move || canvas_height.get()
            style=move || {
                format!(
                    "width: {}px; height: {}px; position: absolute; top: 0; left: 0; pointer-events: none;",
                    width.get(),
                    height.get(),
                )
            }
        ></canvas>
    }
}