// ===================================================================== // 京剧剧本可视分析 — Main App // Coordinates 6 panels through shared state ("linked brushing") // ===================================================================== const DATA = window.JJ_DATA; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "palette": "vermillion", "density": "comfortable", "showAnnotations": true, "networkLayout": "force", "dimNonSelected": true }/*EDITMODE-END*/; function App() { const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); // ---- shared state ------------------------------------------------- const [activeGenres, setActiveGenres] = useState(new Set(["history","family","court"])); const [eraRange, setEraRange] = useState([1800, 1950]); const [selectedPlay, setSelectedPlay] = useState(null); const [selectedRole, setSelectedRole] = useState(null); // "sheng" or "sheng:老生" const [selectedChar, setSelectedChar] = useState(null); // char id within play, or play:char key cross-play const [selectedCharData, setSelectedCharData] = useState(null); const [hoverChar, setHoverChar] = useState(null); const [themeFilter, setThemeFilter] = useState(null); const [hoverGenre, setHoverGenre] = useState(null); // ---- derived: visible plays -------------------------------------- const visiblePlays = useMemo(() => { return DATA.plays.filter(p => activeGenres.has(p.genre) && p.era >= eraRange[0] && p.era <= eraRange[1] ); }, [activeGenres, eraRange]); // ensure selected play is still visible useEffect(() => { if (selectedPlay && !visiblePlays.find(p => p.id === selectedPlay)) { setSelectedPlay(null); setSelectedChar(null); setSelectedCharData(null); } }, [visiblePlays, selectedPlay]); const focusPlayData = selectedPlay ? DATA.plays.find(p => p.id === selectedPlay) : null; function toggleGenre(g) { const n = new Set(activeGenres); if (n.has(g)) n.delete(g); else n.add(g); if (n.size === 0) { n.add(g); return; } // never empty setActiveGenres(n); } function handleSelectPlay(id) { if (selectedPlay === id) { setSelectedPlay(null); setSelectedChar(null); setSelectedCharData(null); } else { setSelectedPlay(id); setSelectedChar(null); setSelectedCharData(null); } } function handleSelectChar(charKey, nodeData) { if (!charKey) { setSelectedChar(null); setSelectedCharData(null); return; } setSelectedChar(charKey); setSelectedCharData(nodeData); } // when hover from outside, set hoverChar // ------------------------------------------------------------------ return (
{/* LEFT COLUMN: play list (top) + sunburst (bottom) ----------- */}
{/* CENTER COLUMN: network (top) + narrative (bottom) ---------- */}
{/* RIGHT COLUMN: themes (top) + detail (bottom) -------------- */}
{/* FOOTER ----------------------------------------------------- */}
); } function Footer({ selectedPlay, selectedCharData, themeFilter, allThemes, visibleCount }) { const themeName = themeFilter ? allThemes.find(t => t.id === themeFilter)?.name : null; return (
LINKED BRUSHING ACTIVE
剧目 {visibleCount}
聚焦 {selectedPlay ? selectedPlay.title : "全部"}
人物 {selectedCharData ? selectedCharData.name : "—"}
主题 {themeName || "—"}
CHINAVIS · 2026
京剧剧本可视分析系统 v0.1
); } // Apply palette/density tweaks at the root function TweakApply({ tweaks }) { useEffect(() => { const root = document.documentElement; if (tweaks.palette === "ink") { root.style.setProperty("--vermillion", "#3a5a6e"); root.style.setProperty("--vermillion-2", "#5078a0"); root.style.setProperty("--gold", "#b8c8d8"); root.style.setProperty("--gold-dim", "#5a7080"); } else if (tweaks.palette === "gold") { root.style.setProperty("--vermillion", "#8a6a2e"); root.style.setProperty("--vermillion-2", "#d4a24c"); root.style.setProperty("--gold", "#e8c468"); root.style.setProperty("--gold-dim", "#8a6a2e"); } else { root.style.setProperty("--vermillion", "#c8341c"); root.style.setProperty("--vermillion-2", "#e04a2a"); root.style.setProperty("--gold", "#d4a24c"); root.style.setProperty("--gold-dim", "#8a6a2e"); } }, [tweaks.palette]); useEffect(() => { document.body.style.fontSize = tweaks.density === "compact" ? "12px" : "13px"; }, [tweaks.density]); return null; } ReactDOM.createRoot(document.getElementById("root")).render();