"""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}")