// ===================================================================== // Narrative arc — Task 4 // Shows dramatic-tension curve per act for the focused play (or // overlay curves for visible plays). Annotated with selected character's // presence per act. // ===================================================================== function NarrativeArc({ plays, focusPlay, selectedChar, selectedCharData, hoverChar, themeFilter }) { const [wrap, size] = useSize(); const W = size.w || 720, H = size.h || 200; const pad = { l: 56, r: 28, t: 26, b: 38 }; const iw = W - pad.l - pad.r; const ih = H - pad.t - pad.b; // smooth curve through points function smoothPath(pts) { if (pts.length < 2) return ""; let d = `M ${pts[0][0]} ${pts[0][1]}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i-1] || pts[i]; const p1 = pts[i], p2 = pts[i+1], p3 = pts[i+2] || pts[i+1]; const cp1x = p1[0] + (p2[0] - p0[0]) / 6; const cp1y = p1[1] + (p2[1] - p0[1]) / 6; const cp2x = p2[0] - (p3[0] - p1[0]) / 6; const cp2y = p2[1] - (p3[1] - p1[1]) / 6; d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2[0]} ${p2[1]}`; } return d; } if (!plays.length) return null; // visible plays curve overlay; focused gets full opacity const visible = plays; const focused = focusPlay ? plays.find(p => p.id === focusPlay) : null; // x by act index normalized (0..1) so plays of different act counts align function pts(play) { const n = play.tension.length; return play.tension.map((v, i) => [ pad.l + (i / (n - 1)) * iw, pad.t + (1 - v) * ih, ]); } return (
04叙 事 节 奏 弧 线
DRAMATIC TENSION · {focused ? `${focused.acts.length} ACTS` : `${visible.length} PLAYS OVERLAID`}
{/* y gridlines */} {[0, 0.25, 0.5, 0.75, 1].map(v => { const y = pad.t + (1 - v) * ih; return ( {v === 0 ? "起" : v === 0.25 ? "承" : v === 0.5 ? "" : v === 0.75 ? "转" : "合"} ); })} {/* axis labels */} 张力 TENSION {/* non-focused curves */} {!focused && visible.map(p => ( ))} {/* focused curve area + line */} {focused && (() => { const ps = pts(focused); const area = `${smoothPath(ps)} L ${ps[ps.length-1][0]} ${pad.t + ih} L ${ps[0][0]} ${pad.t + ih} Z`; return ( {ps.map(([x,y], i) => { const peak = i === ps.findIndex((_,j) => focused.tension[j] === Math.max(...focused.tension)); return ( {/* presence dot for selected char */} {selectedCharData && selectedCharData.playId === focused.id && selectedCharData.presence && selectedCharData.presence[i] === 1 && ( )} {focused.acts[i]} 第{["一","二","三","四","五","六","七","八","九"][i]}场 {peak && ( 高潮 )} ); })} ); })()} {/* legend (bottom-left): which plays are overlaid when no focus */} {!focused && ( ※ 选剧目以聚焦 · 红线 历史 / 金线 家庭 / 翠线 公案 )}
); } window.NarrativeArc = NarrativeArc;