feat: 按指定格式渲染nodes和links数据
This commit is contained in:
parent
a1382b84ca
commit
027a4166c5
1433
src/data/graphData.ts
Normal file
1433
src/data/graphData.ts
Normal file
File diff suppressed because it is too large
Load Diff
629
src/views/index.vue
Normal file
629
src/views/index.vue
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
<template>
|
||||
<div class="sky-page-box sky-pageBgc-r" ref="pageBoxRef" id="visualize_2d">
|
||||
<!-- 时间轴 -->
|
||||
<div class="sky-time-box sky-flex-rsc">
|
||||
<div>{{ dateFormat(temporalTimeMin, 'YYYY/MM/DD') }}</div>
|
||||
<div class="sky-position-r sky-w-50 sky-ml-10 sky-mr-15 sky-mt-2 sky-w-500px h-5px">
|
||||
<!-- 此处更换时间轴图标 -->
|
||||
<img v-for="(item, index) in init_time_left" :key="index" src="./assets/img/initNode_time.svg?url"
|
||||
style="position: absolute; left: 0%; transform: translate(-50%, -30%); z-index: 10"
|
||||
:style="{ left: item }" alt="" />
|
||||
<img v-for="(item, index) in importantNode_time_left" :key="index"
|
||||
src="./assets/img/importantNode_time.svg?url"
|
||||
style="position: absolute; transform: translate(-40%, -30%); z-index: 10" :style="{ left: item }"
|
||||
alt="" />
|
||||
<t-slider :min="temporalTimeMin" :max="temporalTimeMax" :show-tooltip="true" class="sky-timeSlider"
|
||||
v-model="temporalTime" :label="handleTimeLabel" @change-end="changeTemporalTimeEnd">
|
||||
<template #Lable="{ value }">
|
||||
<span>当前值: {{ value * 2 }}</span>
|
||||
</template>
|
||||
<template #button>
|
||||
<img src="./user/lao.png"
|
||||
style="position: absolute; transform: translate(-50%, -100%)"
|
||||
:style="{ left: temporalTick }" alt="" />
|
||||
</template>
|
||||
</t-slider>
|
||||
</div>
|
||||
|
||||
<div>{{ dateFormat(temporalTimeMax, 'YYYY/MM/DD') }}</div>
|
||||
|
||||
<PlayCircleIcon v-if="temporalPlayShow" class="sky-fs-20 sky-hover-c-primary sky-ml-10"
|
||||
@click="handleTemporaPlay" />
|
||||
<PauseCircleIcon v-else class="sky-fs-20 sky-hover-c-primary sky-ml-10" @click="handleTemporaPause" />
|
||||
</div>
|
||||
<!--图显示区域-->
|
||||
<div class="sky-graph-area sky-flex-rss sky-graph-area-h1">
|
||||
<!-- 绘图面板区域 -->
|
||||
<div ref="graphPanelRef" id="sky-graph-panel" style="width: 100%; height: calc(100% - 2px)"></div>
|
||||
</div>
|
||||
|
||||
<t-loading v-if="isLayouting" class="sky-layoutLoading" text="正在布局..." size="small"></t-loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx">
|
||||
export default {
|
||||
name: '',
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import { onMounted, reactive, ref, nextTick, toRaw, onBeforeUnmount, Ref } from 'vue'
|
||||
|
||||
import { PlayCircleIcon, PauseCircleIcon } from 'tdesign-icons-vue-next'
|
||||
|
||||
import GraphVis from '../assets/graphvis/graphvis.esm.min3.js'
|
||||
import { getDefaultConfig } from '../assets/graphvis/defaultConfig.js'
|
||||
import { getLayoutDefaultConfig } from '../assets/graphvis/layoutParamsDict.js'
|
||||
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import timeData from '../data/graphData'
|
||||
import dayjs from 'dayjs'
|
||||
import { id } from 'element-plus/es/locales.mjs'
|
||||
|
||||
const props = defineProps({
|
||||
graphData: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: '人民网',
|
||||
type: 'user',
|
||||
properties: { name: 'Lionel Roob', special_type: ['initial'], },
|
||||
personProperties: {url:('src/views/user/boss.png')},
|
||||
}
|
||||
|
||||
],
|
||||
links: [
|
||||
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
duSize: {
|
||||
// 根据度 改变 node size
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const dateFormat = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
|
||||
if (!date) return '暂无'
|
||||
return dayjs(date).format(format)
|
||||
}
|
||||
let LicenseKey =
|
||||
'01D01J01E01A01D01I01A01E01C01A01D00Z03003903303402P03202N03702R02X02R03303103402P037037'
|
||||
|
||||
const isLayouting = ref(false)
|
||||
let visualizeData = reactive({
|
||||
nodes: [],
|
||||
links: [],
|
||||
})
|
||||
|
||||
const initVisualizeData = () => {
|
||||
const { nodes, links } = props.graphData
|
||||
|
||||
if (Array.isArray(links) && Array.isArray(nodes) && (links.length || nodes.length)) {
|
||||
visualizeData = {
|
||||
links: initLinkStyle(links, nodes),
|
||||
nodes: initNodeStyle(nodes, links),
|
||||
}
|
||||
if (!validate) {
|
||||
MessagePlugin.warning('暂不支持进入时序动态网络')
|
||||
return
|
||||
}
|
||||
createGraph() //初始化客户端
|
||||
nextTick(() => {
|
||||
initGraphData() //初始化加载数据并绘图
|
||||
})
|
||||
} else {
|
||||
MessagePlugin.error('数据有误')
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局绘图客户端对象
|
||||
let graphVis: any = null
|
||||
let graphPanelRef = ref(null)
|
||||
const createGraph = () => {
|
||||
graphVis = new GraphVis({
|
||||
container: document.getElementById('sky-graph-panel'),
|
||||
licenseKey: LicenseKey,
|
||||
config: getDefaultConfig(),
|
||||
})
|
||||
graphVis.setDragHideLine(false) //拖拽时隐藏连线
|
||||
graphVis.setShowDetailScale(0.1) //展示细节的比例
|
||||
graphVis.setZoomRange(0.1, 5) //缩放区间
|
||||
}
|
||||
// 初始化图谱数据
|
||||
|
||||
const initGraphData = () => {
|
||||
graphVis.addGraph(visualizeData)
|
||||
nextTick(() => {
|
||||
layoutEvent('fastForce')
|
||||
graphVis.zoomFit()
|
||||
})
|
||||
}
|
||||
// 节点大小
|
||||
|
||||
const setNodeSize = (nodelist = []) => {
|
||||
let leg = nodelist.length
|
||||
let size = 10
|
||||
if (leg <= 10) {
|
||||
size = 26
|
||||
} else if (leg <= 30) {
|
||||
size = 24
|
||||
} else if (leg <= 50) {
|
||||
size = 22
|
||||
} else if (leg <= 100) {
|
||||
size = 20
|
||||
} else if (leg <= 500) {
|
||||
size = 18
|
||||
} else if (leg <= 1000) {
|
||||
size = 16
|
||||
} else if (leg <= 5000) {
|
||||
size = 14
|
||||
} else if (leg <= 10000) {
|
||||
size = 12
|
||||
} else {
|
||||
size = 10
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
// 初始化 节点样式
|
||||
const initNodeStyle = (nodeList = [], linkList = [], doAlgStyle = false) => {
|
||||
let nodeIdCount = {}
|
||||
let nodeIdsAll = []
|
||||
let resNodes = []
|
||||
|
||||
linkList.forEach((link) => {
|
||||
let source = link.source
|
||||
if (source) {
|
||||
if (nodeIdCount[source]) {
|
||||
nodeIdCount[source]++
|
||||
} else {
|
||||
nodeIdsAll.push(source)
|
||||
nodeIdCount[source] = 1
|
||||
}
|
||||
}
|
||||
let target = link.target
|
||||
if (target) {
|
||||
if (nodeIdCount[target]) {
|
||||
nodeIdCount[target]++
|
||||
} else {
|
||||
nodeIdsAll.push(target)
|
||||
nodeIdCount[target] = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
let descCountArr = Array.from(new Set(Object.values(nodeIdCount).sort()))
|
||||
let ratio = descCountArr.length > 10 ? 10 : descCountArr.length
|
||||
let maxVal = Math.max(...descCountArr)
|
||||
let minVal = Math.min(...descCountArr)
|
||||
|
||||
let size = setNodeSize(nodeList)
|
||||
let isolateNum = 0
|
||||
let nodeObj = {} // 去重
|
||||
let maxSize = 0
|
||||
nodeList.forEach((node) => {
|
||||
if (!node.style) {
|
||||
node.style = {}
|
||||
}
|
||||
|
||||
// 处理节点标签
|
||||
let propName = node.properties?.name || node.properties?.author_name || ''
|
||||
if (propName) {
|
||||
node.label = propName
|
||||
}
|
||||
if (!node.color) {
|
||||
node.color = '255,96,96'
|
||||
}
|
||||
if (!node.size) {
|
||||
node.size = size
|
||||
}
|
||||
|
||||
// 节点大小处理
|
||||
if (props.duSize && nodeIdCount[node.id] && maxVal > minVal) {
|
||||
let enlargeNum = ((20 + 3 * ratio) * (nodeIdCount[node.id] - minVal)) / (maxVal - minVal)
|
||||
node.size = Number(node.size) + Math.ceil(enlargeNum)
|
||||
}
|
||||
|
||||
maxSize = Math.max(maxSize, node.size)
|
||||
|
||||
if (!nodeObj[node.id]) {
|
||||
// 去重
|
||||
nodeObj[node.id] = 1
|
||||
|
||||
if (nodeIdsAll.includes(node.id)) {
|
||||
// 不是孤立节点
|
||||
|
||||
resNodes.push({
|
||||
...node,
|
||||
})
|
||||
} else if (isolateNum < 10) {
|
||||
// 只显示 10个孤立点
|
||||
|
||||
isolateNum++
|
||||
resNodes.push({
|
||||
...node,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
nodeObj[node.id]++
|
||||
}
|
||||
})
|
||||
|
||||
handleTemporalTimeRange()
|
||||
handleImportantTag(nodeList, linkList)
|
||||
|
||||
return resNodes
|
||||
}
|
||||
|
||||
const setLinkWidth = (linkList = [], nodeList = []) => {
|
||||
let leg = linkList.length + nodeList.length
|
||||
|
||||
let wd = 1
|
||||
if (leg <= 10) {
|
||||
wd = 3.25
|
||||
} else if (leg <= 30) {
|
||||
wd = 3
|
||||
} else if (leg <= 50) {
|
||||
wd = 2.75
|
||||
} else if (leg <= 100) {
|
||||
wd = 2.5
|
||||
} else if (leg <= 500) {
|
||||
wd = 2.25
|
||||
} else if (leg <= 1000) {
|
||||
wd = 2
|
||||
} else if (leg <= 2000) {
|
||||
wd = 1.75
|
||||
} else if (leg <= 5000) {
|
||||
wd = 1.5
|
||||
} else if (leg <= 10000) {
|
||||
wd = 1.25
|
||||
} else {
|
||||
wd = 1
|
||||
}
|
||||
|
||||
return wd
|
||||
}
|
||||
// 校验可视化数据
|
||||
let validate = false
|
||||
// 初始化 连边样式
|
||||
const initLinkStyle = (links = [], nodes = []) => {
|
||||
let wd = setLinkWidth(links, nodes)
|
||||
let resLinks = []
|
||||
links.forEach((link) => {
|
||||
if (!link.lineWidth) {
|
||||
link.lineWidth = wd
|
||||
}
|
||||
if (link.enlarge) {
|
||||
const enlarge = Number(link.enlarge) || 1.5
|
||||
link.lineWidth = Number(link.lineWidth) * enlarge
|
||||
}
|
||||
if (link.ranks) {
|
||||
validate = true
|
||||
}
|
||||
|
||||
// 时序 时间范围
|
||||
|
||||
if (!link.properties) {
|
||||
link.properties = {}
|
||||
}
|
||||
if (!link.ranks) {
|
||||
link.ranks = []
|
||||
}
|
||||
if (link.ranks) {
|
||||
link.properties.ranks = link.ranks
|
||||
temporalTimeRange.push(...link.properties.ranks)
|
||||
}
|
||||
resLinks.push({
|
||||
...link,
|
||||
})
|
||||
})
|
||||
return resLinks
|
||||
}
|
||||
|
||||
//当前布局信息
|
||||
let currentLayout = ref({
|
||||
preLayout: 'fastForce',
|
||||
type: '',
|
||||
icon: 'el-icon-video-play',
|
||||
text: '执行布局',
|
||||
status: false,
|
||||
layoutConfigText: '布局参数设置',
|
||||
})
|
||||
const layoutEvent = (layoutType, params = {}) => {
|
||||
// console.log(12)
|
||||
|
||||
// if (stopOperation(true,false)) return;
|
||||
currentLayout.value.type = layoutType
|
||||
runLayout(params)
|
||||
}
|
||||
const runLayout = (params) => {
|
||||
if (currentLayout.value.status) {
|
||||
graphVis.stopLayout()
|
||||
endLayoutCallback()
|
||||
return
|
||||
}
|
||||
let layoutType = currentLayout.value.type
|
||||
currentLayout.value.icon = 'el-icon-loading' //运行中
|
||||
currentLayout.value.text = '布局中,点击停止'
|
||||
currentLayout.value.status = true
|
||||
currentLayout.value.layoutConfigText = '布局中,点击停止'
|
||||
let graphData = graphVis.getVisibleData() //可视化数据
|
||||
|
||||
let layoutConfig = getLayoutDefaultConfig(layoutType, params)
|
||||
|
||||
graphVis.excuteWorkerLayout(graphData, layoutType, layoutConfig, false, function () {
|
||||
endLayoutCallback()
|
||||
MessagePlugin.success('布局成功')
|
||||
})
|
||||
}
|
||||
|
||||
const endLayoutCallback = () => {
|
||||
//布局结束的处理
|
||||
currentLayout.value.icon = 'el-icon-video-play'
|
||||
currentLayout.value.text = '开始布局'
|
||||
currentLayout.value.status = false
|
||||
currentLayout.value.preLayout = currentLayout.value.type
|
||||
currentLayout.value.layoutConfigText = '布局参数设置'
|
||||
graphVis.moveCenter && graphVis.moveCenter()
|
||||
toolBarEvent('zoom', 'auto')
|
||||
}
|
||||
const toolBarEvent = (eventType, type) => {
|
||||
if (eventType == 'zoom') {
|
||||
graphVis.setZoom(type)
|
||||
}
|
||||
}
|
||||
// 时序网络
|
||||
let temporalTime = ref(0)
|
||||
let temporalTimeMin = ref(0)
|
||||
let temporalTimeMax = ref(0)
|
||||
let temporalTimeRange = []
|
||||
let temporalPlayShow = ref(true)
|
||||
let temporaInterval = null
|
||||
let temporalTick = ref('0%')
|
||||
// 重要节点和首发节点的坐标 left
|
||||
let importantNode_time_left = ref([])
|
||||
let init_time_left = ref([])
|
||||
const handleTemporalTimeRange = () => {
|
||||
temporalTimeRange = temporalTimeRange.map((time) => new Date(time).getTime())
|
||||
temporalTimeMin.value = Math.min(...temporalTimeRange)
|
||||
temporalTimeMax.value = Math.max(...temporalTimeRange)
|
||||
temporalTime.value = temporalTimeMax.value
|
||||
}
|
||||
|
||||
const set_important_left_map = (node, map, ratio) => {
|
||||
if (
|
||||
!map.has(node.id) &&
|
||||
(node.properties?.special_type?.includes('post_importantNode') ||
|
||||
node.properties?.special_type?.includes('user_importantNode'))
|
||||
) {
|
||||
map.set(node.id, `${Math.round(ratio * 100)}%`)
|
||||
}
|
||||
}
|
||||
const set_init_left_map = (node, map, ratio) => {
|
||||
if (!map.has(node.id) && node.properties?.special_type?.includes('initial')) {
|
||||
map.set(node.id, `${Math.round(ratio * 100)}%`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportantTag = (nodeList, linkList) => {
|
||||
const temporalTimeRange_copy = [...temporalTimeRange]
|
||||
let important_left_map = new Map()
|
||||
let init_left_map = new Map()
|
||||
temporalTimeRange_copy.sort()
|
||||
temporalTimeRange_copy.forEach((curTime) => {
|
||||
const curLinks = linkList.filter((link) => {
|
||||
let ranks = link.properties.ranks.map((time) => new Date(time).getTime())
|
||||
if (ranks.includes(curTime)) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
curLinks.forEach((link) => {
|
||||
const source = nodeList.find((node) => node.id === link.source)
|
||||
const target = nodeList.find((node) => node.id === link.target)
|
||||
if (source && target) {
|
||||
const ratio =
|
||||
(curTime - temporalTimeMin.value) / (temporalTimeMax.value - temporalTimeMin.value)
|
||||
// 判断节点是否是重要节点并添加到important_left_map
|
||||
set_important_left_map(source, important_left_map, ratio)
|
||||
set_important_left_map(target, important_left_map, ratio)
|
||||
// 判断节点是否是首发节点并添加到init_left_map
|
||||
set_init_left_map(source, init_left_map, ratio)
|
||||
set_init_left_map(target, init_left_map, ratio)
|
||||
}
|
||||
})
|
||||
})
|
||||
importantNode_time_left.value = Array.from(important_left_map.values())
|
||||
init_time_left.value = Array.from(init_left_map.values())
|
||||
}
|
||||
const handleTimeLabel = () => {
|
||||
return dateFormat(temporalTime.value)
|
||||
}
|
||||
|
||||
const changeTemporalTimeEnd = () => {
|
||||
temporalGraphRefresh(temporalTime.value)
|
||||
}
|
||||
|
||||
const temporalGraphRefresh = (time) => {
|
||||
graphVis.nodes.forEach((node) => (node.visible = false))
|
||||
graphVis.links.forEach((item) => {
|
||||
let ranks = item.properties.ranks.map((time) => new Date(time).getTime())
|
||||
ranks.sort()
|
||||
|
||||
// 计算小于等于当前时间的时间点数量
|
||||
const link_time = ranks.filter((t) => t <= time)
|
||||
const count = link_time.length
|
||||
if (count > 0) {
|
||||
item.visible = true
|
||||
let source = graphVis.findNodeById(item.source.id)
|
||||
let target = graphVis.findNodeById(item.target.id)
|
||||
source.visible = true
|
||||
target.visible = true
|
||||
if (count >= 2) {
|
||||
item.label = count
|
||||
item.showlabel = true
|
||||
} else {
|
||||
item.showlabel = false
|
||||
}
|
||||
} else {
|
||||
item.visible = false
|
||||
}
|
||||
})
|
||||
graphVis.refreshView()
|
||||
}
|
||||
|
||||
const handleTemporaPlay = () => {
|
||||
let step = Math.floor((temporalTimeMax.value - temporalTimeMin.value) / 20)
|
||||
// 点击播放时,如果处于最大时间,则从头开始播放
|
||||
if (temporalTime.value === temporalTimeMax.value) {
|
||||
temporalTime.value = temporalTimeMin.value
|
||||
temporalGraphRefresh(temporalTime.value)
|
||||
}
|
||||
// 1小时
|
||||
// let step = 60 * 60 * 1000;
|
||||
temporalPlayShow.value = false
|
||||
temporaInterval = setInterval(() => {
|
||||
let nextTime = temporalTime.value + step
|
||||
if (nextTime >= temporalTimeMax.value) {
|
||||
nextTime = temporalTimeMax.value
|
||||
clearInterval(temporaInterval)
|
||||
temporalPlayShow.value = true
|
||||
temporaInterval = null
|
||||
}
|
||||
temporalTime.value = nextTime
|
||||
const ratio =
|
||||
(temporalTime.value - temporalTimeMin.value) / (temporalTimeMax.value - temporalTimeMin.value)
|
||||
temporalTick.value = `${Math.round(ratio * 100)}%`
|
||||
temporalGraphRefresh(nextTime)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const handleTemporaPause = () => {
|
||||
clearInterval(temporaInterval)
|
||||
temporalPlayShow.value = true
|
||||
temporaInterval = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initVisualizeData()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
graphVis = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.sky-page-box {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-width: 600px;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sky-graph-area-h1 {
|
||||
height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
.sky-layoutLoading {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sky-time-box {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 80px;
|
||||
z-index: 10;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
:deep(.sky-timeSlider .t-slider__button) {
|
||||
transform: translateY(-75%);
|
||||
width: 18px;
|
||||
height: 24px;
|
||||
background-image: url('../src/assets/img/slider_curr.svg');
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0%;
|
||||
}
|
||||
|
||||
:deep(.sky-timeSlider .t-slider .t-slider__rail .t-slider__track) {
|
||||
background-color: #bc1b22;
|
||||
}
|
||||
</style>
|
||||
<style lang="less">
|
||||
.sky-pageBgc-r {
|
||||
background: #f5f1f1 !important;
|
||||
}
|
||||
|
||||
.sky-flex-rsc {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sky-flex-rss {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sky-position-r {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
each(range(100), {
|
||||
.sky-w-@{value} {
|
||||
width: (@value * 1%);
|
||||
}
|
||||
|
||||
}) each(range(2000), {
|
||||
.sky-w-@{value}px {
|
||||
width: (@value * 1px);
|
||||
}
|
||||
|
||||
}) each(range(200), {
|
||||
.sky-m-@{value} {
|
||||
margin:(@value * 1px)
|
||||
}
|
||||
|
||||
.sky-ml-@{value} {
|
||||
margin-left:(@value * 1px)
|
||||
}
|
||||
|
||||
.sky-mr-@{value} {
|
||||
margin-right:(@value * 1px)
|
||||
}
|
||||
|
||||
.sky-mb-@{value} {
|
||||
margin-bottom:(@value * 1px)
|
||||
}
|
||||
|
||||
.sky-mt-@{value} {
|
||||
margin-top:(@value * 1px)
|
||||
}
|
||||
|
||||
}) each(range(50), {
|
||||
.sky-fs-@{value} {
|
||||
font-size: (@value * 1px);
|
||||
}
|
||||
|
||||
}) .sky-hover-c-primary {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user