lodviz_core 0.3.0

Core visualization primitives and data structures for lodviz
Documentation
//! Cardinality constraints for chart validation
//!
//! This module defines maximum cardinality thresholds for different
//! chart types and axes, helping prevent performance issues and
//! poor UX from too many categories or data points.

use super::mark::Mark;

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

/// Severity level for validation issues
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
    /// No issues detected
    Ok,
    /// Non-blocking warning (chart can still render)
    Warning,
    /// Critical error (chart should not render)
    Error,
}

/// Cardinality constraint for a specific chart type and axis
#[derive(Debug, Clone)]
pub struct CardinalityConstraint {
    /// Maximum recommended cardinality (triggers warning)
    pub max_recommended: usize,

    /// Maximum allowed cardinality (triggers error)
    pub max_allowed: usize,

    /// User-facing message explaining the constraint
    pub warning_message: String,
}

impl Default for CardinalityConstraint {
    /// Default constraint with no limits (unlimited cardinality)
    fn default() -> Self {
        Self {
            max_recommended: usize::MAX,
            max_allowed: usize::MAX,
            warning_message: String::new(),
        }
    }
}

impl CardinalityConstraint {
    /// Get cardinality constraint for a specific chart type and axis
    ///
    /// # Examples
    /// ```
    /// use lodviz_core::core::mark::Mark;
    /// use lodviz_core::core::cardinality::CardinalityConstraint;
    ///
    /// let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
    /// assert_eq!(constraint.max_recommended, 50);
    /// assert_eq!(constraint.max_allowed, 200);
    /// ```
    pub fn for_chart(mark: Mark, axis: &str) -> Self {
        match (mark, axis) {
            // Bar charts: too many bars become unreadable
            (Mark::Bar, "X") => Self {
                max_recommended: 50,
                max_allowed: 200,
                warning_message: "Bar charts with >50 categories become hard to read. Consider filtering or grouping categories.".into(),
            },

            // Pie/Arc charts: too many slices are difficult to compare
            (Mark::Arc, "X") => Self {
                max_recommended: 10,
                max_allowed: 30,
                warning_message: "Pie charts with >10 slices are difficult to compare. Consider grouping smaller categories into 'Other'.".into(),
            },

            // Scatter plots: color channel with too many categories loses distinguishability
            (Mark::Point | Mark::Circle, "Color") => Self {
                max_recommended: 20,
                max_allowed: 100,
                warning_message: "Too many colors reduce distinguishability. Consider grouping categories or using a different visual encoding.".into(),
            },

            // Line/Area charts: color channel for series
            (Mark::Line | Mark::Area, "Color") => Self {
                max_recommended: 10,
                max_allowed: 50,
                warning_message: "Too many series make the chart cluttered. Consider filtering or faceting.".into(),
            },

            // Default: no specific constraint
            _ => Self::default(),
        }
    }

    /// Validate cardinality against this constraint
    ///
    /// Returns the severity level based on the cardinality value:
    /// - `Ok` if within recommended limit
    /// - `Warning` if above recommended but below hard limit
    /// - `Error` if above hard limit
    ///
    /// # Examples
    /// ```
    /// use lodviz_core::core::mark::Mark;
    /// use lodviz_core::core::cardinality::{CardinalityConstraint, ValidationSeverity};
    ///
    /// let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
    ///
    /// assert_eq!(constraint.validate(30), ValidationSeverity::Ok);
    /// assert_eq!(constraint.validate(75), ValidationSeverity::Warning);
    /// assert_eq!(constraint.validate(250), ValidationSeverity::Error);
    /// ```
    pub fn validate(&self, cardinality: usize) -> ValidationSeverity {
        if cardinality > self.max_allowed {
            ValidationSeverity::Error
        } else if cardinality > self.max_recommended {
            ValidationSeverity::Warning
        } else {
            ValidationSeverity::Ok
        }
    }

    /// Check if cardinality is within recommended limits
    pub fn is_ok(&self, cardinality: usize) -> bool {
        cardinality <= self.max_recommended
    }

    /// Check if cardinality triggers a warning
    pub fn is_warning(&self, cardinality: usize) -> bool {
        cardinality > self.max_recommended && cardinality <= self.max_allowed
    }

    /// Check if cardinality triggers an error
    pub fn is_error(&self, cardinality: usize) -> bool {
        cardinality > self.max_allowed
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bar_chart_x_axis_thresholds() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
        assert_eq!(constraint.max_recommended, 50);
        assert_eq!(constraint.max_allowed, 200);
        assert!(!constraint.warning_message.is_empty());
    }

    #[test]
    fn test_pie_chart_x_axis_thresholds() {
        let constraint = CardinalityConstraint::for_chart(Mark::Arc, "X");
        assert_eq!(constraint.max_recommended, 10);
        assert_eq!(constraint.max_allowed, 30);
    }

    #[test]
    fn test_validate_ok() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
        assert_eq!(constraint.validate(30), ValidationSeverity::Ok);
        assert_eq!(constraint.validate(50), ValidationSeverity::Ok);
        assert!(constraint.is_ok(30));
    }

    #[test]
    fn test_validate_warning() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
        assert_eq!(constraint.validate(51), ValidationSeverity::Warning);
        assert_eq!(constraint.validate(100), ValidationSeverity::Warning);
        assert_eq!(constraint.validate(200), ValidationSeverity::Warning);
        assert!(constraint.is_warning(75));
    }

    #[test]
    fn test_validate_error() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");
        assert_eq!(constraint.validate(201), ValidationSeverity::Error);
        assert_eq!(constraint.validate(1000), ValidationSeverity::Error);
        assert!(constraint.is_error(250));
    }

    #[test]
    fn test_pie_chart_warns_at_11_slices() {
        let constraint = CardinalityConstraint::for_chart(Mark::Arc, "X");
        assert_eq!(constraint.validate(10), ValidationSeverity::Ok);
        assert_eq!(constraint.validate(11), ValidationSeverity::Warning);
    }

    #[test]
    fn test_pie_chart_errors_at_31_slices() {
        let constraint = CardinalityConstraint::for_chart(Mark::Arc, "X");
        assert_eq!(constraint.validate(30), ValidationSeverity::Warning);
        assert_eq!(constraint.validate(31), ValidationSeverity::Error);
    }

    #[test]
    fn test_scatter_color_channel_constraint() {
        let constraint = CardinalityConstraint::for_chart(Mark::Point, "Color");
        assert_eq!(constraint.max_recommended, 20);
        assert_eq!(constraint.max_allowed, 100);
    }

    #[test]
    fn test_line_color_channel_constraint() {
        let constraint = CardinalityConstraint::for_chart(Mark::Line, "Color");
        assert_eq!(constraint.max_recommended, 10);
        assert_eq!(constraint.max_allowed, 50);
    }

    #[test]
    fn test_default_constraint_no_limits() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "Y");
        assert_eq!(constraint.max_recommended, usize::MAX);
        assert_eq!(constraint.max_allowed, usize::MAX);
        assert_eq!(constraint.validate(1_000_000), ValidationSeverity::Ok);
    }

    #[test]
    fn test_circle_same_as_point() {
        let point_constraint = CardinalityConstraint::for_chart(Mark::Point, "Color");
        let circle_constraint = CardinalityConstraint::for_chart(Mark::Circle, "Color");
        assert_eq!(
            point_constraint.max_recommended,
            circle_constraint.max_recommended
        );
        assert_eq!(point_constraint.max_allowed, circle_constraint.max_allowed);
    }

    #[test]
    fn test_area_similar_to_line() {
        let line_constraint = CardinalityConstraint::for_chart(Mark::Line, "Color");
        let area_constraint = CardinalityConstraint::for_chart(Mark::Area, "Color");
        assert_eq!(
            line_constraint.max_recommended,
            area_constraint.max_recommended
        );
    }

    #[test]
    fn test_boundary_cases() {
        let constraint = CardinalityConstraint::for_chart(Mark::Bar, "X");

        // Exact boundary values
        assert_eq!(constraint.validate(0), ValidationSeverity::Ok);
        assert_eq!(constraint.validate(50), ValidationSeverity::Ok);
        assert_eq!(constraint.validate(51), ValidationSeverity::Warning);
        assert_eq!(constraint.validate(200), ValidationSeverity::Warning);
        assert_eq!(constraint.validate(201), ValidationSeverity::Error);
    }
}