SocialNetworks_duan/src/views/LinkPrediction/socialGroups/components/detailNode.vue
2025-08-18 17:51:58 +08:00

829 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="detailNode-component">
<img src="@/assets/images/icon/goback.png" alt="" class="goback" @click="handleGoback" />
<div class="graph-container" id="container"></div>
<div class="statistic-container">
<div
class="statistics-item"
v-for="item in socialGroupsStore.statisticsDetailList"
:key="item.id"
>
<img :src="item.icon" class="icon" />
<div class="name">{{ item.name }}:&nbsp;</div>
<div class="count">{{ item.count }}</div>
</div>
</div>
<div class="time-axis">
<div class="time">{{ TansTimestamp(startTime, "YYYY.MM.DD HH:mm:ss") }}</div>
<div class="axis" ref="axisRef" @pointerdown="handlePointerDown">
<div class="progress-bar" :style="trackStyle"></div>
<el-tooltip
v-for="(time, index) in timePointsWithPositions"
:key="index"
:content="TansTimestamp(time.time, 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div
class="time-sign"
:style="{ left: `${time.position}px` }"
@click="handleTimePointClick(time.timeStr)"
></div>
</el-tooltip>
<el-tooltip
:content="TansTimestamp(timeList[timeList.length - 1], 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div class="active-needle" :style="{ left: `${lastPosition}px` }"></div>
</el-tooltip>
<el-tooltip
:content="TansTimestamp(currentTime, 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div
class="timeLine-point"
:style="{ left: `${currentPosition}px` }"
@pointerdown.stop="handlePointPointerDown"
></div>
</el-tooltip>
</div>
<div class="time">{{ TansTimestamp(endTime, "YYYY.MM.DD HH:mm:ss") }}</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, onUnmounted, computed, watch, nextTick } from "vue"
import { TansTimestamp, getAvatarUrl } from "@/utils/transform"
import nodeHoverImg from "@/assets/images/nodeHover.png"
import * as echarts from "echarts"
import { storeToRefs } from "pinia"
import { useSocialGroupsStore } from "@/store/linkPrediction/index"
const socialGroupsStore = useSocialGroupsStore()
const { communityDetailNodeList } = storeToRefs(socialGroupsStore)
const { timeList } = storeToRefs(socialGroupsStore)
const { curHighlightUserIdList } = storeToRefs(socialGroupsStore)
// 添加对curSelecedGroupIds的监听用于检测列表项切换
const { curSelecedGroupIds } = storeToRefs(socialGroupsStore)
// 添加对curSelecedGroupIds的watch确保切换列表项时重置时间轴
watch(
curSelecedGroupIds,
(newIds) => {
if (newIds && newIds.length > 0) {
// 重置时间轴位置到起点
currentPosition.value = 0
currentTime.value = new Date("2024-05-16 16:56:04")
// 重新开始自动播放
pause() // 先停止可能正在运行的计时器
play() // 重新开始播放
}
},
{ deep: true }
)
// 用于监听是从 列表 点进来的还是从 边 点进来的
const { clickEvent } = storeToRefs(socialGroupsStore)
const chartsData = ref({})
const emit = defineEmits(["click:goback", "click:openDialog"])
const handleGoback = () => {
pause()
emit("click:goback", "CommunityNode")
}
//当点击时间轴的时候communityDetailNodeList改变重新更新关系图
watch(
communityDetailNodeList,
(newValue) => {
initChart()
},
{ deep: true }
)
// 监听需要高亮的用户id
watch(
curHighlightUserIdList,
(newHiglightUserIdList) => {
if (newHiglightUserIdList.length != 0) {
nextTick(() => {
highLightUserNodes(newHiglightUserIdList)
})
}
},
{
deep: true,
immediate: true
}
)
// 时间轴相关数据
const startTime = ref(new Date("2024-05-16 16:56:04"))
const endTime = ref(new Date("2024-05-23 10:16:56"))
const currentTime = ref(new Date("2024-05-16 16:56:04")) // 当前选中的时间
const currentPosition = ref(0) // 初始位置(轴长度的一半)
const lastPosition = ref(0) // 时间列表最后的时间点的位置
const isPlaying = ref(false) // 是否自动播放
let playTimer = null
// 自动播放控制
const play = () => {
if (isPlaying.value) return
isPlaying.value = true
playTimer = setInterval(() => {
// 步进像素
const step = 4 // 每次移动4px可根据需要调整速度
if (currentPosition.value >= axisWidth) {
pause()
return
}
currentPosition.value = Math.min(axisWidth, currentPosition.value + step)
currentTime.value = getTimeFromPosition(currentPosition.value)
sendTimeChangeRequest()
}, 300) // 每300ms移动一次
}
// 发送请求逻辑封装
const sendTimeChangeRequest = () => {
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
if (socialGroupsStore.curRelationId == "") {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes
)
} else {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes,
socialGroupsStore.curRelationId
)
}
}
const pause = () => {
isPlaying.value = false
if (playTimer) {
clearInterval(playTimer)
playTimer = null
}
}
const axisRef = ref(null)
const isDragging = ref(false)
// 缓存时间计算相关的常量
const axisWidth = 426
const startTimeMs = startTime.value.getTime()
const endTimeMs = endTime.value.getTime()
const totalDuration = endTimeMs - startTimeMs
// 计算每个时间点的位置
const timePointsWithPositions = computed(() => {
// 确保 timeList 是数组
const list = Array.isArray(timeList.value) ? timeList.value : []
if (list.length === 0) return []
return list.map((timeStr) => {
const time = new Date(timeStr)
const timeMs = time.getTime()
const ratio = Math.max(0, Math.min(1, (timeMs - startTimeMs) / totalDuration))
const position = ratio * axisWidth
return { time, position, timeStr }
})
})
watch(timeList, (newList) => {
console.log("🔥 timeList 被更新了:", newList)
})
// watch来监听timeList变化并设置初始值
watch(
timePointsWithPositions,
(newTimePoints) => {
if (newTimePoints && newTimePoints.length > 0) {
// 始终将currentPosition设置为0时间轴起点
currentPosition.value = 0
currentTime.value = "2024-05-16 16:56:04"
lastPosition.value = newTimePoints[newTimePoints.length - 1].position
// 触发图更新
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes
)
}
},
{ immediate: true }
)
// 添加时间点点击事件处理函数
const handleTimePointClick = (timeStr) => {
pause()
const time = new Date(timeStr)
currentTime.value = time
const ratio = (time.getTime() - startTimeMs) / totalDuration
currentPosition.value = ratio * axisWidth
// 触发图更新
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
socialGroupsStore.initGraphCommunityDetailNode(socialGroupsStore.curSelecedGroupIds, currentTimes)
}
// 根据位置计算时间
const getTimeFromPosition = (position) => {
const ratio = Math.max(0, Math.min(1, position / axisWidth))
const timeOffset = totalDuration * ratio
return new Date(startTimeMs + timeOffset)
}
// 指针按下事件
const handlePointerDown = (e) => {
if (e.target.classList.contains("timeLine-point")) return
pause() // 拖动或点击时暂停自动播放
const rect = axisRef.value.getBoundingClientRect()
const position = Math.max(0, Math.min(axisWidth, e.clientX - rect.left))
// 直接更新位置,不使用节流函数
currentPosition.value = position
currentTime.value = getTimeFromPosition(position)
// 点击后输出当前时间
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
if (socialGroupsStore.curRelationId === "") {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes
)
} else {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes,
socialGroupsStore.curRelationId
)
}
}
// 时间点指针按下事件
const handlePointPointerDown = (e) => {
e.stopPropagation()
e.preventDefault()
isDragging.value = true
// 缓存轴的边界矩形,避免重复计算
const rect = axisRef.value.getBoundingClientRect()
const axisLeft = rect.left
const handlePointerMove = (e) => {
if (!isDragging.value) return
const position = Math.max(0, Math.min(axisWidth, e.clientX - axisLeft))
// 直接更新位置,不检查阈值,确保实时响应
currentPosition.value = position
currentTime.value = getTimeFromPosition(position)
}
const handlePointerUp = () => {
isDragging.value = false
// 拖动结束时输出当前时间
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
if (socialGroupsStore.curRelationId === "") {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes
)
} else {
socialGroupsStore.initGraphCommunityDetailNode(
socialGroupsStore.curSelecedGroupIds,
currentTimes,
socialGroupsStore.curRelationId
)
}
document.removeEventListener("pointermove", handlePointerMove)
document.removeEventListener("pointerup", handlePointerUp)
}
document.addEventListener("pointermove", handlePointerMove, { passive: true })
document.addEventListener("pointerup", handlePointerUp)
}
const trackStyle = computed(() => {
const progressPercent = Math.min(100, (currentPosition.value / 426) * 100)
return {
background: `linear-gradient(90deg, #00F3FF 0%, #00F3FF ${progressPercent}%, #3B7699 ${progressPercent}%, #3B7699 100%)`,
width: "100%"
}
})
// 组件卸载时清理事件监听器
onUnmounted(() => {
document.removeEventListener("pointermove", () => {})
document.removeEventListener("pointerup", () => {})
// 清理计时器
if (playTimer) {
pause()
clearInterval(playTimer)
playTimer = null
}
})
let chart = null
const initChart = async () => {
if (!chart) {
chart = echarts.init(document.getElementById("container"))
}
const links = []
const nodes = []
const edgeWidth = (interactionTime) => {
if (interactionTime === 0) return 1
if (interactionTime === 1) return 2
if (interactionTime <= 2) return 3
if (interactionTime <= 3) return 4
else if (interactionTime <= 10) return 6
else if (interactionTime <= 20) return 8
else if (interactionTime <= 30) return 10
else return 1
}
// 添加边唯一标识集合,用于检测重复边
const edgeSet = new Set()
// 使用Map存储边方便后续查找和更新
const edgeMap = new Map()
if (!Object.keys(socialGroupsStore.communityAllNodeList).length) return
// 先获取到所有节点
console.log("301加入节点前")
socialGroupsStore.communityAllNodeList.forEach((item) => {
nodes.push({
id: item.userId,
nodeName: item.userName,
// 头像
avatarData: item.avatarData,
// 节点的默认圆形头像
defaultAvatar: getAvatarUrl(item.defaultAvatar),
// 节点的高亮圆形头像
activeAvatar: getAvatarUrl(item.activeAvatar),
// 粉丝量
fans: item.fans,
symbolSize: 40,
// 发帖数
postNum: item.postNum,
// 发帖频率
postFreqPerDay: item.postFreqPerDay,
// 参与互动次数
interactionNum: item.interactionNum,
// 参与互动频率
interactionFreqPerDay: item.interactionFreqPerDay,
// 帖文被互动次数
interactedNum: item.interactedNum,
// 最近活跃时间
recentActiveTime: item.recentActiveTime
})
})
Object.entries(socialGroupsStore.communityDetailNodeList).forEach(([parentId, children]) => {
children.forEach((child) => {
// 生成边的唯一标识符,与方向无关
const edgeKey = [parentId, child.id].sort().join("-")
// 检查边是否已存在
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey)
const newEdge = {
source: parentId,
target: child.id,
edge: child.isHidden ? 1 : 0,
interactionTimes: child.interactionTime,
lineStyle: {
width: child.isHidden ? 7 : edgeWidth(child.interactionTime),
color: child.isHidden ? "#FF5E00" : "#37ACD7", // 无互动=橙色,有互动=蓝色
type: child.isHidden ? "dashed" : "solid", // 无互动=虚线,有互动=实线
opacity: child.isHidden ? 1 : 0.5
}
}
links.push(newEdge)
edgeMap.set(edgeKey, newEdge)
} else {
// 边已存在检查isHidden状态是否变化
const existingEdge = edgeMap.get(edgeKey)
// 只在isHidden从false变为true时更新样式
if (!existingEdge.edge && child.isHidden) {
existingEdge.edge = 1
existingEdge.lineStyle = {
width: 7,
color: "#FF5E00",
type: "dashed",
opacity: 1
}
}
}
})
})
chartsData.value = { links, nodes }
const data = { links, nodes }
const categories = [
{ name: "事件活跃者", category: 0, icon: "circle" },
{
name: "互动关系",
category: 1,
icon: `image://${new URL("@/assets/images/linkPrediction/icon/interaction-icon2.png", import.meta.url)}`
},
{
name: "紧密团体关系",
category: 2,
icon: `image://${new URL("@/assets/images/linkPrediction/icon/tight-community-legend-icon.png", import.meta.url)}`
}
]
const option = {
//图例配置
legend: [
{
data: categories.map((c) => ({
name: c.name,
itemStyle: {
color:
c.category === 0
? new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 1, color: "#1a3860" }, // 深蓝渐变
{ offset: 0.5, color: "#38546b" },
{ offset: 0, color: "#5fb3b3" }
])
: c.category === 1
? new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 0, color: "#9eec9c" }, // 绿色渐变
{ offset: 0.37, color: "#aef295" },
{ offset: 1, color: "#c2f989" }
])
: new echarts.graphic.LinearGradient(1, 0, 0, 0, [
{ offset: 0, color: "#ff9a9e" }, // 红色渐变(用于 category === 2
{ offset: 0.5, color: "#fad0c4" },
{ offset: 1, color: "#fbc2eb" }
])
},
icon: c.icon
})),
right: 21,
symbolKeepAspect: false,
bottom: 70,
orient: "vertical",
itemWidth: 16,
itemHeight: 16,
itemGap: 12,
backgroundColor: "rgba(0,67,125,0.56)", // 半透明深蓝
borderRadius: 8, // 圆角
borderColor: "#c2f2ff", // 淡蓝色边框
borderWidth: 0.3,
padding: [12, 20, 12, 20], // 上右下左
textStyle: {
color: "#fff",
fontSize: 16,
fontWeight: "normal"
}
}
],
//hover上去的窗口
tooltip: {
trigger: "item",
backgroundColor: "rgba(0,0,0,0)", // 透明背景
borderColor: "rgba(0,0,0,0)", // 透明边框
borderWidth: 0,
extraCssText: "box-shadow:none;padding:0;",
formatter: function (params) {
if (params.dataType === "node") {
return `<div
style="
padding:10px 15px;
height: 68px;
border-radius: 4px;
background: url('${nodeHoverImg}');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
">
<div style="color:#fff;letter-spacing: 0.14px;">
<div >用户ID${params.data.id}</div>
<div >用户名:${params.data.nodeName}</div>
</div>
</div>`
}
return ""
}
},
edgeLabel: {
show: false,
position: "middle",
formatter: function (params) {
return `${params.data.interactionTimes}次互动`
},
fontSize: 14
},
emphasis: {
edgeLabel: {
show: true,
color: "#fff",
fontSize: 18,
textShadowColor: "#fff",
textShadowBlur: 0,
textShadowOffsetX: 0,
textShadowOffsetY: 0
}
},
series: [
{
type: "graph",
layout: "force",
animation: false,
draggable: true,
roam: true,
zoom: 0.15,
categories: categories,
force: {
edgeLength: 2500,
repulsion: 20000,
gravity: 0.1,
friction: 0.02,
coolingFactor: 0.1
},
animationDurationUpdate: 3500, // 节点移动更平滑
data: data.nodes.map((node) => ({
...node,
symbol: node.defaultAvatar ? `image://${node.defaultAvatar}` : "circle",
symbolSize: node.defaultAvatar ? 140 : 40,
itemStyle: {
color: new echarts.graphic.RadialGradient(0.98, 0.38, 0.9, [
{ offset: 1, color: "#1a3860" }, // 最左侧
{ offset: 0.5, color: "#38546b" }, // 中间
{ offset: 0, color: "#5fb3b3" } // 最右侧
]),
opacity: 1,
borderColor: "#46C6AD",
borderWidth: 1,
shadowBlur: 4,
borderType: "dashed",
shadowColor: "rgba(19, 27, 114, 0.25)"
},
emphasis: {
itemStyle: {
shadowBlur: 20,
shadowColor: "#c4a651",
borderColor: "#fcd267",
borderWidth: 5,
borderType: "solid"
}
}
})),
links: data.links,
lineStyle: {
color: "#37ACD7"
}
}
]
}
chart.setOption(option)
}
const highLightUserNodes = (newHiglightUserIdList) => {
if (!newHiglightUserIdList || !chartsData.value || !chartsData.value.nodes) return
// 只让高亮节点显示 activeAvatar其他节点恢复默认头像或圆形
chartsData.value.nodes.forEach((node) => {
if (newHiglightUserIdList.includes(node.id) && node.activeAvatar) {
node.symbol = `image://${node.activeAvatar}`
node.symbolSize = 140
} else {
node.symbol = "circle"
node.symbolSize = 40
node.itemStyle = {
color: new echarts.graphic.RadialGradient(0.98, 0.38, 0.9, [
{ offset: 1, color: "#1a3860" }, // 最左侧
{ offset: 0.5, color: "#38546b" }, // 中间
{ offset: 0, color: "#5fb3b3" } // 最右侧
]),
opacity: 1,
borderColor: "#46C6AD",
borderWidth: 1,
shadowBlur: 4,
borderType: "dashed",
shadowColor: "rgba(19, 27, 114, 0.25)"
}
}
})
chart.setOption({
series: [
{
data: chartsData.value.nodes
}
]
})
}
const handleClickNode = () => {
chart.on("click", function (params) {
if (params.dataType == "node" && curHighlightUserIdList.value.includes(params.data.id)) {
emit("click:openDialog", params.data)
}
})
}
onMounted(() => {
initChart()
chart.on("legendselectchanged", function (params) {
// 解决变形问题
setTimeout(() => {
chart.resize()
}, 0)
})
highLightUserNodes()
play()
handleClickNode()
})
</script>
<style scoped lang="less">
.detailNode-component {
width: 100%;
height: 100%;
position: relative;
.goback {
position: absolute;
top: -25px;
left: 20px;
cursor: pointer;
}
.graph-container {
width: 100%;
height: 93%;
}
.statistic-container {
width: 378px;
height: 42px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid #3aa1f8;
background: linear-gradient(270deg, rgba(0, 82, 125, 0.48) 0%, rgba(0, 200, 255, 0.23) 100%);
backdrop-filter: blur(3px);
position: absolute;
bottom: 105px;
left: 21px;
display: flex;
justify-content: space-between;
.statistics-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
.icon {
width: 14px;
height: 14px;
}
.name {
color: rgba(255, 255, 255, 0.76);
text-align: center;
font-family: OPPOSans;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
margin-left: 3px;
}
.count {
color: #fff;
font-family: D-DIN;
font-size: 15px;
font-style: normal;
font-weight: 700;
line-height: normal;
margin-bottom: 2px;
}
}
}
.time-axis {
width: 95%;
height: 42px;
border: 1px solid #3aa1f8;
background: linear-gradient(270deg, rgba(0, 82, 125, 0.48) 0%, rgba(0, 200, 255, 0.23) 100%);
backdrop-filter: blur(3px);
position: absolute;
left: 20px;
bottom: 50px;
border-radius: 4px;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0px 10px;
color: #fff;
touch-action: none; // 防止触摸设备上的默认行为
.time {
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.axis {
width: 426px;
height: 6px;
border-radius: 20px;
background-color: #3b7699;
cursor: pointer;
transform: translateZ(0); // 启用硬件加速
position: relative;
.progress-bar {
position: absolute;
top: 0;
left: 0;
height: 6px;
background-color: #00ecf9;
border-radius: 20px;
z-index: 1;
}
.time-sign {
width: 4px;
height: 18px;
border-radius: 10px;
background: linear-gradient(180deg, #fee39e 0%, #f9bd25 100%);
z-index: 1;
// 新增添加绝对定位
position: absolute;
// 调整垂直位置使其居中于时间轴
top: -6px;
&::before {
content: "";
position: absolute;
top: -6px;
left: -8px;
width: 20px;
height: 30px;
background: transparent;
}
}
.active-needle {
width: 30px;
height: 34px;
background-image: url("@/assets/images/point.png");
background-size: cover;
bottom: 1px;
left: -11px;
position: absolute;
}
.timeLine-point {
z-index: 2;
width: 18px;
height: 18px;
background-color: transparent;
border-radius: 50%;
border: 1.6px solid #ffe5a4;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: -6px;
left: -5px;
cursor: pointer;
user-select: none;
will-change: left;
transform: translate3d(0, 0, 0); // 强制启用硬件加速
&:hover {
transform: translate3d(0, 0, 0) scale(1.1);
}
&:active {
transform: translate3d(0, 0, 0) scale(0.95);
}
&::after {
content: "";
width: 10px;
height: 10px;
background-color: #f9bd25;
border-radius: 50%;
position: absolute;
}
}
}
.current-time-display {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 67, 125, 0.8);
border: 1px solid #3aa1f8;
border-radius: 4px;
padding: 8px 16px;
color: #fff;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 400;
backdrop-filter: blur(3px);
}
}
}
</style>