f37679530a
收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、 季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。 导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
183 lines
5.0 KiB
React
183 lines
5.0 KiB
React
import { useMemo } from 'react'
|
||
import { Empty } from 'antd'
|
||
import ReactECharts from 'echarts-for-react'
|
||
import * as echarts from 'echarts'
|
||
|
||
/**
|
||
* 题材对比 — 饼图(期数占比)+ 水平柱图(按平均份额排名)
|
||
* 右列通高,对齐 l3_report.html 原型
|
||
*/
|
||
|
||
const TOPIC_COLORS = {
|
||
'装备深解': '#5b8db8',
|
||
'历史纵深': '#e8a838',
|
||
'前沿科技': '#7aa874',
|
||
'横切类比': '#9b7eb8',
|
||
'人物牵引': '#d4816b',
|
||
'事件战例': '#9b7eb8',
|
||
}
|
||
|
||
/**
|
||
* 将 hex 颜色转为 rgba 字符串
|
||
*/
|
||
function hexToRgba(hex, alpha) {
|
||
const h = hex.replace('#', '')
|
||
const r = parseInt(h.substring(0, 2), 16)
|
||
const g = parseInt(h.substring(2, 4), 16)
|
||
const b = parseInt(h.substring(4, 6), 16)
|
||
return `rgba(${r},${g},${b},${alpha})`
|
||
}
|
||
|
||
function TopicCompare({ episodes }) {
|
||
// 过滤有 program_format 的期次
|
||
const validEpisodes = useMemo(() => {
|
||
if (!episodes || !episodes.length) return []
|
||
return episodes.filter((ep) => ep.program_format != null && ep.program_format !== '')
|
||
}, [episodes])
|
||
|
||
// 按 program_format 分组统计
|
||
const topicStats = useMemo(() => {
|
||
if (!validEpisodes.length) return null
|
||
|
||
const groupMap = {}
|
||
validEpisodes.forEach((ep) => {
|
||
const format = ep.program_format
|
||
if (!groupMap[format]) {
|
||
groupMap[format] = []
|
||
}
|
||
groupMap[format].push(ep)
|
||
})
|
||
|
||
return Object.entries(groupMap).map(([name, eps]) => {
|
||
const count = eps.length
|
||
const withShare = eps.filter((ep) => ep.audience_share != null)
|
||
const avgShare =
|
||
withShare.length > 0
|
||
? withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / withShare.length
|
||
: 0
|
||
return {
|
||
name,
|
||
count,
|
||
avgShare: Number(avgShare.toFixed(4)),
|
||
}
|
||
})
|
||
}, [validEpisodes])
|
||
|
||
// 饼图配置
|
||
const pieOption = useMemo(() => {
|
||
if (!topicStats || !topicStats.length) return null
|
||
|
||
// 按期数排序
|
||
const sorted = [...topicStats].sort((a, b) => b.count - a.count)
|
||
const data = sorted.map((t) => ({ name: t.name, value: t.count }))
|
||
const colorArr = sorted.map((t) => TOPIC_COLORS[t.name] || '#999')
|
||
|
||
return {
|
||
tooltip: {
|
||
trigger: 'item',
|
||
formatter: (params) => {
|
||
return params.name + '<br/>期数:' + params.value + ' 期(' + params.percent + '%)'
|
||
},
|
||
},
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
center: ['50%', '55%'],
|
||
data,
|
||
color: colorArr,
|
||
label: {
|
||
fontSize: 11,
|
||
formatter: '{b}\n{c}期 ({d}%)',
|
||
},
|
||
emphasis: {
|
||
label: { fontSize: 14, fontWeight: 'bold' },
|
||
},
|
||
},
|
||
],
|
||
}
|
||
}, [topicStats])
|
||
|
||
// 水平柱图配置
|
||
const barOption = useMemo(() => {
|
||
if (!topicStats || !topicStats.length) return null
|
||
|
||
// 按平均份额升序排列(最低在下,最高在上)
|
||
const sorted = [...topicStats].sort((a, b) => a.avgShare - b.avgShare)
|
||
const yData = sorted.map((t) => t.name)
|
||
const countMap = {}
|
||
sorted.forEach((t) => {
|
||
countMap[t.name] = t.count
|
||
})
|
||
|
||
// 每根柱子渐变填充 + 胶囊圆角,对齐原型 LinearGradient 写法
|
||
const barData = sorted.map((t) => {
|
||
const color = TOPIC_COLORS[t.name] || '#999'
|
||
return {
|
||
value: t.avgShare,
|
||
itemStyle: {
|
||
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||
{ offset: 0, color: hexToRgba(color, 0.15) },
|
||
{ offset: 1, color },
|
||
]),
|
||
borderRadius: [0, 10, 10, 0],
|
||
},
|
||
}
|
||
})
|
||
|
||
// x 轴范围
|
||
const values = sorted.map((t) => t.avgShare)
|
||
const dataMin = Math.min(...values)
|
||
const dataMax = Math.max(...values)
|
||
const xMin = Math.max(0, Math.floor((dataMin - 0.1) * 10) / 10)
|
||
const xMax = Math.ceil((dataMax + 0.1) * 10) / 10
|
||
|
||
return {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: (params) => {
|
||
const name = params[0].name
|
||
const count = countMap[name] || 0
|
||
return name + '<br/>平均份额:' + Number(params[0].value).toFixed(3) + '<br/>期数:' + count
|
||
},
|
||
},
|
||
grid: { left: 80, right: 30, top: 10, bottom: 30 },
|
||
xAxis: {
|
||
type: 'value',
|
||
min: xMin,
|
||
max: xMax,
|
||
},
|
||
yAxis: {
|
||
type: 'category',
|
||
data: yData,
|
||
axisLabel: { fontSize: 12 },
|
||
},
|
||
series: [
|
||
{
|
||
type: 'bar',
|
||
data: barData,
|
||
barWidth: '50%',
|
||
label: {
|
||
show: true,
|
||
position: 'right',
|
||
fontSize: 11,
|
||
formatter: (p) => p.value.toFixed(3),
|
||
},
|
||
},
|
||
],
|
||
}
|
||
}, [topicStats])
|
||
|
||
if (!validEpisodes.length) {
|
||
return <Empty description="暂无题材标签数据" />
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<ReactECharts option={pieOption} style={{ height: 280 }} notMerge />
|
||
<ReactECharts option={barOption} style={{ height: 280 }} notMerge />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default TopicCompare |