// ===================================================================== // Character relationship network — Task 2 // - When a play is selected: detailed network of that play // - When no play: cross-play "constellation" — character archetype clusters // ===================================================================== function NetworkView({ plays, focusPlay, selectedRole, selectedChar, onSelectChar, themeFilter, hoverChar, onHoverChar, layoutKind = "force" }) { const [wrap, size] = useSize(); const W = size.w || 720, H = size.h || 440; // build nodes/links const { nodes, links, clusters } = useMemo(() => { if (focusPlay) { const p = plays.find(x => x.id === focusPlay); if (!p) return { nodes: [], links: [], clusters: [] }; const ns = p.characters.map(c => ({ id: c.id, key: p.id + ":" + c.id, name: c.name, role: c.role, sub: c.sub, themes: c.themes || [], traits: c.traits || [], identity: c.identity, playId: p.id, playTitle: p.title, weight: 1 + (c.themes||[]).length, gender: c.gender, age: c.age, presence: c.presence, })); const ls = p.edges.map(([a,b,w,k]) => ({ source: a, target: b, w, k })); return { nodes: ns, links: ls, clusters: [] }; } else { // multi-play "constellation": each play is a small cluster on a 3x3 grid const ns = []; const cl = []; plays.forEach((p, pi) => { // sort chars by weight, take top 4 const top = [...p.characters].sort((a,b) => (b.themes?.length||0) - (a.themes?.length||0)).slice(0, 4); cl.push({ playId: p.id, playTitle: p.title, genre: p.genre, era: p.era, count: top.length }); top.forEach((c, i) => ns.push({ id: p.id + ":" + c.id, key: p.id + ":" + c.id, name: c.name, role: c.role, sub: c.sub, themes: c.themes || [], traits: c.traits, identity: c.identity, playId: p.id, playTitle: p.title, weight: 1 + (c.themes||[]).length, gender: c.gender, age: c.age, presence: c.presence, cluster: pi, ordInCluster: i, })); }); const ls = []; // intra-cluster: own edges (limited to top4) plays.forEach(p => p.edges.forEach(([a,b,w,k]) => { const top4 = [...p.characters].sort((x,y) => (y.themes?.length||0) - (x.themes?.length||0)).slice(0, 4).map(c => c.id); if (top4.includes(a) && top4.includes(b)) ls.push({ source: p.id + ":" + a, target: p.id + ":" + b, w, k, intra: true }); })); // inter-cluster: shared theme of >= 2 for (let i = 0; i < ns.length; i++) { for (let j = i+1; j < ns.length; j++) { if (ns[i].playId === ns[j].playId) continue; const shared = ns[i].themes.filter(t => ns[j].themes.includes(t)); if (shared.length >= 2 && ns[i].role === ns[j].role) { ls.push({ source: ns[i].key, target: ns[j].key, w: 0.2 + shared.length * 0.12, k: "theme", intra: false, shared }); } } } return { nodes: ns, links: ls, clusters: cl }; } }, [plays, focusPlay]); // simulation const simRef = useRef(null); const [tick, setTick] = useState(0); const positionsRef = useRef({}); const [ready, setReady] = useState(false); useEffect(() => { if (!nodes.length || !W) return; const rng = mulberry32(focusPlay ? focusPlay.charCodeAt(0) * 17 + nodes.length : 1234 + nodes.length); const pos = {}; // cluster anchors (only when no focusPlay) const anchors = {}; if (!focusPlay && clusters.length) { const cols = Math.min(5, Math.ceil(Math.sqrt(clusters.length * (W / H)))); const rows = Math.ceil(clusters.length / cols); const cellW = W / cols; const cellH = (H - 40) / rows; clusters.forEach((cl, i) => { const cx = (i % cols) * cellW + cellW / 2; const cy = Math.floor(i / cols) * cellH + cellH / 2 + 20; anchors[cl.playId] = { x: cx, y: cy, r: Math.min(cellW, cellH) * 0.32 }; }); } nodes.forEach((n, i) => { if (!focusPlay && anchors[n.playId]) { const A = anchors[n.playId]; const a = (n.ordInCluster / 4) * Math.PI * 2; pos[n.id] = { x: A.x + Math.cos(a) * A.r * 0.5 + (rng() - 0.5) * 6, y: A.y + Math.sin(a) * A.r * 0.5 + (rng() - 0.5) * 6, vx: 0, vy: 0, }; } else { const a = (i / nodes.length) * Math.PI * 2; const r = Math.min(W, H) * 0.30; pos[n.id] = { x: W/2 + Math.cos(a) * r + (rng() - 0.5) * 30, y: H/2 + Math.sin(a) * r + (rng() - 0.5) * 30, vx: 0, vy: 0, }; } }); positionsRef.current = pos; setReady(true); // run a few hundred sim steps in a RAF loop let raf; let iter = 0; const maxIter = focusPlay ? 350 : 220; function step() { const alpha = Math.max(0.02, 0.6 * (1 - iter / maxIter)); const ids = nodes.map(n => n.id); const nodeById = {}; nodes.forEach(n => { nodeById[n.id] = n; }); // repulsion — only within same cluster when multi-play for (let i = 0; i < ids.length; i++) { for (let j = i+1; j < ids.length; j++) { const a = pos[ids[i]], b = pos[ids[j]]; const sameCluster = focusPlay || (nodeById[ids[i]].playId === nodeById[ids[j]].playId); let dx = b.x - a.x, dy = b.y - a.y; let d2 = dx*dx + dy*dy + 0.1; const d = Math.sqrt(d2); // mild global repulsion + strong intra-cluster repulsion const rep = (sameCluster ? 1500 : 60) / d2; const fx = (dx / d) * rep; const fy = (dy / d) * rep; a.vx -= fx * alpha; a.vy -= fy * alpha; b.vx += fx * alpha; b.vy += fy * alpha; } } // links spring links.forEach(L => { const a = pos[L.source], b = pos[L.target]; if (!a || !b) return; let dx = b.x - a.x, dy = b.y - a.y; let d = Math.sqrt(dx*dx + dy*dy) + 0.1; const desired = L.intra === false ? 240 : (focusPlay ? 110 : 60); const k = L.intra === false ? 0.005 : 0.06; const f = (d - desired) * k * L.w; const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx * alpha; a.vy += fy * alpha; b.vx -= fx * alpha; b.vy -= fy * alpha; }); // anchor pull (multi-play only) if (!focusPlay) { nodes.forEach(n => { const A = anchors[n.playId]; if (!A) return; const p = pos[n.id]; p.vx += (A.x - p.x) * 0.06 * alpha; p.vy += (A.y - p.y) * 0.06 * alpha; }); } else { // centering ids.forEach(id => { const n = pos[id]; n.vx += (W/2 - n.x) * 0.005 * alpha; n.vy += (H/2 - n.y) * 0.005 * alpha; }); } // damping & integrate ids.forEach(id => { const n = pos[id]; n.vx *= 0.78; n.vy *= 0.78; n.x += n.vx; n.y += n.vy; n.x = Math.max(20, Math.min(W-20, n.x)); n.y = Math.max(34, Math.min(H-20, n.y)); }); iter++; setTick(t => t+1); if (iter < maxIter) raf = requestAnimationFrame(step); } raf = requestAnimationFrame(step); return () => { if (raf) cancelAnimationFrame(raf); }; }, [nodes.length, focusPlay, W, H, clusters.length]); const pos = positionsRef.current; function isDimmed(n) { if (selectedRole) { if (selectedRole.includes(":")) { const [r, s] = selectedRole.split(":"); if (n.role !== r || n.sub !== s) return true; } else { if (n.role !== selectedRole) return true; } } if (themeFilter && !(n.themes||[]).includes(themeFilter)) return true; return false; } return (
03人 物 关 系 网 络
{focusPlay ? `FOCUSED · ${nodes.length} CHARS · ${links.length} TIES` : `CROSS-PLAY · ${nodes.length} ARCHETYPES`}
{/* edge color legend (top-right corner) */} 关系 {[ ["family","#d4a24c","亲族"], ["ally","#b8c8d8","盟友"], ["foe","#c8341c","敌对"], ["rom","#f0e6d6","婚恋"], ["master","#5e8a6a","主仆"], ].map(([k, c, lbl], i) => ( {lbl} ))} {!focusPlay && ( ※ 灰虚线 = 跨剧目共享主题 )} {/* cluster labels (multi-play) */} {!focusPlay && clusters.map((cl, i) => { // approximate centroid from positions const ids = nodes.filter(n => n.playId === cl.playId).map(n => n.id); const ps = ids.map(id => pos[id]).filter(Boolean); if (!ps.length) return null; const cx = ps.reduce((s,p)=>s+p.x,0)/ps.length; const cy = ps.reduce((s,p)=>s+p.y,0)/ps.length; return ( 《{cl.playTitle}》 ); })} {ready && ( <> {/* links */} {links.map((L, i) => { const a = pos[L.source], b = pos[L.target]; if (!a || !b) return null; const cls = linkClassFromKind(L.k); const involve = selectedChar && (L.source === selectedChar || L.target === selectedChar); const sourceN = nodes.find(n => n.id === L.source); const targetN = nodes.find(n => n.id === L.target); const dimByRole = (sourceN && isDimmed(sourceN)) || (targetN && isDimmed(targetN)); const op = involve ? 0.95 : (dimByRole ? 0.08 : (L.intra === false ? 0.22 : 0.55)); const sw = involve ? 2.2 + L.w * 1.6 : 0.6 + L.w * 1.4; return ( ); })} {/* nodes */} {nodes.map(n => { const p = pos[n.id]; if (!p) return null; const dim = isDimmed(n); const sel = selectedChar === n.id; const hov = hoverChar && hoverChar.id === n.id; const r = sel ? 22 : (focusPlay ? 14 + Math.min(8, n.weight) : 10 + Math.min(4, n.weight)); return ( onHoverChar({ ...n })} onMouseLeave={() => onHoverChar(null)} onClick={() => onSelectChar(sel ? null : n.id, n)} style={{ opacity: dim ? 0.2 : 1 }} > {/* face-paint ring */} {/* 净 face: ink crossbar */} {n.role === "jing" && ( <> )} {/* 丑 face: white nose patch */} {n.role === "chou" && ( )} {/* 旦 face: rouge spots */} {n.role === "dan" && ( <> )} {/* 生 (老生 has beard mark) */} {n.role === "sheng" && n.sub === "老生" && ( )} {n.name} {(sel || hov) && ( {n.sub} )} ); })} )} {/* placeholder when empty */} {!nodes.length && ( ※ 无匹配剧目 ※ )}
); } window.NetworkView = NetworkView;