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
|