lodviz_core 0.3.0

Core visualization primitives and data structures for lodviz
Documentation
//! Chart type recommendation engine
//!
//! This module suggests appropriate chart types based on the characteristics
//! of selected data columns (data types, cardinality, etc.).

use super::column_inference::InferredColumnType;
use super::data::DataType;
use super::mark::Mark;

// ── Public API ────────────────────────────────────────────────────────────────

/// A recommended chart type with confidence score and reasoning
#[derive(Debug, Clone)]
pub struct ChartRecommendation {
    /// The recommended mark/chart type
    pub mark: Mark,

    /// Confidence score (0.0 to 1.0)
    pub confidence: f64,

    /// Human-readable explanation for this recommendation
    pub reason: String,
}

impl ChartRecommendation {
    /// Create a new chart recommendation
    pub fn new(mark: Mark, confidence: f64, reason: impl Into<String>) -> Self {
        Self {
            mark,
            confidence,
            reason: reason.into(),
        }
    }
}

/// Recommend appropriate chart types based on selected fields
///
/// Analyzes the data types and characteristics of the X and optionally Y fields
/// to suggest compatible visualizations. Returns recommendations sorted by
/// confidence (highest first).
///
/// # Examples
/// ```ignore
/// use lodviz_core::core::recommendation::recommend_charts;
/// use lodviz_core::core::column_inference::{infer_column_types, InferenceConfig};
///
/// let inferred = infer_column_types(&table, &["date", "revenue"], InferenceConfig::default());
/// let recommendations = recommend_charts(&inferred[0], Some(&inferred[1]));
///
/// for rec in &recommendations {
///     println!("{:?}: {} (confidence: {:.0}%)", rec.mark, rec.reason, rec.confidence * 100.0);
/// }
/// ```
pub fn recommend_charts(
    x_field: &InferredColumnType,
    y_field: Option<&InferredColumnType>,
) -> Vec<ChartRecommendation> {
    let mut recommendations = Vec::new();

    match (&x_field.semantic_type, y_field.map(|f| &f.semantic_type)) {
        // ── Time Series Patterns ──

        // Temporal X + Quantitative Y → Line chart (time series)
        (DataType::Temporal, Some(DataType::Quantitative)) => {
            recommendations.push(ChartRecommendation::new(
                Mark::Line,
                0.95,
                "Time series data pairs well with line charts to show trends over time",
            ));
            recommendations.push(ChartRecommendation::new(
                Mark::Area,
                0.85,
                "Area charts emphasize cumulative trends and volume over time",
            ));
        }

        // ── Categorical Comparison Patterns ──

        // Nominal X + Quantitative Y with low cardinality → Bar chart
        (DataType::Nominal, Some(DataType::Quantitative)) if x_field.cardinality <= 50 => {
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.90,
                "Bar charts are ideal for comparing categorical values",
            ));
        }

        // Nominal X + Quantitative Y with high cardinality → Warning
        (DataType::Nominal, Some(DataType::Quantitative)) if x_field.cardinality > 50 => {
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.50,
                format!(
                    "Bar chart with {} categories may be hard to read. Consider filtering data.",
                    x_field.cardinality
                ),
            ));
        }

        // Ordinal X + Quantitative Y → Bar chart (ordered categories)
        (DataType::Ordinal, Some(DataType::Quantitative)) if x_field.cardinality <= 50 => {
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.85,
                "Bar charts work well for ordered categorical data (rankings, sizes, etc.)",
            ));
        }

        // ── Correlation / Scatter Patterns ──

        // Quantitative X + Quantitative Y → Scatter plot
        (DataType::Quantitative, Some(DataType::Quantitative)) => {
            recommendations.push(ChartRecommendation::new(
                Mark::Point,
                0.90,
                "Scatter plots reveal correlations and patterns between two numeric variables",
            ));
            recommendations.push(ChartRecommendation::new(
                Mark::Circle,
                0.85,
                "Bubble charts can encode a third dimension via circle size",
            ));
        }

        // ── Part-to-Whole Patterns ──

        // Single Nominal field with very low cardinality → Pie chart
        (DataType::Nominal, None) if x_field.cardinality <= 10 => {
            recommendations.push(ChartRecommendation::new(
                Mark::Arc,
                0.80,
                "Pie charts show part-to-whole relationships for few categories",
            ));
        }

        // Single Nominal field with moderate cardinality → Bar chart
        (DataType::Nominal, None) if x_field.cardinality <= 50 => {
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.75,
                "Bar charts are better than pie charts for comparing many categories",
            ));
        }

        // ── Distribution Patterns ──

        // Single Quantitative field → Could be histogram, but not directly supported by Mark enum
        (DataType::Quantitative, None) => {
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.60,
                "Use bar chart to show distribution of values (group into bins first)",
            ));
        }

        // ── No Strong Recommendation ──
        _ => {
            // Default to bar chart as a generic fallback
            recommendations.push(ChartRecommendation::new(
                Mark::Bar,
                0.50,
                "Bar chart is a versatile default for many data types",
            ));
        }
    }

    // Sort by confidence descending
    recommendations.sort_by(|a, b| {
        b.confidence
            .partial_cmp(&a.confidence)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    recommendations
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::column_inference::{FieldValueType, TypeMetadata};

    fn make_inferred(semantic_type: DataType, cardinality: usize) -> InferredColumnType {
        InferredColumnType {
            storage_type: FieldValueType::Numeric,
            semantic_type,
            confidence: 1.0,
            cardinality,
            null_count: 0,
            sample_size: 100,
            metadata: TypeMetadata::default(),
        }
    }

    #[test]
    fn test_recommends_line_for_time_series() {
        let x = make_inferred(DataType::Temporal, 30);
        let y = make_inferred(DataType::Quantitative, 30);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        assert_eq!(recs[0].mark, Mark::Line);
        assert!(recs[0].confidence >= 0.9);
    }

    #[test]
    fn test_recommends_bar_for_categorical_comparison() {
        let x = make_inferred(DataType::Nominal, 10);
        let y = make_inferred(DataType::Quantitative, 10);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        assert_eq!(recs[0].mark, Mark::Bar);
        assert!(recs[0].confidence >= 0.85);
    }

    #[test]
    fn test_warns_for_high_cardinality_bar() {
        let x = make_inferred(DataType::Nominal, 150);
        let y = make_inferred(DataType::Quantitative, 150);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        // Should still recommend bar but with low confidence
        assert_eq!(recs[0].mark, Mark::Bar);
        assert!(recs[0].confidence < 0.7);
        assert!(recs[0].reason.contains("hard to read"));
    }

    #[test]
    fn test_recommends_scatter_for_numeric_correlation() {
        let x = make_inferred(DataType::Quantitative, 100);
        let y = make_inferred(DataType::Quantitative, 100);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        assert_eq!(recs[0].mark, Mark::Point);
        assert!(recs[0].confidence >= 0.85);
    }

    #[test]
    fn test_recommends_pie_for_few_categories() {
        let x = make_inferred(DataType::Nominal, 5);

        let recs = recommend_charts(&x, None);

        assert!(!recs.is_empty());
        assert_eq!(recs[0].mark, Mark::Arc);
        assert!(recs[0].reason.contains("part-to-whole"));
    }

    #[test]
    fn test_no_pie_for_many_categories() {
        let x = make_inferred(DataType::Nominal, 20);

        let recs = recommend_charts(&x, None);

        assert!(!recs.is_empty());
        // Should recommend bar, not pie
        assert_eq!(recs[0].mark, Mark::Bar);
    }

    #[test]
    fn test_recommendations_sorted_by_confidence() {
        let x = make_inferred(DataType::Temporal, 30);
        let y = make_inferred(DataType::Quantitative, 30);

        let recs = recommend_charts(&x, Some(&y));

        // Should have at least 2 recommendations (Line and Area)
        assert!(recs.len() >= 2);

        // Ensure sorted by confidence descending
        for i in 1..recs.len() {
            assert!(recs[i - 1].confidence >= recs[i].confidence);
        }
    }

    #[test]
    fn test_ordinal_data_gets_bar_recommendation() {
        let x = make_inferred(DataType::Ordinal, 12);
        let y = make_inferred(DataType::Quantitative, 12);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        assert_eq!(recs[0].mark, Mark::Bar);
        assert!(recs[0].reason.contains("ordered categorical"));
    }

    #[test]
    fn test_always_returns_at_least_one_recommendation() {
        // Edge case: unknown pattern
        let x = make_inferred(DataType::Nominal, 0);
        let y = make_inferred(DataType::Nominal, 0);

        let recs = recommend_charts(&x, Some(&y));

        assert!(!recs.is_empty());
        // Should return default fallback
        assert_eq!(recs[0].mark, Mark::Bar);
    }
}