"""Attention vs Density 4-Quadrant Scatter. X-axis: attention_score Y-axis: citation_density Quadrant lines at median values. Point size: fanout_count, hover: cluster_label. """ import statistics import streamlit as st import plotly.graph_objects as go # Quadrant colors QUAD_COLORS = { "high_attn_low_density": "#10B981", # Green - Opportunity "high_attn_high_density": "#3B82F6", # Blue - Competitive "low_attn_low_density": "#9CA3AF", # Gray - Niche "low_attn_high_density": "#EF4444", # Red - Crowded } def render_distribution(clusters: list[dict], frame: str = "all"): """Render attention vs density quadrant scatter chart.""" scored = [ c for c in clusters if c.get("attention_score") is not None and c.get("citation_density") is not None ] if not scored: st.info("스코어가 계산된 클러스터가 없습니다.") return # 4-Quadrant business interpretation (frame-specific) if frame == "demand": st.markdown(""" ChatGPT Demand 토픽을 **검색 빈도**(가로축)와 **출처 경쟁**(세로축)로 분류합니다: - **Opportunity** (우하단): 검색 빈도 높음 + 경쟁 낮음 → 콘텐츠 선점 기회 - **Competitive** (우상단): 검색 빈도 높음 + 경쟁 높음 → 차별화 필요 - **Niche** (좌하단): 검색 빈도 낮음 + 경쟁 낮음 → 틈새 영역 - **Crowded** (좌상단): 검색 빈도 낮음 + 경쟁 높음 → 포화 영역 """) elif frame == "supply": st.markdown(""" Gemini Supply 토픽을 **인용 빈도**(가로축)와 **출처 집중도**(세로축)로 분류합니다: - **Opportunity** (우하단): 인용 빈도 높음 + 출처 분산 → 새 출처 진입 기회 - **Competitive** (우상단): 인용 빈도 높음 + 출처 집중 → 기존 권위자 지배 - **Niche** (좌하단): 인용 빈도 낮음 + 출처 분산 → 틈새 영역 - **Crowded** (좌상단): 인용 빈도 낮음 + 출처 집중 → 포화 영역 """) else: st.markdown(""" 토픽을 **AI 관심도**(가로축)와 **경쟁 밀도**(세로축)로 분류합니다: - **Opportunity** (우하단): AI 관심 높음 + 경쟁 낮음 → 콘텐츠 선점 기회 - **Competitive** (우상단): AI 관심 높음 + 경쟁 높음 → 차별화 필요 - **Niche** (좌하단): AI 관심 낮음 + 경쟁 낮음 → 틈새 영역 - **Crowded** (좌상단): AI 관심 낮음 + 경쟁 높음 → 포화 영역 """) attns = [float(c["attention_score"]) for c in scored] densities = [float(c["citation_density"]) for c in scored] median_attn = statistics.median(attns) median_density = statistics.median(densities) # Classify each point into quadrant xs, ys, sizes, colors, hover_texts = [], [], [], [], [] quadrant_counts = {"opportunity": 0, "competitive": 0, "niche": 0, "crowded": 0} for c in scored: attn = float(c["attention_score"]) density = float(c["citation_density"]) fanout_count = c.get("fanout_count", 10) label = c.get("cluster_label") or f"Cluster-{c['id'][:8]}" xs.append(attn) ys.append(density) sizes.append(max(5, min(35, fanout_count / 5))) if attn >= median_attn and density < median_density: color = QUAD_COLORS["high_attn_low_density"] quad = "Opportunity" quadrant_counts["opportunity"] += 1 elif attn >= median_attn and density >= median_density: color = QUAD_COLORS["high_attn_high_density"] quad = "Competitive" quadrant_counts["competitive"] += 1 elif attn < median_attn and density < median_density: color = QUAD_COLORS["low_attn_low_density"] quad = "Niche" quadrant_counts["niche"] += 1 else: color = QUAD_COLORS["low_attn_high_density"] quad = "Crowded" quadrant_counts["crowded"] += 1 colors.append(color) opp = float(c.get("opportunity_score", 0) or 0) hover_texts.append( f"{label}
" f"Attention: {attn:.4f}
" f"Density: {density:.4f}
" f"Opportunity: {opp:.4f}
" f"Fanouts: {fanout_count}
" f"Quadrant: {quad}" ) fig = go.Figure() # Data points fig.add_trace(go.Scatter( x=xs, y=ys, mode="markers", marker=dict( size=sizes, color=colors, opacity=0.7, line=dict(width=0.5, color="#333"), ), text=hover_texts, hoverinfo="text", showlegend=False, )) # Quadrant lines (add padding to avoid collapse when all values are identical) attn_span = max(attns) - min(attns) or 0.001 density_span = max(densities) - min(densities) or 0.1 x_range = [min(attns) - attn_span * 0.1, max(attns) + attn_span * 0.1] y_range = [min(densities) - density_span * 0.1, max(densities) + density_span * 0.1] fig.add_hline(y=median_density, line_dash="dash", line_color="#9CA3AF", opacity=0.5) fig.add_vline(x=median_attn, line_dash="dash", line_color="#9CA3AF", opacity=0.5) # Quadrant labels fig.add_annotation(x=x_range[1], y=y_range[0], text="🟢 Opportunity", showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_low_density"])) fig.add_annotation(x=x_range[1], y=y_range[1], text="🔵 Competitive", showarrow=False, font=dict(size=11, color=QUAD_COLORS["high_attn_high_density"])) fig.add_annotation(x=x_range[0], y=y_range[0], text="⚪ Niche", showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_low_density"])) fig.add_annotation(x=x_range[0], y=y_range[1], text="🔴 Crowded", showarrow=False, font=dict(size=11, color=QUAD_COLORS["low_attn_high_density"])) fig.update_layout( title="Attention vs Citation Density (4-Quadrant)", xaxis_title="Attention Score", yaxis_title="Citation Density", height=600, template="plotly_white", hoverlabel=dict(bgcolor="white", font_size=12), ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) # Quadrant summary with action guides st.markdown("#### Quadrant 요약") q1, q2, q3, q4 = st.columns(4) with q1: st.metric( "🟢 Opportunity", f"{quadrant_counts['opportunity']}개", help="AI 관심도 높음 + 경쟁 낮음 → 콘텐츠 선점 기회. " "이 토픽에 대한 전문 콘텐츠를 제작하면 AI 답변에 인용될 가능성이 높습니다.", ) with q2: st.metric( "🔵 Competitive", f"{quadrant_counts['competitive']}개", help="AI 관심도 높음 + 경쟁 높음 → 경쟁 치열. " "차별화된 콘텐츠나 전문성이 필요합니다.", ) with q3: st.metric( "⚪ Niche", f"{quadrant_counts['niche']}개", help="AI 관심도 낮음 + 경쟁 낮음 → 틈새 영역. " "시장이 성장하면 선점 효과를 볼 수 있습니다.", ) with q4: st.metric( "🔴 Crowded", f"{quadrant_counts['crowded']}개", help="AI 관심도 낮음 + 경쟁 높음 → 포화 영역. " "새로운 진출보다 기존 콘텐츠 유지에 집중하세요.", ) st.caption(f"Median Attention: {median_attn:.4f} | Median Density: {median_density:.4f}")