SocialNetworks_duan/src/views/LinkPrediction/components/detailNode.vue
qumeng039@126.com 9ff920b4b4 警告修改
2025-08-01 17:02:46 +08:00

721 lines
21 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 interactionStore.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">
<el-tooltip
v-for="time in timeList"
:key="time"
:content="TansTimestamp(time, 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div class="time-section" :style="{ left: getTimeSectionLeft(time) + 5 + 'px' }"></div>
</el-tooltip>
<div class="progress-bar" :style="trackStyle"></div>
<div class="active-sign">
<el-tooltip
:content="TansTimestamp(timeList[timeList.length - 1], 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div class="active-needle" :style="showHidden"></div>
</el-tooltip>
<el-tooltip
:content="TansTimestamp(currentTime, 'YYYY.MM.DD HH:mm:ss')"
placement="bottom"
effect="light"
>
<div
class="timeLine-point"
@pointerdown.stop="handlePointPointerDown"
:style="{ left: `${currentPosition}px` }"
></div>
</el-tooltip>
</div>
</div>
<div class="time">{{ TansTimestamp(endTime, "YYYY.MM.DD HH:mm:ss") }}</div>
</div>
</div>
</template>
<script setup>
import {
defineEmits,
onMounted,
ref,
onUnmounted,
computed,
watch,
nextTick,
defineProps
} from "vue"
import { TansTimestamp, getAvatarUrl } from "@/utils/transform"
import nodeHoverImg from "@/assets/images/nodeHover.png"
import * as echarts from "echarts"
import { storeToRefs } from "pinia"
const props = defineProps({
interactionStore: {
type: Object,
required: true
}
})
const { communityDetailNodeRelation, timeList, predictionUserIds, curSelecedGroupIds } =
storeToRefs(props.interactionStore)
const emit = defineEmits(["click:goback", "click:openDialog"])
const chartsData = ref({})
const handleGoback = () => {
pause()
emit("click:goback", "CommunityNode")
}
const handleClickNode = () => {
chart.on("click", function (params) {
if (params.dataType == "node" && predictionUserIds.value.includes(params.data.id)) {
emit("click:openDialog", params.data)
}
})
}
// 时间轴相关数据
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 isPlaying = ref(false) // 是否自动播放
let playTimer = null
// 添加对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 }
)
//当点击时间轴的时候communityDetailNodeList改变重新更新关系图
watch(
communityDetailNodeRelation,
() => {
initChart()
},
{ deep: true }
)
//监听predictionUserIds的变化从而筛选需要高亮的预测节点
watch(
predictionUserIds,
(newIds) => {
if (newIds.length != 0) {
nextTick(() => {
highLightUserNodes(newIds)
})
}
},
{ deep: true, immediate: true }
)
// 自动播放控制
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 pause = () => {
isPlaying.value = false
if (playTimer) {
clearInterval(playTimer)
playTimer = null
}
}
// 发送请求逻辑封装
const sendTimeChangeRequest = () => {
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
if (props.interactionStore.curRelationId == "") {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes
)
} else {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes,
props.interactionStore.curRelationId
)
}
}
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 getTimeSectionLeft = computed(() => {
return (time) => {
const total = endTime.value.getTime() - startTime.value.getTime()
const offset = new Date(time).getTime() - startTime.value.getTime()
return Math.max(0, Math.min(426, (offset / total) * 426))
}
})
// 让 active-needle 标定在 timeList 最后一个时间点
const showHidden = computed(() => {
if (!timeList.value || timeList.value.length === 0) return {}
// 取最后一个时间点
const lastTime = timeList.value[timeList.value.length - 1]
// 计算 left 位置
const left = getTimeSectionLeft.value(lastTime) + 5 // +5px 保持和 time-section 对齐
return { left: `${left}px` }
})
// 根据位置计算时间
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)
sendTimeChangeRequest()
}
// 时间点指针按下事件
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
// 拖动结束时输出当前时间
pause() // 拖动或点击时暂停自动播放
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
if (props.interactionStore.curRelationId == "") {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes
)
} else {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes,
props.interactionStore.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", () => {})
pause()
})
let chart = null
const initChart = async () => {
if (chart == null) {
chart = echarts.init(document.getElementById("container"))
}
const links = []
let nodes = []
const edgeWidth = (interactionTime) => {
if (interactionTime <= 3) return 3
else if (interactionTime <= 10) return 4
else if (interactionTime <= 20) return 6
else if (interactionTime <= 30) return 8
else return 1
}
if (!Object.keys(props.interactionStore.communityDetailNodeRelation).length) return
//先处理节点
nodes = props.interactionStore.communityDetailNodeList.map((item) => ({
id: item.userId,
name: item.userName,
// 头像
avatarData: item.avatarData,
//默认圆形头像
defaultAvatar: getAvatarUrl(item.defaultAvatar),
//激活状态的头像
activeAvatar: getAvatarUrl(item.activeAvatar),
symbolSize: 40,
postNum: item.postNum,
fans: item.fans,
// 发帖频率
postFreqPerDay: item.postFreqPerDay,
// 参与互动次数
interactionNum: item.interactionNum,
// 参与互动频率
interactionFreqPerDay: item.interactionFreqPerDay,
// 帖文被互动次数
interactedNum: item.interactedNum,
// 最近活跃时间
recentActiveTime: item.recentActiveTime
}))
Object.entries(props.interactionStore.communityDetailNodeRelation).forEach(
([parentId, children]) => {
children.forEach((child) => {
links.push({
source: parentId,
target: child.id,
edge: child.isHidden ? 1 : 0,
interactionTimes: child.interactionTime,
lineStyle: {
width: child.isHidden ? 7 : edgeWidth(child.interactionTime),
color: child.isHidden ? props.interactionStore.predictionLineColor : "#37ACD7", // 无互动=灰色,有互动=黄色
opacity: child.isHidden ? 1 : 0.5, // 可选:调整透明度增强模糊感
type: child.isHidden ? "dashed" : "solid" // 无互动=实线,有互动=虚线
}
})
})
}
)
chartsData.value = { 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: props.interactionStore.predictionLegendContent,
category: 2,
icon: props.interactionStore.predictionLegendIcon
}
]
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.name}</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.1,
categories: categories,
force: {
edgeLength: 4000,
repulsion: 4000,
gravity: 0.1,
friction: 0.02,
coolingFactor: 0.1
},
animationDurationUpdate: 3500, // 节点移动更平滑
data: chartsData.value.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: chartsData.value.links,
lineStyle: {
color: "#37ACD7",
width: 1
}
}
]
}
chart.setOption(option)
}
const highLightUserNodes = (userIds) => {
if (!userIds) return
// 只让高亮节点显示 activeAvatar其他节点恢复默认头像或圆形
chartsData.value.nodes.forEach((node) => {
if (userIds.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
}
]
},
false
)
}
onMounted(() => {
initChart()
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;
.time-section {
position: absolute;
top: -8px;
width: 4px;
height: 22px;
background: #ffe066;
border-radius: 3px;
z-index: 3;
}
.progress-bar {
position: absolute;
top: 0;
left: 0;
height: 6px;
background-color: #00ecf9;
border-radius: 20px;
z-index: 1;
}
.active-sign {
position: relative;
z-index: 2;
.active-needle {
width: 30px;
height: 34px;
background-image: url("@/assets/images/point.png");
background-size: cover;
bottom: -8px;
left: -11px;
position: absolute;
}
.timeLine-point {
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;
}
}
}
}
}
}
</style>