// ===================================================================== // 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 (
05主 题 构 成
{plays.length} PLAYS · {allThemes.length} TOPICS
{focused ? `《${focused.title}》主题强度` : "主题热度 · 跨剧目"}
{/* theme bars */}
{sortedThemes.map(t => { const v = focused ? (focused.themes.includes(t.id) ? 1 : 0) + focused.characters.filter(c => (c.themes||[]).includes(t.id)).length * 0.18 : themeCounts[t.id] || 0; const max = focused ? 3 : maxCount; const pct = Math.max(0, Math.min(1, v / max)); const sel = themeFilter === t.id; const dim = themeFilter && !sel; return (
onSetThemeFilter(sel ? null : t.id)} style={{ display:"grid", gridTemplateColumns:`${barLabelW}px 1fr 22px`, alignItems:"center", gap:6, padding:"2px 0", cursor:"pointer", opacity: dim ? 0.32 : 1, }} >
{t.name}
{sel &&
}
{v < 1 ? v.toFixed(1) : Math.round(v)}
); })}
{/* Task 5: role × theme cross-link heatmap */}
行 当 × 主 题 · 关联强度
{/* column labels — short 名 */} {allThemes.map((t, i) => ( onSetThemeFilter(themeFilter === t.id ? null : t.id)}> {t.name[0]} ))} {["sheng","dan","jing","chou"].map((r, ri) => ( onSelectRole(selectedRole === r ? null : r)}> {roleLabel(r)} {allThemes.map((t, ci) => { const v = matrix[r][t.id] || 0; const op = v === 0 ? 0.06 : 0.15 + 0.85 * (v / maxCell); const sel = (selectedRole === r) || (themeFilter === t.id); return ( { onSetThemeFilter(themeFilter === t.id ? null : t.id); }}> {`${roleLabel(r)} × ${t.name}: ${v}`} ); })} ))}
※ 见旦角主载 爱情·亲情; 净 主载 权谋·清正; 生 跨忠义·智谋
); } window.ThemePanel = ThemePanel;