lodviz_components 0.3.0

Components for data visualization using lodviz_core
Documentation
/// EmptyStateMessage component for displaying data validation errors
use leptos::prelude::*;
use lodviz_core::core::theme::ChartTheme;

/// Props for displaying an empty state message in charts
#[derive(Clone)]
pub struct EmptyStateProps {
    /// Main title of the error
    pub title: String,
    /// Detailed error message or instructions
    pub message: String,
    /// Optional suggestion for fixing the issue
    pub suggestion: Option<String>,
}

impl EmptyStateProps {
    /// Create a new empty state for missing data
    pub fn no_data() -> Self {
        Self {
            title: "No Data Available".to_string(),
            message: "The dataset is empty or contains no valid data points.".to_string(),
            suggestion: Some("Please check your data source and column mappings.".to_string()),
        }
    }

    /// Create an empty state for missing columns
    pub fn missing_columns(columns: Vec<String>) -> Self {
        Self {
            title: "Missing Columns".to_string(),
            message: format!("Required columns not found: {}", columns.join(", ")),
            suggestion: Some("Please verify that your dataset contains these columns.".to_string()),
        }
    }

    /// Create an empty state for type mismatch
    pub fn type_mismatch(column: String, expected: String) -> Self {
        Self {
            title: "Incompatible Data Type".to_string(),
            message: format!(
                "Column '{}' contains non-numeric data, but {} type is required.",
                column, expected
            ),
            suggestion: Some("Please select a column with numeric or date values.".to_string()),
        }
    }

    /// Create an empty state for insufficient numeric data
    pub fn insufficient_numeric_data(column: String, percent: f64) -> Self {
        Self {
            title: "Insufficient Numeric Data".to_string(),
            message: format!(
                "Column '{}' contains less than {}% numeric values.",
                column,
                percent.round()
            ),
            suggestion: Some("Please clean your data or select a different column.".to_string()),
        }
    }

    /// Create an empty state for role mismatch (Dimension vs Measure)
    pub fn role_mismatch(column: String, axis: String, expected_role: String) -> Self {
        let role_description = match expected_role.as_str() {
            "Dimension" => "categorical (text/date)",
            "Measure" => "numeric",
            _ => "compatible",
        };

        Self {
            title: "Column Incompatibility".to_string(),
            message: format!(
                "The {} axis expects a {} column, but '{}' has the wrong data role.",
                axis, expected_role, column
            ),
            suggestion: Some(format!(
                "Try using a {} column instead, or swap the X and Y axes.",
                role_description
            )),
        }
    }

    /// Create an empty state for high cardinality
    pub fn high_cardinality(
        column: String,
        cardinality: usize,
        threshold: usize,
        chart_type: String,
    ) -> Self {
        Self {
            title: "Too Many Categories".to_string(),
            message: format!(
                "Column '{}' has {} unique values (limit: {} for {} charts).",
                column, cardinality, threshold, chart_type
            ),
            suggestion: Some(
                "Consider filtering your data, grouping smaller categories, or using a different chart type.".to_string()
            ),
        }
    }

    /// Create an empty state for mark incompatibility
    pub fn mark_incompatible(
        mark: String,
        axis: String,
        required_types: String,
        column: String,
    ) -> Self {
        Self {
            title: format!("{} Chart Incompatibility", mark),
            message: format!(
                "The {} axis requires {} data, but column '{}' has an incompatible type.",
                axis, required_types, column
            ),
            suggestion: Some(format!(
                "Try selecting a different chart type or changing the column mapping for the {} axis.",
                axis
            )),
        }
    }

    /// Create a generic error state from any error message
    pub fn generic_error(error: &str) -> Self {
        Self {
            title: "Validation Error".to_string(),
            message: error.to_string(),
            suggestion: Some("Please check your data and chart configuration.".to_string()),
        }
    }
}

/// EmptyStateMessage component
///
/// Displays a centered error message with optional suggestion
/// when chart rendering fails due to data validation issues.
#[component]
pub fn EmptyStateMessage(
    /// The empty state configuration
    props: EmptyStateProps,
    /// Chart theme for styling
    #[prop(optional, into)]
    theme: Signal<ChartTheme>,
) -> impl IntoView {
    view! {
        <div
            class="empty-state-message"
            style=move || {
                let th = theme.get();
                format!(
                    "display: flex; flex-direction: column; align-items: center; justify-content: center; \
                    height: 100%; padding: 40px; text-align: center; color: {}; font-family: {}; background-color: {};",
                    th.text_color,
                    th.font_family,
                    th.background_color,
                )
            }
        >
            <svg
                width="64"
                height="64"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                style="opacity: 0.5; margin-bottom: 16px;"
            >
                <circle cx="12" cy="12" r="10"></circle>
                <line x1="12" y1="8" x2="12" y2="12"></line>
                <line x1="12" y1="16" x2="12.01" y2="16"></line>
            </svg>

            <h3 style=move || {
                let th = theme.get();
                format!(
                    "margin: 0 0 8px 0; font-size: {}px; font-weight: {}; color: {};",
                    th.title_font_size,
                    th.title_font_weight,
                    th.text_color,
                )
            }>{props.title.clone()}</h3>

            <p style=move || {
                let th = theme.get();
                format!(
                    "margin: 0 0 16px 0; font-size: {}px; color: {}; max-width: 400px; line-height: 1.5;",
                    th.axis_font_size,
                    th.text_color,
                )
            }>{props.message.clone()}</p>

            {move || {
                props
                    .suggestion
                    .clone()
                    .map(|suggestion| {
                        view! {
                            <p style=move || {
                                let th = theme.get();
                                format!(
                                    "margin: 0; font-size: {}px; color: {}; opacity: 0.7; font-style: italic; max-width: 400px;",
                                    th.font_size,
                                    th.text_color,
                                )
                            }>{suggestion}</p>
                        }
                    })
            }}
        </div>
    }
}

/// ValidationWarning component
///
/// Displays a non-blocking warning banner above the chart
/// for issues that don't prevent rendering but should be addressed.
#[component]
pub fn ValidationWarning(
    /// List of warning messages to display
    warnings: Vec<String>,
    /// Chart theme for styling
    #[prop(optional, into)]
    theme: Signal<ChartTheme>,
) -> impl IntoView {
    // Don't render anything if there are no warnings
    if warnings.is_empty() {
        return ().into_any();
    }

    view! {
        <div
            class="validation-warning-banner"
            style=move || {
                let th = theme.get();
                format!(
                    "display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; \
                    margin-bottom: 16px; border-radius: 6px; \
                    background-color: rgba(255, 193, 7, 0.1); \
                    border-left: 4px solid #ffc107; \
                    color: {}; font-family: {}; font-size: 13px;",
                    th.text_color,
                    th.font_family,
                )
            }
        >
            <svg
                width="20"
                height="20"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                style="flex-shrink: 0; color: #ffc107; margin-top: 2px;"
            >
                <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
                <line x1="12" y1="9" x2="12" y2="13"></line>
                <line x1="12" y1="17" x2="12.01" y2="17"></line>
            </svg>

            <div style="flex: 1;">
                <div style="font-weight: 600; margin-bottom: 4px; color: #f57c00;">"Warning"</div>
                <ul style="margin: 0; padding-left: 20px; list-style-type: disc;">
                    {warnings
                        .into_iter()
                        .map(|warning| {
                            view! { <li style="margin: 4px 0;">{warning}</li> }
                        })
                        .collect_view()}
                </ul>
            </div>
        </div>
    }
        .into_any()
}