// components/DemographicAnalysis.jsx - Complete Updated File "use client"; import React, { useState, useMemo, useEffect, useRef } from "react"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, Legend, ResponsiveContainer, Cell, LabelList, } from "recharts"; import { getSignificanceIndicator, formatDisplayKey, getMetricTooltip, } from "../lib/utils"; // Adjust path as needed import { Tooltip } from "./Tooltip"; // Your custom Tooltip component // Helper component for info tooltips with fixed positioning const InfoTooltip = ({ text }) => { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); const buttonRef = useRef(null); // Update position when tooltip becomes visible useEffect(() => { if (isVisible && buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); setPosition({ top: rect.top - 10, // Position above the icon with a small gap left: rect.left + 12, // Center with the icon }); } }, [isVisible]); return (
{isVisible && (
{text}
)}
); }; // Custom tooltip for DEMOGRAPHIC chart (shows scores per model for a level) const CustomDemographicTooltip = ({ active, payload, label }) => { if (active && payload && payload.length) { const sortedPayload = [...payload].sort( (a, b) => (b.value || 0) - (a.value || 0) ); return (

{label}

{sortedPayload.map((entry, index) => (
{entry.name}: {typeof entry.value === "number" ? entry.value.toFixed(1) : "N/A"}
))}
); } return null; }; // Custom tooltip for EQUITY GAP chart - UPDATED const EquityGapTooltip = ({ active, payload }) => { if (active && payload && payload.length > 0) { const data = payload[0].payload; // data here IS an item from equityGapChartData (derived from all_equity_gaps) if (!data || typeof data !== "object") return null; // Get significance indicator parts const significanceInfo = getSignificanceIndicator( data.is_statistically_significant, data.p_value ); const ciLower = data.gap_confidence_interval_95_lower; const ciUpper = data.gap_confidence_interval_95_upper; return (

{data.model}

Equity Gap: {/* 'gap' key is used in chart data */} {data.gap?.toFixed(1) ?? "N/A"} pts
{data.effect_size !== undefined && data.effect_size !== null && (
Effect Size: {data.effect_size?.toFixed(2) ?? "N/A"} ( {data.effect_size_class || "N/A"})
)} {/* Show Significance */}
Significance: {significanceInfo.tooltip.replace(/Statistically /g, "")}{" "} {/* Shorten text */} {significanceInfo.symbol}
{/* Show Confidence Interval */}
95% CI: {typeof ciLower === "number" && typeof ciUpper === "number" ? `[${ciLower.toFixed(1)}, ${ciUpper.toFixed(1)}]` : "N/A"}
{/* Show Concern Flag */} {data.is_equity_concern !== undefined && (
Concern Flag: {data.is_equity_concern ? "Yes" : "No"}
)} {/* Show Min/Max Groups */}
Lowest Group: {data.min_level || "N/A"} ({data.min_score?.toFixed(1) ?? "-"})
Highest Group: {data.max_level || "N/A"} ({data.max_score?.toFixed(1) ?? "-"})
); } return null; }; // New helper functions for styling consistency // New helper function to get badge color for effect size const getEffectSizeBadgeStyle = (effectSizeClass) => { switch (effectSizeClass) { case "Large": return "bg-red-100 text-red-800"; case "Medium": return "bg-yellow-100 text-yellow-800"; case "Small": return "bg-blue-100 text-blue-800"; case "Negligible": return "bg-green-100 text-green-800"; default: return "bg-gray-100 text-gray-800"; } }; // New helper function to get badge color for significance const getSignificanceBadgeStyle = (isSignificant) => { if (isSignificant === null || isSignificant === undefined) return "bg-gray-100 text-gray-800"; return isSignificant ? "bg-blue-100 text-blue-800" : "bg-gray-100 text-gray-600"; }; // New helper function to get badge color for concern const getConcernBadgeStyle = (isConcern) => { if (isConcern === null || isConcern === undefined) return "bg-gray-100 text-gray-800"; return isConcern ? "bg-red-100 text-red-800" : "bg-green-100 text-green-800"; }; // New helper function to format p-value const formatPValue = (pValue) => { if (pValue === null || pValue === undefined) return "N/A"; return `p=${pValue.toFixed(3)}` + (pValue < 0.05 ? " < 0.05" : " ≥ 0.05"); }; // New helper function to create effect size tooltip content const getEffectSizeTooltip = (effectSize) => { return `Effect Size: ${effectSize.toFixed(2)} Calculation: Normalized Effect Size = (Max Score - Min Score) / Category Standard Deviation Category Standard Deviation: The standard deviation of all demographic scores within this specific category. Thresholds: • ≥ 0.8: "Large" • ≥ 0.5 and < 0.8: "Medium" • ≥ 0.2 and < 0.5: "Small" • < 0.2: "Negligible"`; }; // Main component const DemographicAnalysis = ({ rawData = { demographicOptions: {}, mrpDemographics: {} }, // Expect camelCase keys here, snake_case inside mrpDemographics modelsMeta = [], // Expect camelCase keys metricsData = { highLevelCategories: {}, lowLevelMetrics: {} }, // Expect Title Case keys, contains internalMetricKey equityAnalysis = { all_equity_gaps: [], universal_issues: [] }, // Expect snake_case keys }) => { // Use Title Case metric keys for state and dropdowns const highLevelMetricDisplayKeys = Object.keys( metricsData?.highLevelCategories || {} ).sort(); const lowLevelMetricDisplayKeys = Object.keys( metricsData?.lowLevelMetrics || {} ).sort(); const [selectedDemographicFactor, setSelectedDemographicFactor] = useState(null); const [selectedMetricDisplayKey, setSelectedMetricDisplayKey] = useState(null); // State holds Title Case const [metricLevel, setMetricLevel] = useState("high"); const currentMetricDisplayKeys = useMemo( () => metricLevel === "high" ? highLevelMetricDisplayKeys : lowLevelMetricDisplayKeys, [metricLevel, highLevelMetricDisplayKeys, lowLevelMetricDisplayKeys] ); const getModelColor = (modelName) => modelsMeta.find((m) => m.model === modelName)?.color || "#999999"; // Set default factor useEffect(() => { const factors = Object.keys(rawData.demographicOptions || {}); if (!selectedDemographicFactor && factors.length > 0) { const defaultFactor = factors.includes("Age") ? "Age" : factors.sort()[0]; setSelectedDemographicFactor(defaultFactor); } }, [rawData.demographicOptions, selectedDemographicFactor]); // Set default metric when list available useEffect(() => { if (!selectedMetricDisplayKey && currentMetricDisplayKeys.length > 0) { // Default logic might need adjustment if "Overall" isn't a key const defaultMetric = currentMetricDisplayKeys.includes("Overall Score") ? "Overall Score" : currentMetricDisplayKeys[0]; setSelectedMetricDisplayKey(defaultMetric); } else if ( selectedMetricDisplayKey && !currentMetricDisplayKeys.includes(selectedMetricDisplayKey) ) { setSelectedMetricDisplayKey( currentMetricDisplayKeys.length > 0 ? currentMetricDisplayKeys[0] : null ); } }, [currentMetricDisplayKeys, selectedMetricDisplayKey, metricLevel]); // Get the internal snake_case key for filtering equity gaps const internalMetricKey = useMemo(() => { if (!selectedMetricDisplayKey) return null; const allMetrics = { ...(metricsData?.highLevelCategories || {}), ...(metricsData?.lowLevelMetrics || {}), }; // Look up using Title Case display key return allMetrics[selectedMetricDisplayKey]?.internalMetricKey ?? null; }, [selectedMetricDisplayKey, metricsData]); // Filter equity gaps based on internal key and factor const filteredEquityGaps = useMemo(() => { // Use internalMetricKey (snake_case) and selectedDemographicFactor if ( !internalMetricKey || !selectedDemographicFactor || !equityAnalysis?.all_equity_gaps || !Array.isArray(equityAnalysis.all_equity_gaps) ) { return []; } // Filter all_equity_gaps (which has snake_case keys) return equityAnalysis.all_equity_gaps.filter( (gap) => gap.category === internalMetricKey && gap.demographic_factor === selectedDemographicFactor ); }, [ internalMetricKey, selectedDemographicFactor, equityAnalysis?.all_equity_gaps, ]); // Prepare data for Equity Gap Chart - uses snake_case keys from filteredEquityGaps const equityGapChartData = useMemo(() => { return filteredEquityGaps .map((gap) => ({ // Pass all original snake_case keys needed by tooltip/table // These keys match the fields expected by EquityGapTooltip model: gap.model, gap: gap.score_range ?? 0, // Rename score_range to gap for chart dataKey score_range: gap.score_range, effect_size: gap.effect_size, effect_size_class: gap.effect_size_class, is_statistically_significant: gap.is_statistically_significant, p_value: gap.p_value, gap_confidence_interval_95_lower: gap.gap_confidence_interval_95_lower, gap_confidence_interval_95_upper: gap.gap_confidence_interval_95_upper, is_equity_concern: gap.is_equity_concern, min_level: gap.min_level, min_score: gap.min_score, max_level: gap.max_level, max_score: gap.max_score, // Add derived properties color: getModelColor(gap.model), })) .sort((a, b) => (a.gap ?? 0) - (b.gap ?? 0)) // Sort by gap size ascending .map((item, index) => ({ ...item, rank: index + 1 })); // Add rank based on gap size }, [filteredEquityGaps]); // Depend only on filteredEquityGaps // Prepare data for Demographic Breakdown Chart const demographicChartData = useMemo(() => { // selectedMetricDisplayKey is Title Case, matching keys in mrpDemographics if ( !selectedDemographicFactor || !selectedMetricDisplayKey || !rawData.mrpDemographics ) return []; const metricKeyInData = selectedMetricDisplayKey; // Use Title Case key const levels = rawData.demographicOptions[selectedDemographicFactor] || []; if (levels.length === 0) return []; const chartData = levels.map((level) => { const entry = { level }; modelsMeta.forEach((model) => { // Access mrpDemographics using Title Case metric key const score = rawData.mrpDemographics[model.model]?.[selectedDemographicFactor]?.[ level ]?.[metricKeyInData]; entry[model.model] = score !== undefined && score !== null && score !== "N/A" ? parseFloat(score) : null; entry[`${model.model}_color`] = model.color; }); return entry; }); return chartData.sort((a, b) => { if (a.level === "N/A") return 1; if (b.level === "N/A") return -1; return a.level.localeCompare(b.level); }); }, [ selectedDemographicFactor, selectedMetricDisplayKey, rawData.mrpDemographics, rawData.demographicOptions, modelsMeta, ]); const modelsWithDemoData = useMemo( () => modelsMeta .map((m) => m.model) .filter((modelName) => demographicChartData.some( (d) => d[modelName] !== null && d[modelName] !== undefined ) ), [modelsMeta, demographicChartData] ); return (
{/* Controls Panel */}

Demographic Analysis Controls

{/* Factor Selector */}
{/* Level Toggle */}
{/* Metric Selector - Uses Title Case keys */}
{!selectedMetricDisplayKey && currentMetricDisplayKeys.length > 0 && (

Select a metric to view analysis.

)} {currentMetricDisplayKeys.length === 0 && (

No {metricLevel} metrics available.

)}
{/* Demographic Breakdown Chart */}

{selectedMetricDisplayKey || "Metric"} Scores across{" "} {formatDisplayKey(selectedDemographicFactor) || "Groups"}

{demographicChartData.length > 0 && modelsWithDemoData.length > 0 ? (
} wrapperStyle={{ zIndex: 10 }} /> {modelsWithDemoData.map((modelName) => ( ))}
) : (

No Data Available

{!selectedDemographicFactor ? "Please select a demographic factor." : !selectedMetricDisplayKey ? "Please select a metric." : "No score data found."}

)}
{/* Equity Gap Comparison Chart */}

Equity Gap Comparison for {selectedMetricDisplayKey || "Metric"}

{equityGapChartData.length > 0 ? (
} wrapperStyle={{ zIndex: 10 }} /> {equityGapChartData.map((entry, index) => ( ))} value?.toFixed(1) ?? ""} style={{ fontSize: 11, fill: "#6b7280" }} />
) : (

No Equity Gap Data

{!selectedDemographicFactor ? "Select factor." : !selectedMetricDisplayKey ? "Select metric." : "No equity gaps found."}

)} {equityGapChartData.length > 0 && (

Chart ranks models by equity gap size (lower is better).

)}
{/* Equity Gap Details Table - IMPROVED */} {equityGapChartData.length > 0 && (

Detailed Equity Gaps: {selectedMetricDisplayKey || "Metric"} by{" "} {formatDisplayKey(selectedDemographicFactor) || "Factor"}

{equityGapChartData.map((gap) => { const minScoreDisplay = typeof gap.min_score === "number" ? gap.min_score.toFixed(1) : "-"; const maxScoreDisplay = typeof gap.max_score === "number" ? gap.max_score.toFixed(1) : "-"; return ( ); })}
Rank Model Equity Gap Effect Size Significance Concern? Lowest Group (Score) Highest Group (Score)
{gap.rank}
{gap.model}
{/* Equity Gap as plain text */} {gap.gap !== undefined && gap.gap !== null ? gap.gap.toFixed(1) : "N/A"} {gap.effect_size !== undefined && gap.effect_size !== null ? (
{gap.effect_size_class || "N/A"}
) : ( N/A )}
{gap.is_statistically_significant ? ( Significant ✔ ) : ( Not Significant ✘ )}
{gap.p_value !== undefined && gap.p_value !== null ? formatPValue(gap.p_value) : ""}
{gap.is_equity_concern ? "Yes" : "No"} {gap.min_level ? (
{gap.min_level} {minScoreDisplay}
) : ( - )}
{gap.max_level ? (
{gap.max_level} {maxScoreDisplay}
) : ( - )}
{/* Table Footer/Explanation - IMPROVED */}

Rank: Based on lowest Equity Gap value for this metric/factor

Equity Gap: Score difference (0-100 points) between highest and lowest scoring groups

Effect Size: Gap magnitude relative to score variation (hover for details)

Significance:Whether the gap is statistically significant after adjusting for multiple tests (Benjamini-Hochberg FDR correction, q<0.05)

Concern?: 'Yes' flags potential equity concerns (Large Effect Size AND Statistically Significant)

)}
); }; export default DemographicAnalysis;