lodviz_components 0.3.0

Components for data visualization using lodviz_core
Documentation
/// KPI Card component for single-value metrics with trend indicators
use leptos::prelude::*;
use lodviz_core::core::scale::{LinearScale, Scale};
use lodviz_core::core::theme::ChartTheme;

/// Format for displaying KPI values
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KpiFormat {
    /// Plain number (e.g., "127543")
    Number,
    /// Currency with symbol (e.g., "$127,543")
    Currency,
    /// Percentage (e.g., "12.5%")
    Percentage,
}

/// Comparison data for KPI card
#[derive(Debug, Clone, PartialEq)]
pub struct KpiComparison {
    /// Baseline value to compare against
    pub baseline: f64,
    /// Label for comparison (e.g., "vs Last Month", "vs Target")
    pub label: String,
}

/// Data structure for KPI Card
#[derive(Debug, Clone, PartialEq)]
pub struct KpiData {
    /// Label/title for this KPI
    pub label: String,
    /// Current value
    pub value: f64,
    /// Display format
    pub format: KpiFormat,
    /// Optional comparison with baseline
    pub comparison: Option<KpiComparison>,
    /// Optional sparkline data (mini trend chart)
    pub sparkline: Option<Vec<f64>>,
}

impl KpiData {
    /// Create a new KPI with just label and value
    pub fn new(label: impl Into<String>, value: f64) -> Self {
        Self {
            label: label.into(),
            value,
            format: KpiFormat::Number,
            comparison: None,
            sparkline: None,
        }
    }

    /// Set the display format
    pub fn with_format(mut self, format: KpiFormat) -> Self {
        self.format = format;
        self
    }

    /// Add comparison data
    pub fn with_comparison(mut self, baseline: f64, label: impl Into<String>) -> Self {
        self.comparison = Some(KpiComparison {
            baseline,
            label: label.into(),
        });
        self
    }

    /// Add sparkline trend data
    pub fn with_sparkline(mut self, sparkline: Vec<f64>) -> Self {
        self.sparkline = Some(sparkline);
        self
    }
}

/// Format a numeric value according to KpiFormat
fn format_value(value: f64, format: KpiFormat) -> String {
    match format {
        KpiFormat::Number => {
            // Format with thousands separators
            let abs_val = value.abs();
            if abs_val >= 1_000_000.0 {
                format!("{:.1}M", value / 1_000_000.0)
            } else if abs_val >= 1_000.0 {
                format!("{:.1}K", value / 1_000.0)
            } else {
                format!("{:.0}", value)
            }
        }
        KpiFormat::Currency => {
            let abs_val = value.abs();
            if abs_val >= 1_000_000.0 {
                format!("${:.1}M", value / 1_000_000.0)
            } else if abs_val >= 1_000.0 {
                format!("${:.1}K", value / 1_000.0)
            } else {
                format!("${:.0}", value)
            }
        }
        KpiFormat::Percentage => {
            format!("{:.1}%", value)
        }
    }
}

/// Generate SVG path for sparkline
fn generate_sparkline_path(data: &[f64], width: f64, height: f64) -> String {
    if data.is_empty() {
        return "M 0 0".to_string();
    }

    // Find min/max for scaling
    let min = data.iter().copied().fold(f64::INFINITY, f64::min);
    let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);

    // Create scales
    let x_scale = LinearScale::new((0.0, (data.len() - 1) as f64), (0.0, width));
    let y_scale = LinearScale::new((min, max), (height, 0.0)); // Inverted Y

    // Generate path
    let mut path = String::with_capacity(data.len() * 16);
    for (i, &value) in data.iter().enumerate() {
        let x = x_scale.map(i as f64);
        let y = y_scale.map(value);
        if i == 0 {
            path.push_str(&format!("M {x:.2} {y:.2}"));
        } else {
            path.push_str(&format!(" L {x:.2} {y:.2}"));
        }
    }
    path
}

/// KPI Card component
///
/// Displays a single-value metric with optional trend and comparison
///
/// # Features
/// - Formatted value display (number, currency, percentage)
/// - Comparison with baseline (green/red indicator)
/// - Optional sparkline mini-chart
/// - Responsive SVG rendering
///
/// # Example
/// ```rust,ignore
/// use lodviz_components::KpiCard;
/// use lodviz_components::KpiData;
/// use leptos::prelude::*;
///
/// let data = Signal::derive(|| {
///     KpiData::new("Revenue", 127543.0)
///         .with_format(KpiFormat::Currency)
///         .with_comparison(114320.0, "vs Last Month")
///         .with_sparkline(vec![100.0, 105.0, 110.0, 115.0, 127.0])
/// });
///
/// view! {
///     <KpiCard data=data />
/// }
/// ```
#[component]
pub fn KpiCard(
    /// KPI data (label, value, format, comparison, sparkline)
    data: Signal<KpiData>,
    /// Optional width (default: 300)
    #[prop(optional)]
    width: Option<u32>,
    /// Optional height (default: 200)
    #[prop(optional)]
    height: Option<u32>,
    /// Optional theme (default: ChartTheme::default())
    #[prop(optional)]
    theme: Option<Signal<ChartTheme>>,
) -> impl IntoView {
    let w = width.unwrap_or(300);
    let h = height.unwrap_or(200);
    let theme = theme.unwrap_or_else(|| Signal::derive(ChartTheme::default));

    view! {
        <svg
            viewBox=format!("0 0 {w} {h}")
            width=format!("{w}")
            height=format!("{h}")
            class="kpi-card"
            role="img"
            aria-label=move || format!("KPI: {}", data.get().label)
        >
            // Background rect
            <rect
                x="0"
                y="0"
                width=format!("{w}")
                height=format!("{h}")
                fill=move || theme.get().background_color.clone()
                rx="8"
            />

            // Title
            <text
                x=format!("{}", w / 2)
                y="30"
                text-anchor="middle"
                fill=move || theme.get().text_color.clone()
                font-size="14"
                font-family=move || theme.get().font_family.clone()
            >
                {move || data.get().label.clone()}
            </text>

            // Value
            <text
                x=format!("{}", w / 2)
                y="80"
                text-anchor="middle"
                fill=move || theme.get().text_color.clone()
                font-size="36"
                font-weight="bold"
                font-family=move || theme.get().font_family.clone()
            >
                {move || {
                    let d = data.get();
                    format_value(d.value, d.format)
                }}
            </text>

            // Comparison (if present)
            {move || {
                data.get()
                    .comparison
                    .as_ref()
                    .map(|comp| {
                        let d = data.get();
                        let change = d.value - comp.baseline;
                        let change_pct = (change / comp.baseline.abs()) * 100.0;
                        let is_positive = change >= 0.0;
                        let color = if is_positive { "#10b981" } else { "#ef4444" };
                        let arrow = if is_positive { "" } else { "" };
                        let sign = if is_positive { "+" } else { "" };

                        view! {
                            <text
                                x=format!("{}", w / 2)
                                y="110"
                                text-anchor="middle"
                                fill=color
                                font-size="14"
                                font-family=move || theme.get().font_family.clone()
                            >
                                {format!("{} {}{:.1}% {}", arrow, sign, change_pct, comp.label)}
                            </text>
                        }
                    })
            }}

            // Sparkline (if present)
            {move || {
                data.get()
                    .sparkline
                    .as_ref()
                    .map(|sparkline_data| {
                        let sparkline_height = 40.0;
                        let sparkline_width = (w as f64) * 0.8;
                        let sparkline_x = ((w as f64) - sparkline_width) / 2.0;
                        let sparkline_y = (h as f64) - sparkline_height - 20.0;
                        let path = generate_sparkline_path(
                            sparkline_data,
                            sparkline_width,
                            sparkline_height,
                        );

                        view! {
                            <g transform=format!("translate({sparkline_x:.2}, {sparkline_y:.2})")>
                                <path
                                    d=path
                                    fill="none"
                                    stroke=move || {
                                        theme
                                            .get()
                                            .palette
                                            .first()
                                            .cloned()
                                            .unwrap_or_else(|| "#3b82f6".to_string())
                                    }
                                    stroke-width="2"
                                    opacity="0.8"
                                />
                            </g>
                        }
                    })
            }}
        </svg>
    }
}