ConfigTreemap.tsx (9319B)
1 import React, { useState, useCallback } from "react"; 2 import { Treemap, ResponsiveContainer, Tooltip } from "recharts"; 3 import type { TreemapNode } from "recharts/types/chart/Treemap"; 4 import type { Run, AxisName } from "../lib/types"; 5 import { groupIntoCells, type Cell } from "../lib/analysis"; 6 7 interface ConfigTreemapProps { 8 runs: Run[]; 9 } 10 11 const SECONDARY_AXES: AxisName[] = [ 12 "prompt_style", 13 "effort", 14 "language", 15 "human_language", 16 "linter", 17 "playwright", 18 "context_file", 19 "web_search", 20 "max_budget", 21 "tests_provided", 22 "strategy", 23 "design_guidance", 24 "architecture", 25 "error_checking", 26 "context_noise", 27 "renderer", 28 ]; 29 30 function scoreColor(avgScore: number | null): string { 31 if (avgScore === null) return "hsl(213 14% 30%)"; 32 const pct = avgScore * 100; 33 if (pct > 60) return "hsl(92 28% 45%)"; 34 if (pct >= 30) return "hsl(40 71% 50%)"; 35 return "hsl(355 52% 48%)"; 36 } 37 38 interface LeafData { 39 name: string; 40 displayName: string; 41 size: number; 42 avgScore: number | null; 43 avgScorePct: string; 44 model: string; 45 configValue: string; 46 color: string; 47 [key: string]: unknown; 48 } 49 50 interface GroupData { 51 name: string; 52 children: LeafData[]; 53 [key: string]: unknown; 54 } 55 56 function buildTreeData(runs: Run[], secondaryAxis: AxisName): GroupData[] { 57 const cells = groupIntoCells(runs); 58 const byModel: Record<string, Record<string, Cell[]>> = {}; 59 60 for (const cell of cells) { 61 const model = cell.meta.model; 62 const secondary = String(cell.meta[secondaryAxis]); 63 if (!byModel[model]) byModel[model] = {}; 64 if (!byModel[model][secondary]) byModel[model][secondary] = []; 65 byModel[model][secondary].push(cell); 66 } 67 68 return Object.entries(byModel) 69 .sort(([a], [b]) => a.localeCompare(b)) 70 .map(([model, configs]) => ({ 71 name: model, 72 children: Object.entries(configs) 73 .sort(([a], [b]) => a.localeCompare(b)) 74 .map(([configValue, configCells]) => { 75 const scoredCells = configCells.filter((c) => c.score.avg > 0); 76 const avgScore = 77 scoredCells.length > 0 78 ? scoredCells.reduce((s, c) => s + c.score.avg, 0) / scoredCells.length 79 : null; 80 81 return { 82 name: `${model} / ${configValue}`, 83 displayName: `${model} / ${configValue}`, 84 size: configCells.length, 85 avgScore, 86 avgScorePct: 87 avgScore !== null ? `${(avgScore * 100).toFixed(0)}%` : "--", 88 model, 89 configValue, 90 color: scoreColor(avgScore), 91 }; 92 }), 93 })); 94 } 95 96 function CustomContent(props: TreemapNode): React.ReactElement { 97 const { x, y, width, height, depth, name } = props; 98 99 // Only render leaf nodes (depth === 2 in a two-level hierarchy via 'flat' type) 100 // depth 1 = model group, depth 2 = leaf 101 if (depth < 2) return <g />; 102 103 const avgScorePct = (props as unknown as LeafData).avgScorePct ?? "--"; 104 const count = (props as unknown as LeafData).size ?? 0; 105 const color = (props as unknown as LeafData).color ?? "hsl(213 14% 30%)"; 106 107 const showText = width > 50 && height > 36; 108 const showCount = width > 50 && height > 50; 109 110 return ( 111 <g> 112 <rect 113 x={x} 114 y={y} 115 width={width} 116 height={height} 117 fill={color} 118 stroke="hsl(213 16% 12%)" 119 strokeWidth={2} 120 /> 121 {showText && ( 122 <> 123 <text 124 x={x + width / 2} 125 y={y + height / 2 - (showCount ? 8 : 0)} 126 textAnchor="middle" 127 dominantBaseline="central" 128 fill="hsl(213 27% 95%)" 129 style={{ 130 fontFamily: "'JetBrains Mono', monospace", 131 fontSize: "11px", 132 fontWeight: 600, 133 textTransform: "uppercase", 134 }} 135 > 136 {width > 100 ? name : (props as unknown as LeafData).configValue} 137 </text> 138 <text 139 x={x + width / 2} 140 y={y + height / 2 + 8} 141 textAnchor="middle" 142 dominantBaseline="central" 143 fill="hsl(213 27% 95%)" 144 style={{ 145 fontFamily: "'JetBrains Mono', monospace", 146 fontSize: "11px", 147 fontWeight: 500, 148 textTransform: "uppercase", 149 }} 150 > 151 {avgScorePct} 152 </text> 153 {showCount && ( 154 <text 155 x={x + width / 2} 156 y={y + height / 2 + 22} 157 textAnchor="middle" 158 dominantBaseline="central" 159 fill="hsla(213, 27%, 95%, 0.65)" 160 style={{ 161 fontFamily: "'JetBrains Mono', monospace", 162 fontSize: "11px", 163 fontWeight: 400, 164 textTransform: "uppercase", 165 }} 166 > 167 n={count} 168 </text> 169 )} 170 </> 171 )} 172 </g> 173 ); 174 } 175 176 function CustomTooltip({ 177 active, 178 payload, 179 }: { 180 active?: boolean; 181 payload?: Array<{ payload: TreemapNode }>; 182 }) { 183 if (!active || !payload || payload.length === 0) return null; 184 185 const node = payload[0].payload as unknown as LeafData; 186 if (!node.displayName) return null; 187 188 return ( 189 <div 190 style={{ 191 background: "hsl(217 16% 15.5%)", 192 border: "1px solid hsl(217 17% 28%)", 193 padding: "8px 12px", 194 fontFamily: "'JetBrains Mono', monospace", 195 fontSize: "11px", 196 textTransform: "uppercase", 197 letterSpacing: "0.5px", 198 }} 199 > 200 <div style={{ fontWeight: 600, marginBottom: 4, color: "hsl(213 27% 88%)" }}> 201 {node.displayName} 202 </div> 203 <div style={{ color: "hsl(213 14% 65%)" }}> 204 Score: {node.avgScorePct} 205 </div> 206 <div style={{ color: "hsl(213 14% 65%)" }}> 207 Cells: {node.size} 208 </div> 209 </div> 210 ); 211 } 212 213 export default function ConfigTreemap({ runs }: ConfigTreemapProps) { 214 const [secondaryAxis, setSecondaryAxis] = useState<AxisName>("prompt_style"); 215 216 const handleClick = useCallback( 217 (node: TreemapNode) => { 218 const leaf = node as unknown as LeafData; 219 if (leaf.model && leaf.configValue) { 220 const params = new URLSearchParams(); 221 params.set("model", leaf.model); 222 params.set(secondaryAxis, leaf.configValue); 223 window.location.href = `/?${params.toString()}`; 224 } 225 }, 226 [secondaryAxis], 227 ); 228 229 if (runs.length === 0) { 230 return ( 231 <div 232 className="card" 233 style={{ 234 textAlign: "center", 235 padding: "40px", 236 color: "var(--text-muted)", 237 }} 238 > 239 No data for treemap. 240 </div> 241 ); 242 } 243 244 const treeData = buildTreeData(runs, secondaryAxis); 245 246 return ( 247 <div 248 className="card" 249 style={{ 250 background: "var(--surface-1)", 251 border: "1px solid var(--border)", 252 borderRadius: 0, 253 padding: "20px", 254 }} 255 > 256 <div 257 style={{ 258 display: "flex", 259 justifyContent: "space-between", 260 alignItems: "center", 261 marginBottom: "16px", 262 }} 263 > 264 <h3 style={{ margin: 0 }}>Configuration Treemap</h3> 265 <div className="filter-group"> 266 <label htmlFor="treemap-axis">Group by</label> 267 <select 268 id="treemap-axis" 269 value={secondaryAxis} 270 onChange={(e) => setSecondaryAxis(e.target.value as AxisName)} 271 > 272 {SECONDARY_AXES.map((axis) => ( 273 <option key={axis} value={axis}> 274 {axis} 275 </option> 276 ))} 277 </select> 278 </div> 279 </div> 280 281 <div 282 style={{ 283 display: "flex", 284 gap: "16px", 285 marginBottom: "12px", 286 fontSize: "11px", 287 fontFamily: "var(--font-mono)", 288 textTransform: "uppercase", 289 letterSpacing: "0.5px", 290 color: "var(--text-muted)", 291 }} 292 > 293 <span> 294 <span 295 style={{ 296 display: "inline-block", 297 width: 10, 298 height: 10, 299 background: "hsl(92 28% 45%)", 300 marginRight: 4, 301 verticalAlign: "middle", 302 }} 303 /> 304 {">"} 60% 305 </span> 306 <span> 307 <span 308 style={{ 309 display: "inline-block", 310 width: 10, 311 height: 10, 312 background: "hsl(40 71% 50%)", 313 marginRight: 4, 314 verticalAlign: "middle", 315 }} 316 /> 317 30-60% 318 </span> 319 <span> 320 <span 321 style={{ 322 display: "inline-block", 323 width: 10, 324 height: 10, 325 background: "hsl(355 52% 48%)", 326 marginRight: 4, 327 verticalAlign: "middle", 328 }} 329 /> 330 {"<"} 30% 331 </span> 332 </div> 333 334 <ResponsiveContainer width="100%" height={400}> 335 <Treemap 336 data={treeData} 337 dataKey="size" 338 nameKey="name" 339 type="flat" 340 content={CustomContent} 341 onClick={handleClick} 342 isAnimationActive={false} 343 stroke="hsl(213 16% 12%)" 344 > 345 <Tooltip content={<CustomTooltip />} /> 346 </Treemap> 347 </ResponsiveContainer> 348 </div> 349 ); 350 }