// ===================================================================== // Theme panel — Task 3 + Task 5 // Top half: theme prevalence bars (clickable filter) // Bottom half: 行当 × 主题 association heatmap (the cross-link, Task 5) // ===================================================================== function ThemePanel({ plays, allPlays, allThemes, focusPlay, themeFilter, onSetThemeFilter, selectedRole, onSelectRole, roleThemeMatrix }) { const [wrap, size] = useSize(); const W = size.w || 296, H = size.h || 320; // theme counts across visible plays const themeCounts = useMemo(() => { const c = {}; allThemes.forEach(t => c[t.id] = 0); plays.forEach(p => { // weight each play's themes by character count too p.themes.forEach(tid => { c[tid] = (c[tid] || 0) + 1; }); p.characters.forEach(ch => (ch.themes||[]).forEach(tid => { c[tid] = (c[tid]||0) + 0.4; })); }); return c; }, [plays, allThemes]); // for focused play: theme composition (which themes + intensity) const focused = focusPlay ? plays.find(p => p.id === focusPlay) : null; // compute filtered role × theme matrix from visible plays const matrix = useMemo(() => { const m = {}; ["sheng","dan","jing","chou"].forEach(r => { m[r] = {}; allThemes.forEach(t => m[r][t.id] = 0); }); plays.forEach(p => p.characters.forEach(c => { (c.themes||[]).forEach(tid => { if (m[c.role]) m[c.role][tid] = (m[c.role][tid]||0) + 1; }); })); return m; }, [plays, allThemes]); const sortedThemes = [...allThemes].sort((a,b) => (themeCounts[b.id]||0) - (themeCounts[a.id]||0)); const maxCount = Math.max(...Object.values(themeCounts), 1); const maxCell = Math.max(1, ...["sheng","dan","jing","chou"].flatMap(r => allThemes.map(t => matrix[r][t.id]))); // layout const barH = 14; const barGap = 2; const barLabelW = 38; const barAreaH = sortedThemes.length * (barH + barGap); // heatmap geometry const cellW = (W - 14 - 26) / allThemes.length; const cellH = 14; const heatY = 32 + barAreaH + 28; return (