SocialNetworks_duan/src/views/LinkPrediction/components/detailNode.vue

721 lines
21 KiB
Vue
Raw Normal View History

<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
2025-07-30 16:33:29 +08:00
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>
2025-07-31 16:13:39 +08:00
import {
defineEmits,
onMounted,
ref,
onUnmounted,
computed,
watch,
nextTick,
defineProps
} from "vue"
2025-08-01 14:48:51 +08:00
import { TansTimestamp, getAvatarUrl } from "@/utils/transform"
import nodeHoverImg from "@/assets/images/nodeHover.png"
import * as echarts from "echarts"
import { storeToRefs } from "pinia"
2025-07-30 16:33:29 +08:00
2025-07-31 16:13:39 +08:00
const props = defineProps({
interactionStore: {
type: Object,
required: true
}
})
2025-08-01 11:09:49 +08:00
const { communityDetailNodeRelation, timeList, predictionUserIds, curSelecedGroupIds } =
storeToRefs(props.interactionStore)
const emit = defineEmits(["click:goback", "click:openDialog"])
const chartsData = ref({})
const handleGoback = () => {
2025-08-01 09:11:24 +08:00
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)
}
})
}
2025-08-01 11:09:49 +08:00
// 时间轴相关数据
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(
2025-07-30 16:33:29 +08:00
communityDetailNodeRelation,
() => {
initChart()
},
{ deep: true }
)
2025-07-30 16:33:29 +08:00
//监听predictionUserIds的变化从而筛选需要高亮的预测节点
watch(
2025-07-30 16:33:29 +08:00
predictionUserIds,
(newIds) => {
if (newIds.length != 0) {
nextTick(() => {
highLightUserNodes(newIds)
})
}
},
2025-07-30 16:33:29 +08:00
{ deep: true, immediate: true }
)
2025-07-30 16:33:29 +08:00
2025-07-31 11:16:57 +08:00
// 自动播放控制
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")
2025-07-31 16:13:39 +08:00
if (props.interactionStore.curRelationId == "") {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes
)
2025-07-31 11:16:57 +08:00
} else {
2025-07-31 16:13:39 +08:00
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
2025-07-31 11:16:57 +08:00
currentTimes,
2025-07-31 16:13:39 +08:00
props.interactionStore.curRelationId
2025-07-31 11:16:57 +08:00
)
}
}
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))
}
})
2025-07-30 16:33:29 +08:00
// 让 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
2025-07-31 11:16:57 +08:00
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)
2025-07-31 11:16:57 +08:00
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
// 拖动结束时输出当前时间
2025-08-01 16:12:39 +08:00
pause() // 拖动或点击时暂停自动播放
const currentTimes = TansTimestamp(currentTime.value, "YYYY-MM-DD HH:mm:ss")
2025-07-31 16:13:39 +08:00
if (props.interactionStore.curRelationId == "") {
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes
)
} else {
2025-07-31 16:13:39 +08:00
props.interactionStore.initGraphCommunityDetailNode(
props.interactionStore.curSelecedGroupIds,
currentTimes,
2025-07-31 16:13:39 +08:00
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", () => {})
2025-07-31 11:16:57 +08:00
pause()
})
let chart = null
const initChart = async () => {
2025-08-01 17:02:46 +08:00
if (chart == null) {
chart = echarts.init(document.getElementById("container"))
}
const links = []
2025-07-30 16:33:29 +08:00
let nodes = []
const edgeWidth = (interactionTime) => {
2025-08-01 16:48:12 +08:00
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
}
2025-07-31 16:13:39 +08:00
if (!Object.keys(props.interactionStore.communityDetailNodeRelation).length) return
2025-07-30 16:33:29 +08:00
//先处理节点
2025-07-31 16:13:39 +08:00
nodes = props.interactionStore.communityDetailNodeList.map((item) => ({
2025-07-30 16:33:29 +08:00
id: item.userId,
name: item.userName,
// 头像
avatarData: item.avatarData,
2025-08-01 16:12:39 +08:00
//默认圆形头像
2025-08-01 14:48:51 +08:00
defaultAvatar: getAvatarUrl(item.defaultAvatar),
2025-08-01 16:12:39 +08:00
//激活状态的头像
2025-08-01 14:48:51 +08:00
activeAvatar: getAvatarUrl(item.activeAvatar),
2025-07-30 16:33:29 +08:00
symbolSize: 40,
postNum: item.postNum,
fans: item.fans,
// 发帖频率
postFreqPerDay: item.postFreqPerDay,
// 参与互动次数
interactionNum: item.interactionNum,
// 参与互动频率
interactionFreqPerDay: item.interactionFreqPerDay,
// 帖文被互动次数
interactedNum: item.interactedNum,
// 最近活跃时间
recentActiveTime: item.recentActiveTime
2025-07-30 16:33:29 +08:00
}))
2025-07-31 16:13:39 +08:00
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: {
2025-07-31 17:06:43 +08:00
width: child.isHidden ? 7 : edgeWidth(child.interactionTime),
2025-07-31 16:13:39 +08:00
color: child.isHidden ? props.interactionStore.predictionLineColor : "#37ACD7", // 无互动=灰色,有互动=黄色
2025-07-31 17:06:43 +08:00
opacity: child.isHidden ? 1 : 0.5, // 可选:调整透明度增强模糊感
2025-07-31 16:13:39 +08:00
type: child.isHidden ? "dashed" : "solid" // 无互动=实线,有互动=虚线
}
})
})
2025-07-31 16:13:39 +08:00
}
)
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)}`
},
{
2025-07-31 16:13:39 +08:00
name: props.interactionStore.predictionLegendContent,
category: 2,
2025-07-31 16:13:39 +08:00
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;">
2025-08-01 09:11:24 +08:00
<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,
2025-07-31 11:16:57 +08:00
zoom: 0.1,
categories: categories,
force: {
2025-07-31 17:06:43 +08:00
edgeLength: 4000,
repulsion: 4000,
gravity: 0.1,
friction: 0.02,
coolingFactor: 0.1
},
animationDurationUpdate: 3500, // 节点移动更平滑
data: chartsData.value.nodes.map((node) => ({
...node,
2025-08-01 14:48:51 +08:00
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
2025-08-01 14:48:51 +08:00
// 只让高亮节点显示 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()
2025-07-31 11:16:57 +08:00
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>