import { useState } from 'react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell, CartesianGrid, } from 'recharts'; import { lookupMedicalTerm } from '../utils/medicalDictionary'; const COLOR_POSITIVE = '#910505'; const COLOR_NEGATIVE = '#24a316'; /** * Horizontal bar chart of SHAP values. * Positive (risk-increasing) bars are red, negative (protective) are green. * * Props: * chartData — Array of { feature: string, shap_value: number } sorted by * |shap_value| descending (as returned by the backend). * baseValue — Optional base (expected) value from the SHAP explainer. * maxVisible — Maximum number of features to show before "Show All" (default 10). */ export default function ShapBarChart({ chartData, baseValue, maxVisible = 10 }) { const [showAll, setShowAll] = useState(false); if (!chartData || chartData.length === 0) { return (
No SHAP values to display. Run inference first.
); } // chartData is already sorted by |shap_value| descending from the backend const data = chartData.map((d) => ({ name: d.feature, value: d.shap_value, absValue: Math.abs(d.shap_value), })); const truncated = !showAll && data.length > maxVisible; const displayData = truncated ? data.slice(0, maxVisible) : data; const maxAbs = Math.max(...data.map((d) => d.absValue), 0.01); const domainMax = maxAbs * 1.15; const chartHeight = Math.max(200, displayData.length * 36); /** * Custom Y-axis tick renderer. * Uses SVG foreignObject to embed HTML with the MedicalTooltip hover effect. */ const renderCustomYAxisTick = ({ x, y, payload }) => { const featureName = payload.value; const desc = lookupMedicalTerm(featureName); return (
{featureName} {desc && ( {desc} )}
); }; /** * Custom Recharts Tooltip content with medical descriptions. */ const CustomRechartsTooltip = ({ active, payload, label }) => { if (!active || !payload || payload.length === 0) return null; const item = payload[0]; const desc = lookupMedicalTerm(label); const val = item.value; const isRiskInc = val >= 0; return (

{label}

SHAP: {val.toFixed(4)} {' '} {isRiskInc ? '↑ Risk-increasing' : '↓ Protective'}

{desc && (

{desc}

)}
); }; return (
{baseValue !== undefined && (
Base value (expected log-odds): {baseValue.toFixed(4)}
)} tick.toFixed(2)} /> } cursor={{ fill: '#f8fafc' }} /> {displayData.map((entry, i) => ( = 0 ? COLOR_POSITIVE : COLOR_NEGATIVE} fillOpacity={0.75 + Math.abs(entry.value) / (maxAbs * 2) * 0.25} /> ))} {/* Show All / Show Less toggle */} {data.length > maxVisible && ( )}
Risk-increasing Protective
); }