// ===================================================================== // 行当 Sunburst — Task 1: role-type classification // Inner ring: 生 旦 净 丑 ; Outer ring: 老生/小生/... // Selection brushes across to network + theme heatmap // ===================================================================== function RoleSunburst({ plays, focusPlay, selectedRole, onSelectRole, hoverChar, characterIndex }) { const [wrap, size] = useSize(); // collect chars matching filter const chars = useMemo(() => { const list = []; plays.forEach(p => { if (focusPlay && p.id !== focusPlay) return; p.characters.forEach(c => list.push({ ...c, playId: p.id, playTitle: p.title, genre: p.genre })); }); return list; }, [plays, focusPlay]); // aggregate const tree = useMemo(() => { const t = {}; chars.forEach(c => { if (!t[c.role]) t[c.role] = { total: 0, subs: {} }; t[c.role].total++; if (!t[c.role].subs[c.sub]) t[c.role].subs[c.sub] = { total: 0, chars: [] }; t[c.role].subs[c.sub].total++; t[c.role].subs[c.sub].chars.push(c); }); return t; }, [chars]); const roleOrder = ["sheng","dan","jing","chou"]; const totalChars = chars.length || 1; const W = size.w || 360; const H = size.h || 280; const cx = W / 2, cy = H / 2 + 4; const rInner = 28; const rMid = Math.min(W, H) * 0.30; const rOuter = Math.min(W, H) * 0.46; // build arcs let acc = 0; const innerArcs = []; const outerArcs = []; roleOrder.forEach(role => { if (!tree[role]) return; const span = tree[role].total / totalChars; const a0 = acc * Math.PI * 2 - Math.PI / 2; const a1 = (acc + span) * Math.PI * 2 - Math.PI / 2; innerArcs.push({ role, a0, a1, count: tree[role].total }); let subAcc = acc; Object.entries(tree[role].subs).forEach(([sub, info]) => { const subSpan = info.total / totalChars; const sa0 = subAcc * Math.PI * 2 - Math.PI / 2; const sa1 = (subAcc + subSpan) * Math.PI * 2 - Math.PI / 2; outerArcs.push({ role, sub, a0: sa0, a1: sa1, count: info.total, chars: info.chars }); subAcc += subSpan; }); acc += span; }); function arcPath(a0, a1, rIn, rOut) { const sx0 = cx + rOut * Math.cos(a0), sy0 = cy + rOut * Math.sin(a0); const sx1 = cx + rOut * Math.cos(a1), sy1 = cy + rOut * Math.sin(a1); const ex0 = cx + rIn * Math.cos(a1), ey0 = cy + rIn * Math.sin(a1); const ex1 = cx + rIn * Math.cos(a0), ey1 = cy + rIn * Math.sin(a0); const large = (a1 - a0) > Math.PI ? 1 : 0; return `M ${sx0} ${sy0} A ${rOut} ${rOut} 0 ${large} 1 ${sx1} ${sy1} L ${ex0} ${ey0} A ${rIn} ${rIn} 0 ${large} 0 ${ex1} ${ey1} Z`; } return (
02行 当 分 类
SUNBURST · {chars.length} ROLES
{/* outer ring */} {outerArcs.map((a,i) => { const dim = selectedRole && selectedRole !== a.role && selectedRole !== a.role + ":" + a.sub; const active = selectedRole === a.role + ":" + a.sub; const hoverMatch = hoverChar && a.chars.some(c => c.id === hoverChar.id && c.playId === hoverChar.playId); return ( onSelectRole(selectedRole === a.role + ":" + a.sub ? null : a.role + ":" + a.sub)} /> ); })} {/* inner ring */} {innerArcs.map((a,i) => { const dim = selectedRole && !(selectedRole === a.role || (selectedRole.startsWith(a.role + ":"))); const active = selectedRole === a.role; return ( onSelectRole(selectedRole === a.role ? null : a.role)} /> {/* big role char */} {roleLabel(a.role)} ); })} {/* outer labels */} {outerArcs.map((a,i) => { const mid = (a.a0 + a.a1)/2; const tr = rOuter + 11; const tx = cx + tr * Math.cos(mid); const ty = cy + tr * Math.sin(mid); const anchor = Math.cos(mid) > 0.15 ? "start" : Math.cos(mid) < -0.15 ? "end" : "middle"; const dim = selectedRole && selectedRole !== a.role && selectedRole !== a.role + ":" + a.sub; return ( {a.sub}{a.count} ); })} {/* center hub */} ROLES {chars.length}
); } window.RoleSunburst = RoleSunburst;