Nora Petrova
Add project to new space
20e666e
raw
history blame
36.1 kB
// 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 (
<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>
);
};
// 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 (
<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;
};
// 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 (
<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;
};
// 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 (
<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&lt;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;