| 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'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export default function ShapBarChart({ chartData, baseValue, maxVisible = 10 }) { |
| const [showAll, setShowAll] = useState(false); |
|
|
| if (!chartData || chartData.length === 0) { |
| return ( |
| <div className="text-center text-gray-400 py-8 text-sm"> |
| No SHAP values to display. Run inference first. |
| </div> |
| ); |
| } |
|
|
| |
| 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); |
|
|
| |
| |
| |
| |
| const renderCustomYAxisTick = ({ x, y, payload }) => { |
| const featureName = payload.value; |
| const desc = lookupMedicalTerm(featureName); |
|
|
| return ( |
| <g transform={`translate(${x},${y})`}> |
| <foreignObject |
| x={-140} |
| y={-10} |
| width={140} |
| height={22} |
| style={{ overflow: 'visible' }} |
| > |
| <div |
| className="group/tooltip relative inline-flex justify-end" |
| style={{ width: '100%' }} |
| > |
| <span |
| className="text-xs font-medium truncate" |
| style={{ |
| color: '#334155', |
| maxWidth: '130px', |
| display: 'inline-block', |
| whiteSpace: 'nowrap', |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| cursor: desc ? 'help' : 'default', |
| borderBottom: desc ? '1px dashed #94a3b8' : 'none', |
| }} |
| > |
| {featureName} |
| </span> |
| {desc && ( |
| <span |
| role="tooltip" |
| className=" |
| invisible group-hover/tooltip:visible |
| opacity-0 group-hover/tooltip:opacity-100 |
| transition-all duration-200 delay-[400ms] |
| absolute bottom-full left-1/2 -translate-x-1/2 mb-1 |
| z-50 |
| max-w-[260px] w-max |
| px-3 py-2 |
| rounded-lg |
| bg-gray-900 text-white |
| text-xs leading-relaxed |
| shadow-lg |
| pointer-events-none |
| text-left |
| font-normal |
| " |
| > |
| {desc} |
| <span |
| className=" |
| absolute top-full left-1/2 -translate-x-1/2 |
| w-0 h-0 |
| border-4 border-transparent border-t-gray-900 |
| " |
| /> |
| </span> |
| )} |
| </div> |
| </foreignObject> |
| </g> |
| ); |
| }; |
|
|
| |
| |
| |
| 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 ( |
| <div |
| style={{ |
| fontSize: 12, |
| borderRadius: 8, |
| border: '1px solid #e2e8f0', |
| boxShadow: '0 2px 8px rgba(0,0,0,0.08)', |
| background: 'white', |
| padding: '8px 12px', |
| maxWidth: 280, |
| }} |
| > |
| <p style={{ fontWeight: 600, margin: '0 0 4px', color: '#1e293b' }}> |
| {label} |
| </p> |
| <p style={{ margin: '0 0 2px', color: isRiskInc ? '#dc2626' : '#16a34a' }}> |
| SHAP: {val.toFixed(4)} |
| {' '} |
| <span>{isRiskInc ? '↑ Risk-increasing' : '↓ Protective'}</span> |
| </p> |
| {desc && ( |
| <p style={{ margin: '4px 0 0', color: '#64748b', lineHeight: 1.4, fontSize: 11 }}> |
| {desc} |
| </p> |
| )} |
| </div> |
| ); |
| }; |
|
|
| return ( |
| <div className="space-y-3"> |
| {baseValue !== undefined && ( |
| <div className="text-xs text-gray-500 text-center"> |
| Base value (expected log-odds): <span className="font-mono font-medium">{baseValue.toFixed(4)}</span> |
| </div> |
| )} |
| |
| <ResponsiveContainer width="100%" height={chartHeight}> |
| <BarChart |
| data={displayData} |
| layout="vertical" |
| margin={{ top: 4, right: 16, left: 150, bottom: 4 }} |
| barSize={20} |
| > |
| <CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#f1f5f9" /> |
| <XAxis |
| type="number" |
| domain={[-domainMax, domainMax]} |
| tick={{ fontSize: 11, fill: '#64748b' }} |
| tickLine={false} |
| axisLine={false} |
| tickFormatter={(tick) => tick.toFixed(2)} |
| /> |
| <YAxis |
| type="category" |
| dataKey="name" |
| tick={renderCustomYAxisTick} |
| tickLine={false} |
| axisLine={false} |
| width={150} |
| /> |
| <Tooltip |
| content={<CustomRechartsTooltip />} |
| cursor={{ fill: '#f8fafc' }} |
| /> |
| <Bar dataKey="value" radius={[0, 4, 4, 0]}> |
| {displayData.map((entry, i) => ( |
| <Cell |
| key={i} |
| fill={entry.value >= 0 ? COLOR_POSITIVE : COLOR_NEGATIVE} |
| fillOpacity={0.75 + Math.abs(entry.value) / (maxAbs * 2) * 0.25} |
| /> |
| ))} |
| </Bar> |
| </BarChart> |
| </ResponsiveContainer> |
| |
| {/* Show All / Show Less toggle */} |
| {data.length > maxVisible && ( |
| <button |
| type="button" |
| onClick={() => setShowAll((prev) => !prev)} |
| className="w-full text-xs text-primary-600 hover:text-primary-700 font-medium py-1.5 transition-colors" |
| > |
| {truncated |
| ? `Show All ${data.length} Features` |
| : `Show Less (${maxVisible} features)`} |
| </button> |
| )} |
| |
| <div className="flex justify-center gap-6 text-xs text-gray-500"> |
| <span className="flex items-center gap-1.5"> |
| <span className="w-3 h-3 rounded-sm" style={{ backgroundColor: COLOR_POSITIVE }} /> |
| Risk-increasing |
| </span> |
| <span className="flex items-center gap-1.5"> |
| <span className="w-3 h-3 rounded-sm" style={{ backgroundColor: COLOR_NEGATIVE }} /> |
| Protective |
| </span> |
| </div> |
| </div> |
| ); |
| } |
|
|