Heatmap.tsx (5303B)
1 import type { InteractionResult } from "../lib/analysis"; 2 3 interface HeatmapProps { 4 data: InteractionResult; 5 metric: string; 6 totalRuns?: number; 7 totalCells?: number; 8 } 9 10 export default function Heatmap({ data, metric, totalRuns, totalCells }: HeatmapProps) { 11 const { axisA, axisB, table } = data; 12 13 const aValues = Object.keys(table).sort(); 14 const bValues = Array.from( 15 new Set(aValues.flatMap((a) => Object.keys(table[a]))) 16 ).sort(); 17 18 if (aValues.length === 0 || bValues.length === 0) { 19 return ( 20 <div 21 className="card" 22 style={{ 23 textAlign: "center", 24 padding: "40px", 25 color: "var(--text-muted)", 26 }} 27 > 28 Not enough data for this interaction. 29 </div> 30 ); 31 } 32 33 // Find min/max for color scale 34 const allMeans = aValues.flatMap((a) => 35 bValues.filter((b) => table[a]?.[b]).map((b) => table[a][b].mean) 36 ); 37 const minVal = Math.min(...allMeans); 38 const maxVal = Math.max(...allMeans); 39 const range = maxVal - minVal || 1; 40 41 function cellColor(value: number): string { 42 const ratio = (value - minVal) / range; 43 if (ratio > 0.66) 44 return `rgba(34, 197, 94, ${0.3 + ratio * 0.5})`; 45 if (ratio > 0.33) 46 return `rgba(234, 179, 8, ${0.3 + ratio * 0.4})`; 47 return `rgba(239, 68, 68, ${0.3 + (1 - ratio) * 0.4})`; 48 } 49 50 return ( 51 <div className="card"> 52 <h3 style={{ marginBottom: "4px" }}> 53 {axisA} x {axisB} 54 </h3> 55 {totalRuns != null && totalCells != null && ( 56 <div style={{ fontSize: "10px", fontFamily: "'JetBrains Mono', monospace", color: "var(--text-muted, hsl(213 14% 65%))", marginBottom: "4px" }}> 57 (n={totalRuns} runs across {totalCells} cells) 58 </div> 59 )} 60 <p 61 style={{ 62 color: "var(--text-muted)", 63 fontSize: "0.75rem", 64 marginBottom: "16px", 65 }} 66 > 67 Mean {metric} for each combination. Interaction strength:{" "} 68 <span 69 style={{ 70 fontFamily: "var(--font-mono)", 71 color: 72 data.maxInteraction > 0.05 73 ? "var(--yellow)" 74 : "var(--text-muted)", 75 }} 76 > 77 {(data.maxInteraction * 100).toFixed(1)}% 78 </span> 79 </p> 80 81 <div style={{ overflowX: "auto" }}> 82 <table style={{ borderCollapse: "collapse" }}> 83 <thead> 84 <tr> 85 <th 86 style={{ 87 padding: "8px 12px", 88 fontSize: "0.7rem", 89 textAlign: "center", 90 }} 91 > 92 {axisA} \ {axisB} 93 </th> 94 {bValues.map((b) => ( 95 <th 96 key={b} 97 style={{ 98 padding: "8px 12px", 99 fontSize: "0.75rem", 100 textAlign: "center", 101 fontFamily: "var(--font-mono)", 102 }} 103 > 104 {b} 105 </th> 106 ))} 107 </tr> 108 </thead> 109 <tbody> 110 {aValues.map((a) => ( 111 <tr key={a}> 112 <td 113 style={{ 114 padding: "8px 12px", 115 fontSize: "0.75rem", 116 fontFamily: "var(--font-mono)", 117 fontWeight: 600, 118 }} 119 > 120 {a} 121 </td> 122 {bValues.map((b) => { 123 const cell = table[a]?.[b]; 124 if (!cell) { 125 return ( 126 <td 127 key={b} 128 style={{ 129 padding: "8px 12px", 130 textAlign: "center", 131 color: "var(--text-muted)", 132 }} 133 > 134 - 135 </td> 136 ); 137 } 138 const isLowN = cell.n < 3; 139 return ( 140 <td 141 key={b} 142 style={{ 143 padding: "8px 12px", 144 textAlign: "center", 145 background: cellColor(cell.mean), 146 fontFamily: "var(--font-mono)", 147 fontSize: "0.8rem", 148 fontWeight: 600, 149 borderRadius: "2px", 150 opacity: isLowN ? 0.4 : 1, 151 ...(isLowN ? { borderStyle: "dashed", borderWidth: "1px", borderColor: "var(--text-muted)" } : {}), 152 }} 153 > 154 {(cell.mean * 100).toFixed(0)}% 155 <div 156 style={{ 157 fontSize: "0.6rem", 158 color: isLowN ? "var(--yellow, hsl(40 95% 64%))" : "var(--text-muted)", 159 fontWeight: isLowN ? 600 : 400, 160 }} 161 > 162 n={cell.n} 163 </div> 164 </td> 165 ); 166 })} 167 </tr> 168 ))} 169 </tbody> 170 </table> 171 </div> 172 </div> 173 ); 174 }