|
|
|
|
|
"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"; |
|
import { Tooltip } from "./Tooltip"; |
|
|
|
|
|
const InfoTooltip = ({ text }) => { |
|
const [isVisible, setIsVisible] = useState(false); |
|
const [position, setPosition] = useState({ top: 0, left: 0 }); |
|
const buttonRef = useRef(null); |
|
|
|
|
|
useEffect(() => { |
|
if (isVisible && buttonRef.current) { |
|
const rect = buttonRef.current.getBoundingClientRect(); |
|
setPosition({ |
|
top: rect.top - 10, |
|
left: rect.left + 12, |
|
}); |
|
} |
|
}, [isVisible]); |
|
|
|
return ( |
|
<div className="relative inline-block ml-1 align-middle"> |
|
<button |
|
ref={buttonRef} |
|
className="text-gray-400 hover:text-gray-600 focus:outline-none" |
|
onMouseEnter={() => setIsVisible(true)} |
|
onMouseLeave={() => setIsVisible(false)} |
|
onClick={(e) => { |
|
e.stopPropagation(); |
|
setIsVisible(!isVisible); |
|
}} |
|
aria-label="Info" |
|
> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
className="h-4 w-4" |
|
viewBox="0 0 20 20" |
|
fill="currentColor" |
|
> |
|
<path |
|
fillRule="evenodd" |
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" |
|
clipRule="evenodd" |
|
/> |
|
</svg> |
|
</button> |
|
{isVisible && ( |
|
<div |
|
className="fixed p-2 bg-white border-1 rounded shadow-xl text-xs text-gray-700 whitespace-pre-wrap" |
|
style={{ |
|
top: `${position.top}px`, |
|
left: `${position.left}px`, |
|
zIndex: 9999, |
|
maxWidth: "250px", |
|
transform: "translate(-50%, -100%)", |
|
}} |
|
> |
|
{text} |
|
</div> |
|
)} |
|
</div> |
|
); |
|
}; |
|
|
|
|
|
const CustomDemographicTooltip = ({ active, payload, label }) => { |
|
if (active && payload && payload.length) { |
|
const sortedPayload = [...payload].sort( |
|
(a, b) => (b.value || 0) - (a.value || 0) |
|
); |
|
return ( |
|
<div className="bg-white p-3 border rounded shadow-lg max-w-xs"> |
|
<p className="font-medium text-sm mb-1">{label}</p> |
|
{sortedPayload.map((entry, index) => ( |
|
<div key={`item-${index}`} className="flex items-center mt-1"> |
|
<div |
|
className="w-3 h-3 mr-2 rounded-full flex-shrink-0" |
|
style={{ |
|
backgroundColor: |
|
entry.payload[`${entry.dataKey}_color`] || |
|
entry.color || |
|
"#999", |
|
}} |
|
></div> |
|
<span className="text-xs flex-grow pr-2">{entry.name}: </span> |
|
<span className="text-xs font-medium ml-1 whitespace-nowrap"> |
|
{typeof entry.value === "number" ? entry.value.toFixed(1) : "N/A"} |
|
</span> |
|
</div> |
|
))} |
|
</div> |
|
); |
|
} |
|
return null; |
|
}; |
|
|
|
|
|
const EquityGapTooltip = ({ active, payload }) => { |
|
if (active && payload && payload.length > 0) { |
|
const data = payload[0].payload; |
|
|
|
if (!data || typeof data !== "object") return null; |
|
|
|
|
|
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 ( |
|
<div className="bg-white p-3 border rounded shadow-lg text-xs max-w-xs"> |
|
<p className="font-medium text-sm mb-2">{data.model}</p> |
|
<div className="space-y-1"> |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">Equity Gap:</span> |
|
{/* 'gap' key is used in chart data */} |
|
<span>{data.gap?.toFixed(1) ?? "N/A"} pts</span> |
|
</div> |
|
{data.effect_size !== undefined && data.effect_size !== null && ( |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">Effect Size:</span> |
|
<span> |
|
{data.effect_size?.toFixed(2) ?? "N/A"} ( |
|
{data.effect_size_class || "N/A"}) |
|
</span> |
|
</div> |
|
)} |
|
{/* Show Significance */} |
|
<div className="flex justify-between items-center"> |
|
<span className="font-semibold">Significance:</span> |
|
<span className={`flex items-center ${significanceInfo.className}`}> |
|
{significanceInfo.tooltip.replace(/Statistically /g, "")}{" "} |
|
{/* Shorten text */} |
|
<span className="ml-1 font-bold">{significanceInfo.symbol}</span> |
|
</span> |
|
</div> |
|
{/* Show Confidence Interval */} |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">95% CI:</span> |
|
<span> |
|
{typeof ciLower === "number" && typeof ciUpper === "number" |
|
? `[${ciLower.toFixed(1)}, ${ciUpper.toFixed(1)}]` |
|
: "N/A"} |
|
</span> |
|
</div> |
|
{/* Show Concern Flag */} |
|
{data.is_equity_concern !== undefined && ( |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">Concern Flag:</span> |
|
<span |
|
className={ |
|
data.is_equity_concern |
|
? "font-bold text-red-600" |
|
: "text-gray-600" |
|
} |
|
> |
|
{data.is_equity_concern ? "Yes" : "No"} |
|
</span> |
|
</div> |
|
)} |
|
{/* Show Min/Max Groups */} |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">Lowest Group:</span> |
|
<span> |
|
{data.min_level || "N/A"} ({data.min_score?.toFixed(1) ?? "-"}) |
|
</span> |
|
</div> |
|
<div className="flex justify-between"> |
|
<span className="font-semibold">Highest Group:</span> |
|
<span> |
|
{data.max_level || "N/A"} ({data.max_score?.toFixed(1) ?? "-"}) |
|
</span> |
|
</div> |
|
</div> |
|
</div> |
|
); |
|
} |
|
return null; |
|
}; |
|
|
|
|
|
|
|
|
|
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"; |
|
} |
|
}; |
|
|
|
|
|
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"; |
|
}; |
|
|
|
|
|
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"; |
|
}; |
|
|
|
|
|
const formatPValue = (pValue) => { |
|
if (pValue === null || pValue === undefined) return "N/A"; |
|
return `p=${pValue.toFixed(3)}` + (pValue < 0.05 ? " < 0.05" : " ≥ 0.05"); |
|
}; |
|
|
|
|
|
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"`; |
|
}; |
|
|
|
|
|
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 |
|
}) => { |
|
|
|
const highLevelMetricDisplayKeys = Object.keys( |
|
metricsData?.highLevelCategories || {} |
|
).sort(); |
|
const lowLevelMetricDisplayKeys = Object.keys( |
|
metricsData?.lowLevelMetrics || {} |
|
).sort(); |
|
|
|
const [selectedDemographicFactor, setSelectedDemographicFactor] = |
|
useState(null); |
|
const [selectedMetricDisplayKey, setSelectedMetricDisplayKey] = |
|
useState(null); |
|
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"; |
|
|
|
|
|
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]); |
|
|
|
|
|
useEffect(() => { |
|
if (!selectedMetricDisplayKey && currentMetricDisplayKeys.length > 0) { |
|
|
|
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]); |
|
|
|
|
|
const internalMetricKey = useMemo(() => { |
|
if (!selectedMetricDisplayKey) return null; |
|
const allMetrics = { |
|
...(metricsData?.highLevelCategories || {}), |
|
...(metricsData?.lowLevelMetrics || {}), |
|
}; |
|
|
|
return allMetrics[selectedMetricDisplayKey]?.internalMetricKey ?? null; |
|
}, [selectedMetricDisplayKey, metricsData]); |
|
|
|
|
|
const filteredEquityGaps = useMemo(() => { |
|
|
|
if ( |
|
!internalMetricKey || |
|
!selectedDemographicFactor || |
|
!equityAnalysis?.all_equity_gaps || |
|
!Array.isArray(equityAnalysis.all_equity_gaps) |
|
) { |
|
return []; |
|
} |
|
|
|
return equityAnalysis.all_equity_gaps.filter( |
|
(gap) => |
|
gap.category === internalMetricKey && |
|
gap.demographic_factor === selectedDemographicFactor |
|
); |
|
}, [ |
|
internalMetricKey, |
|
selectedDemographicFactor, |
|
equityAnalysis?.all_equity_gaps, |
|
]); |
|
|
|
|
|
const equityGapChartData = useMemo(() => { |
|
return filteredEquityGaps |
|
.map((gap) => ({ |
|
|
|
|
|
model: gap.model, |
|
gap: gap.score_range ?? 0, |
|
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, |
|
|
|
|
|
color: getModelColor(gap.model), |
|
})) |
|
.sort((a, b) => (a.gap ?? 0) - (b.gap ?? 0)) |
|
.map((item, index) => ({ ...item, rank: index + 1 })); |
|
}, [filteredEquityGaps]); |
|
|
|
|
|
const demographicChartData = useMemo(() => { |
|
|
|
if ( |
|
!selectedDemographicFactor || |
|
!selectedMetricDisplayKey || |
|
!rawData.mrpDemographics |
|
) |
|
return []; |
|
const metricKeyInData = selectedMetricDisplayKey; |
|
const levels = rawData.demographicOptions[selectedDemographicFactor] || []; |
|
if (levels.length === 0) return []; |
|
|
|
const chartData = levels.map((level) => { |
|
const entry = { level }; |
|
modelsMeta.forEach((model) => { |
|
|
|
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 ( |
|
<div> |
|
{/* Controls Panel */} |
|
<div className="border rounded-lg overflow-hidden mb-6 shadow-sm"> |
|
<div className="px-4 py-3 bg-gray-50 border-b"> |
|
<h3 className="font-semibold text-gray-800"> |
|
Demographic Analysis Controls |
|
</h3> |
|
</div> |
|
<div className="p-4 grid grid-cols-1 md:grid-cols-3 gap-4"> |
|
{/* Factor Selector */} |
|
<div> |
|
<label |
|
htmlFor="factorSelect" |
|
className="block text-sm font-medium text-gray-700 mb-1" |
|
> |
|
Demographic Factor |
|
</label> |
|
<select |
|
id="factorSelect" |
|
className="w-full border rounded-md px-3 py-2 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" |
|
value={selectedDemographicFactor || ""} |
|
onChange={(e) => setSelectedDemographicFactor(e.target.value)} |
|
> |
|
<option value="" disabled> |
|
Select factor |
|
</option> |
|
{Object.keys(rawData.demographicOptions || {}) |
|
.sort() |
|
.map((factor) => ( |
|
<option key={factor} value={factor}> |
|
{formatDisplayKey(factor)} |
|
</option> |
|
))} |
|
</select> |
|
</div> |
|
{/* Level Toggle */} |
|
<div> |
|
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|
Metric Level |
|
</label> |
|
<div className="flex"> |
|
<button |
|
className={`px-3 py-2 text-sm font-medium border ${ |
|
metricLevel === "high" |
|
? "bg-blue-100 text-blue-800 border-blue-300" |
|
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50" |
|
} rounded-l-md flex-1`} |
|
onClick={() => setMetricLevel("high")} |
|
> |
|
High-Level |
|
</button> |
|
<button |
|
className={`px-3 py-2 text-sm font-medium border-t border-b border-r ${ |
|
metricLevel === "low" |
|
? "bg-blue-100 text-blue-800 border-blue-300" |
|
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50" |
|
} rounded-r-md flex-1`} |
|
onClick={() => setMetricLevel("low")} |
|
> |
|
Low-Level |
|
</button> |
|
</div> |
|
</div> |
|
{/* Metric Selector - Uses Title Case keys */} |
|
<div> |
|
<label |
|
htmlFor="metricSelect" |
|
className="block text-sm font-medium text-gray-700 mb-1" |
|
> |
|
<Tooltip content={getMetricTooltip(selectedMetricDisplayKey)}> |
|
<span> |
|
{metricLevel === "high" |
|
? "High-Level Category" |
|
: "Low-Level Metric"} |
|
</span> |
|
</Tooltip> |
|
</label> |
|
<select |
|
id="metricSelect" |
|
className="w-full border rounded-md px-3 py-2 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" |
|
value={selectedMetricDisplayKey || ""} |
|
onChange={(e) => setSelectedMetricDisplayKey(e.target.value)} |
|
disabled={currentMetricDisplayKeys.length === 0} |
|
> |
|
<option value="" disabled> |
|
Select metric |
|
</option> |
|
{/* Iterate through Title Case keys */} |
|
{currentMetricDisplayKeys.map((displayKey) => ( |
|
<option key={displayKey} value={displayKey}> |
|
{displayKey} |
|
</option> |
|
))} |
|
</select> |
|
{!selectedMetricDisplayKey && |
|
currentMetricDisplayKeys.length > 0 && ( |
|
<p className="mt-1 text-xs text-gray-500"> |
|
Select a metric to view analysis. |
|
</p> |
|
)} |
|
{currentMetricDisplayKeys.length === 0 && ( |
|
<p className="mt-1 text-xs text-amber-600"> |
|
No {metricLevel} metrics available. |
|
</p> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{/* Demographic Breakdown Chart */} |
|
<div className="border rounded-lg overflow-hidden mb-6 shadow-sm"> |
|
<div className="px-4 py-3 bg-gray-50 border-b"> |
|
<h3 className="font-semibold text-gray-800"> |
|
{selectedMetricDisplayKey || "Metric"} Scores across{" "} |
|
{formatDisplayKey(selectedDemographicFactor) || "Groups"} |
|
<InfoTooltip |
|
text={`Shows the average score (0-100) for each model within each subgroup of ${formatDisplayKey( |
|
selectedDemographicFactor |
|
)}. Higher scores are better.`} |
|
/> |
|
</h3> |
|
</div> |
|
<div className="p-4"> |
|
{demographicChartData.length > 0 && modelsWithDemoData.length > 0 ? ( |
|
<div className="h-80"> |
|
<ResponsiveContainer width="100%" height="100%"> |
|
<BarChart |
|
data={demographicChartData} |
|
margin={{ top: 5, right: 5, left: 0, bottom: 60 }} |
|
> |
|
<CartesianGrid strokeDasharray="3 3" vertical={false} /> |
|
<XAxis |
|
dataKey="level" |
|
angle={-45} |
|
textAnchor="end" |
|
tick={{ fontSize: 11 }} |
|
interval={0} |
|
height={70} |
|
/> |
|
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} width={40} /> |
|
<RechartsTooltip |
|
content={<CustomDemographicTooltip />} |
|
wrapperStyle={{ zIndex: 10 }} |
|
/> |
|
<Legend |
|
layout="horizontal" |
|
verticalAlign="bottom" |
|
align="center" |
|
wrapperStyle={{ paddingTop: 30 }} |
|
iconSize={10} |
|
/> |
|
{modelsWithDemoData.map((modelName) => ( |
|
<Bar |
|
key={modelName} |
|
dataKey={modelName} |
|
name={modelName} |
|
fill={getModelColor(modelName)} |
|
/> |
|
))} |
|
</BarChart> |
|
</ResponsiveContainer> |
|
</div> |
|
) : ( |
|
<div className="flex items-center justify-center h-60 bg-gray-50 rounded"> |
|
<div className="text-center p-4"> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
className="h-10 w-10 mx-auto text-gray-400 mb-3" |
|
fill="none" |
|
viewBox="0 0 24 24" |
|
stroke="currentColor" |
|
> |
|
<path |
|
strokeLinecap="round" |
|
strokeLinejoin="round" |
|
strokeWidth={2} |
|
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V7a2 2 0 012-2h2l2-3h6l2 3h2a2 2 0 012 2v10a2 2 0 01-2 2h-1" |
|
/> |
|
</svg> |
|
<h3 className="text-lg font-medium text-gray-900 mb-1"> |
|
No Data Available |
|
</h3> |
|
<p className="text-sm text-gray-600"> |
|
{!selectedDemographicFactor |
|
? "Please select a demographic factor." |
|
: !selectedMetricDisplayKey |
|
? "Please select a metric." |
|
: "No score data found."} |
|
</p> |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
|
|
{/* Equity Gap Comparison Chart */} |
|
<div className="border rounded-lg overflow-hidden mb-6 shadow-sm"> |
|
<div className="px-4 py-3 bg-gray-50 border-b"> |
|
<h3 className="font-semibold text-gray-800"> |
|
Equity Gap Comparison for {selectedMetricDisplayKey || "Metric"} |
|
<InfoTooltip |
|
text={`Compares the maximum score difference observed between ${formatDisplayKey( |
|
selectedDemographicFactor |
|
)} groups for each model. Lower gaps indicate better equity.`} |
|
/> |
|
</h3> |
|
</div> |
|
<div className="p-4"> |
|
{equityGapChartData.length > 0 ? ( |
|
<div className="h-72"> |
|
<ResponsiveContainer width="100%" height="100%"> |
|
<BarChart |
|
data={equityGapChartData} |
|
margin={{ top: 5, right: 30, left: 5, bottom: 5 }} |
|
layout="vertical" |
|
> |
|
<CartesianGrid |
|
strokeDasharray="3 3" |
|
horizontal={true} |
|
vertical={false} |
|
/> |
|
<XAxis |
|
type="number" |
|
dataKey="gap" |
|
domain={[0, "auto"]} |
|
tick={{ fontSize: 11 }} |
|
allowDecimals={false} |
|
/> |
|
<YAxis |
|
dataKey="model" |
|
type="category" |
|
width={130} |
|
tick={{ fontSize: 11 }} |
|
/> |
|
<RechartsTooltip |
|
content={<EquityGapTooltip />} |
|
wrapperStyle={{ zIndex: 10 }} |
|
/> |
|
<Bar |
|
dataKey="gap" |
|
name="Equity Gap" |
|
barSize={20} |
|
radius={[0, 4, 4, 0]} |
|
> |
|
{equityGapChartData.map((entry, index) => ( |
|
<Cell |
|
key={`cell-${index}`} |
|
fill={entry.color} |
|
fillOpacity={0.8} |
|
/> |
|
))} |
|
<LabelList |
|
dataKey="gap" |
|
position="right" |
|
formatter={(value) => value?.toFixed(1) ?? ""} |
|
style={{ fontSize: 11, fill: "#6b7280" }} |
|
/> |
|
</Bar> |
|
</BarChart> |
|
</ResponsiveContainer> |
|
</div> |
|
) : ( |
|
<div className="flex items-center justify-center h-60 bg-gray-50 rounded"> |
|
<div className="text-center p-4"> |
|
<svg |
|
xmlns="http://www.w3.org/2000/svg" |
|
className="h-10 w-10 mx-auto text-gray-400 mb-3" |
|
fill="none" |
|
viewBox="0 0 24 24" |
|
stroke="currentColor" |
|
> |
|
<path |
|
strokeLinecap="round" |
|
strokeLinejoin="round" |
|
strokeWidth={2} |
|
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V7a2 2 0 012-2h2l2-3h6l2 3h2a2 2 0 012 2v10a2 2 0 01-2 2h-1" |
|
/> |
|
</svg> |
|
<h3 className="text-lg font-medium text-gray-900 mb-1"> |
|
No Equity Gap Data |
|
</h3> |
|
<p className="text-sm text-gray-600"> |
|
{!selectedDemographicFactor |
|
? "Select factor." |
|
: !selectedMetricDisplayKey |
|
? "Select metric." |
|
: "No equity gaps found."} |
|
</p> |
|
</div> |
|
</div> |
|
)} |
|
{equityGapChartData.length > 0 && ( |
|
<p className="mt-3 text-xs text-gray-500"> |
|
Chart ranks models by equity gap size (lower is better). |
|
</p> |
|
)} |
|
</div> |
|
</div> |
|
|
|
{/* Equity Gap Details Table - IMPROVED */} |
|
{equityGapChartData.length > 0 && ( |
|
<div className="border rounded-lg overflow-hidden mb-6 shadow-sm"> |
|
<div className="px-4 py-3 bg-gray-50 border-b"> |
|
<h3 className="font-semibold text-gray-800"> |
|
Detailed Equity Gaps: {selectedMetricDisplayKey || "Metric"} by{" "} |
|
{formatDisplayKey(selectedDemographicFactor) || "Factor"} |
|
</h3> |
|
</div> |
|
<div className="p-4 overflow-x-auto"> |
|
<table className="min-w-full divide-y divide-gray-200"> |
|
<thead className="bg-gray-50"> |
|
<tr> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Rank |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Model |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Equity Gap |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Effect Size |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Significance |
|
</th> |
|
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Concern? |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Lowest Group (Score) |
|
</th> |
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> |
|
Highest Group (Score) |
|
</th> |
|
</tr> |
|
</thead> |
|
<tbody className="bg-white divide-y divide-gray-200"> |
|
{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 ( |
|
<tr |
|
key={gap.model} |
|
className={`hover:bg-gray-50 ${ |
|
gap.is_equity_concern ? "bg-red-50" : "" |
|
}`} |
|
> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm text-gray-500"> |
|
{gap.rank} |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap"> |
|
<div className="flex items-center"> |
|
<div |
|
className="w-3 h-3 rounded-full mr-2 flex-shrink-0" |
|
style={{ backgroundColor: gap.color }} |
|
></div> |
|
<span className="text-sm font-medium text-gray-900"> |
|
{gap.model} |
|
</span> |
|
</div> |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm font-medium"> |
|
{/* Equity Gap as plain text */} |
|
{gap.gap !== undefined && gap.gap !== null |
|
? gap.gap.toFixed(1) |
|
: "N/A"} |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm"> |
|
{gap.effect_size !== undefined && |
|
gap.effect_size !== null ? ( |
|
<div className="flex items-center"> |
|
<span |
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${getEffectSizeBadgeStyle( |
|
gap.effect_size_class |
|
)}`} |
|
> |
|
{gap.effect_size_class || "N/A"} |
|
</span> |
|
<InfoTooltip |
|
text={getEffectSizeTooltip(gap.effect_size)} |
|
/> |
|
</div> |
|
) : ( |
|
<span className="text-gray-500">N/A</span> |
|
)} |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm"> |
|
<div className="flex flex-col"> |
|
<div className="flex items-center"> |
|
<span |
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${getSignificanceBadgeStyle( |
|
gap.is_statistically_significant |
|
)}`} |
|
> |
|
{gap.is_statistically_significant ? ( |
|
<span>Significant ✔</span> |
|
) : ( |
|
<span>Not Significant ✘</span> |
|
)} |
|
</span> |
|
</div> |
|
<div className="text-xs text-gray-500 mt-1"> |
|
{gap.p_value !== undefined && gap.p_value !== null |
|
? formatPValue(gap.p_value) |
|
: ""} |
|
</div> |
|
</div> |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm text-center"> |
|
<span |
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getConcernBadgeStyle( |
|
gap.is_equity_concern |
|
)}`} |
|
> |
|
{gap.is_equity_concern ? "Yes" : "No"} |
|
</span> |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm"> |
|
{gap.min_level ? ( |
|
<div className="flex flex-col"> |
|
<span className="font-medium">{gap.min_level}</span> |
|
<span className="text-gray-500"> |
|
{minScoreDisplay} |
|
</span> |
|
</div> |
|
) : ( |
|
<span className="text-gray-500">-</span> |
|
)} |
|
</td> |
|
<td className="px-3 py-2 whitespace-nowrap text-sm"> |
|
{gap.max_level ? ( |
|
<div className="flex flex-col"> |
|
<span className="font-medium">{gap.max_level}</span> |
|
<span className="text-gray-500"> |
|
{maxScoreDisplay} |
|
</span> |
|
</div> |
|
) : ( |
|
<span className="text-gray-500">-</span> |
|
)} |
|
</td> |
|
</tr> |
|
); |
|
})} |
|
</tbody> |
|
</table> |
|
</div> |
|
{/* Table Footer/Explanation - IMPROVED */} |
|
<div className="px-4 pb-4 pt-2 text-xs text-gray-600"> |
|
<div className="space-y-1"> |
|
<p> |
|
<span className="font-semibold">Rank:</span> Based on lowest |
|
Equity Gap value for this metric/factor |
|
</p> |
|
<p> |
|
<span className="font-semibold">Equity Gap:</span> Score |
|
difference (0-100 points) between highest and lowest scoring |
|
groups |
|
</p> |
|
<p> |
|
<span className="font-semibold">Effect Size:</span> Gap |
|
magnitude relative to score variation (hover for details) |
|
</p> |
|
<p> |
|
<span className="font-semibold">Significance:</span>Whether the |
|
gap is statistically significant after adjusting for multiple |
|
tests (Benjamini-Hochberg FDR correction, q<0.05) |
|
</p> |
|
<p> |
|
<span className="font-semibold">Concern?:</span> 'Yes' flags |
|
potential equity concerns (Large Effect Size AND Statistically |
|
Significant) |
|
</p> |
|
</div> |
|
</div> |
|
</div> |
|
)} |
|
</div> |
|
); |
|
}; |
|
|
|
export default DemographicAnalysis; |
|
|