👻:团队在开发大屏项目时,常常会遇到动态词云的效果,如下图所示:
"每条词条在容器范围内随机移动,当鼠标放在单独词条上时,停止移动"
为了方便之后开发,现在记录一下该组件的封装 (Vue3项目)😎
WordCloudRandom.vue
文件:
ini
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface AnimateElement extends HTMLElement {
animateInfo: {
scale?: number
x?: number
y?: number
z?: number
}
prevAnimateInfo: {
scale?: number
x?: number
y?: number
z?: number
}
}
const container = ref<HTMLElement>()
let randomAnimate: RandomAnimate
class RandomAnimate {
container: HTMLElement | null = null
items: AnimateElement[] = []
minScale = 0.9
scaleInterval = 10000 // 缩放间隔
xInterval = 40000 // 横向位移速度
yInterval = 40000 // 纵向位移速度
animateInterval = 16 // 动画间隔 16ms
index = 1
animationFrame: number | undefined
restartTimer: number | undefined
mutationObserver: MutationObserver | undefined
constructor(container: HTMLElement) {
this.container = container
this.init()
}
init() {
this.container!.style.position = 'relative'
this.createMutation()
}
createMutation() {
this.mutationObserver = new MutationObserver(function (mutationRecords) {
mutationRecords.forEach((record) => {
const { addedNodes, removedNodes } = record
if (addedNodes.length) {
randomAnimate.add(
Array.from(addedNodes).filter(
(node) => node.nodeType === 1
) as AnimateElement[]
)
}
if (removedNodes.length) {
randomAnimate.remove(
Array.from(removedNodes).filter(
(node) => node.nodeType === 1
) as AnimateElement[]
)
}
})
})
this.mutationObserver.observe(container.value!, {
childList: true,
attributes: false
})
}
add(items: AnimateElement | AnimateElement[]) {
items = Array.isArray(items) ? items : [items]
items.forEach((item: AnimateElement) => {
item.style.position = 'absolute'
item.style.left = '0px'
item.style.top = '0px'
item.animateInfo = {}
item.prevAnimateInfo = {} // 上一状态,用来判断移动方向与缩放状态
this.setScale(item)
this.setPosition(item)
this.setHover(item)
this.render(item)
})
this.items.push(...items)
}
remove(item: AnimateElement | AnimateElement[]) {
if (Array.isArray(item)) {
item.forEach((i) => {
this.remove(i)
})
} else {
// item.parentElement!.removeChild(item)
const index = this.items.indexOf(item)
if (index > -1) {
this.items.splice(index, 1)
}
}
}
setScale(item: AnimateElement) {
const maxZ = this.items.length * 100
const minScale = this.minScale
const { animateInfo, prevAnimateInfo } = item
if (!animateInfo.scale) {
animateInfo.scale = Math.random() * (1 - minScale) + minScale
} else {
const scaleInterval = this.scaleInterval
const scaleStep = ((1 - minScale) / scaleInterval) * this.animateInterval
const _animate = JSON.parse(JSON.stringify(animateInfo))
let direction
if (prevAnimateInfo.scale) {
direction =
animateInfo.scale > prevAnimateInfo.scale ||
animateInfo.scale === minScale
? 1
: -1
} else {
direction = Math.random() > 0.5 ? 1 : -1 // 随机开始放大或缩小
}
if (direction > 0) {
// 放大
animateInfo.scale += scaleStep
if (animateInfo.scale > 1) {
animateInfo.scale = 1
}
} else {
// 缩小
animateInfo.scale -= scaleStep
if (animateInfo.scale < minScale) {
animateInfo.scale = minScale
}
}
item.prevAnimateInfo.scale = _animate.scale
}
animateInfo.z = ~~(((animateInfo.scale - minScale) / (1 - minScale)) * maxZ)
}
setPosition(item: AnimateElement) {
const maxX = this.container!.clientWidth - item.clientWidth
const maxY = this.container!.clientHeight - item.clientHeight
const { animateInfo, prevAnimateInfo } = item
const _animate = JSON.parse(JSON.stringify(animateInfo))
if (animateInfo.x === undefined) {
animateInfo.x = Math.random() * maxX
animateInfo.y = Math.random() * maxY
} else {
let { xInterval, yInterval, animateInterval } = this
// xInterval = xInterval - Math.random() * 15000
// yInterval = yInterval - Math.random() * 15000
const xStep = (maxX / xInterval) * animateInterval
const yStep = (maxY / yInterval) * animateInterval
let directionX, directionY
if (prevAnimateInfo.x === undefined) {
directionX = Math.random() > 0.5 ? 1 : -1 // 随机开始向左或向右
directionY = Math.random() > 0.5 ? 1 : -1 // 随机开始向上或向下
} else {
directionX =
animateInfo.x > prevAnimateInfo.x || animateInfo.x === 0 ? 1 : -1
directionY =
animateInfo.y! > prevAnimateInfo.y! || animateInfo.y === 0 ? 1 : -1
}
if (directionX > 0) {
// 向右
animateInfo.x += xStep
if (animateInfo.x > maxX) {
animateInfo.x = maxX
}
} else {
// 向左
animateInfo.x -= xStep
if (animateInfo.x < 0) {
animateInfo.x = 0
}
}
if (directionY > 0) {
// 向下
animateInfo.y! += yStep
if (animateInfo.y! > maxY) {
animateInfo.y = maxY
}
} else {
// 向上
animateInfo.y! -= yStep
if (animateInfo.y! < 0) {
animateInfo.y = 0
}
}
item.prevAnimateInfo.x = _animate.x
item.prevAnimateInfo.y = _animate.y
}
}
setHover(item: AnimateElement) {
item.addEventListener('mouseenter', () => {
this.stop()
clearTimeout(this.restartTimer)
item.style.zIndex = this.items.length * 10000 + ''
item.style.transform = item.style.transform.replace(
/scale((\d+.?\d*))/,
'scale(1)'
)
})
item.addEventListener('mouseleave', () => {
item.style.zIndex = item.animateInfo.z + ''
item.style.transform = item.style.transform.replace(
/scale((\d+.?\d*))/,
`scale(${item.animateInfo.scale})`
)
this.restartTimer = setTimeout(() => {
this.start()
}, 300)
})
}
render(item: AnimateElement) {
const { x = 10, y = 10, z, scale } = item.animateInfo
item.style.zIndex = z + ''
item.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
}
stop() {
this.animationFrame && window.cancelAnimationFrame(this.animationFrame)
}
start() {
this.items.forEach((item: AnimateElement) => {
this.setScale(item)
this.setPosition(item)
this.render(item)
})
this.animationFrame = window.requestAnimationFrame(() => {
this.start()
})
}
destroy() {
this.stop()
this.items = []
this.container = null
this.mutationObserver && this.mutationObserver.disconnect()
}
}
function init() {
randomAnimate = new RandomAnimate(container.value!)
const items = Array.from(container.value!.children) as AnimateElement[]
randomAnimate.add(items)
randomAnimate.start()
}
onMounted(() => {
init()
})
onUnmounted(() => {
randomAnimate.destroy()
})
</script>
<template>
<div class="word-cloud-container" ref="container">
<slot></slot>
</div>
</template>
<style lang="less" scoped>
.word-cloud-container {
width: 100%;
height: 100%;
position: relative;
}
</style>
使用:
xml
<script setup lang="ts">
import WordCloudRandom from '@/components/WordCloudRandom/index.vue'
const list = ref(['xxx1','xxx2','xxx3'])
</script>
<template>
<div class="container">
<WordCloudRandom>
<div class="card" v-for="item in list" :key="item">
<div class="content">{{ item }}</div>
</div>
</WordCloudRandom>
</div>
</template>
<style lang="less" scoped>
.word-cloud-random {
width: 100%;
height: 100%;
overflow: hidden;
background-color: transparent;
position: relative;
}
.container {
width: 660px;
height: 440px;
// border: 1px solid #124664;
// border-radius: 4px;
// margin: 0px auto;
position: absolute;
// left: 50%;
top: 50%;
transform: translate(0, -45%);
}
.card {
width: 213px;
height: auto;
box-shadow: 0px 2px 4px 0px rgba(0, 78, 106, 0.8);
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
background: #073351;
text-align: center;
padding: 6px;
transition: all 0.3s;
// white-space: nowrap;
// overflow: hidden;
// text-overflow: ellipsis;
}
.card:hover {
border: 1px solid #2be5eb;
background: #06436d;
box-shadow: 0px 2px 5px 0px rgba(0, 78, 106, 0.8);
}
.content {
font-size: 22px;
font-family: PingFangSC-Semibold, PingFang SC;
font-weight: 600;
color: #2be5eb;
line-height: 48px;
}
</style>
以上,文章开头图片当中的效果就可以实现了;当然有了以上的基础,我们还可以升级样式,如:
实现如下:
xml
<template>
<div class="hexagon">
<WordCloudRandom class="wrapper">
<div class="item" v-for="item in indiArr" :key="item.title">
<div class="shape" :class="[item.type, item.color]">
<div class="six-wrapper" v-if="item.type === 'six'">
<div class="main1 tool"></div>
<div class="main2 tool"></div>
<div class="main3 tool"></div>
<div class="main4 tool"></div>
<div class="main5 tool"></div>
<div class="main6 tool"></div>
</div>
</div>
<div class="title">{{ item.title }}</div>
</div>
</WordCloudRandom>
</div>
</template>
<script setup lang="tsx">
import {
onBeforeMount,
onBeforeUnmount,
onMounted,
watch,
watchEffect
} from 'vue'
import WordCloudRandom from '@/components/WordCloudRandom/index.vue'
const indiArr = ref([
{
title: '清运车数据',
type: 'four',
color: 'color2'
},
{
title: '集治点数据',
type: 'four',
color: 'color2'
},
{
title: '投放点数据',
type: 'four',
color: 'color3'
},
{
title: '巡查/问题既记录',
type: 'four',
color: 'color3'
},
{
title: '网格员信息',
type: 'four',
color: 'color4'
},
{
title: '垃圾箱满溢算法',
type: 'six',
color: 'color1'
},
{
title: '垃圾桶不规范算法',
type: 'six',
color: 'color1'
},
{
title: '打包垃圾检测算法',
type: 'six',
color: 'color1'
},
{
title: '推送服务',
type: 'three',
color: 'color6'
},
{
title: '算力资源',
type: 'zero',
color: 'color5'
}
])
</script>
<style lang="less" scoped>
.hexagon {
width: 1340px;
height: 1440px;
background-image: url(@/assets/images/page1/center-grid.png); //背景图
background-repeat: no-repeat;
background-size: 1340px 1440px;
padding: 300px 50px;
transition: 0.8s;
.wrapper {
position: relative;
width: 100%;
height: 100%;
.item {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 208px;
height: 263px;
background-image: url(@/assets/images/page1/bg-hexagon.svg); //背景图
background-repeat: no-repeat;
background-size: 100% 100%;
.shape {
position: absolute; // 父元素要加上position: relative;
top: 70px;
left: 50%;
transform: translate(-50%, -50%);
margin-bottom: 10px;
// 圆形
&.zero {
width: 33px;
height: 33px;
border-radius: 50%;
// background-color: red;
&.color1 {
background-color: #d64c3b;
}
&.color2 {
background-color: #ffd943;
}
&.color3 {
background-color: #ffa328;
}
&.color4 {
background-color: #46c2ff;
}
&.color5 {
background-color: #8f0eb8;
}
&.color6 {
background-color: #232cff;
}
}
// 三角形
&.three {
top: 75px;
left: 33%;
width: 0;
height: 0;
// border: 40px solid red;
// border-color: red transparent transparent transparent;
transform-origin: 50% 0 0;
transform: scaleX(0.6) rotateX(180deg);
&.color1 {
border: 40px solid #d64c3b;
border-color: #d64c3b transparent transparent transparent;
}
&.color2 {
border: 40px solid #ffd943;
border-color: #ffd943 transparent transparent transparent;
}
&.color3 {
border: 40px solid #ffa328;
border-color: #ffa328 transparent transparent transparent;
}
&.color4 {
border: 40px solid #46c2ff;
border-color: #46c2ff transparent transparent transparent;
}
&.color5 {
border: 40px solid #8f0eb8;
border-color: #8f0eb8 transparent transparent transparent;
}
&.color6 {
border: 40px solid #232cff;
border-color: #232cff transparent transparent transparent;
}
}
// 正方形
&.four {
width: 33px;
height: 33px;
// background-color: red;
&.color1 {
background-color: #d64c3b;
}
&.color2 {
background-color: #ffd943;
}
&.color3 {
background-color: #ffa328;
}
&.color4 {
background-color: #46c2ff;
}
&.color5 {
background-color: #8f0eb8;
}
&.color6 {
background-color: #232cff;
}
}
// 六边形
&.six {
left: 46%;
.six-wrapper {
transform: scale(0.3);
.main2 {
transform: rotate(60deg);
}
.main3 {
transform: rotate(120deg);
}
.main4 {
transform: rotate(180deg);
}
.main5 {
transform: rotate(240deg);
}
.main6 {
transform: rotate(300deg);
}
.tool {
width: 0px;
height: 0px;
border-right: calc(60px / 1.732) solid transparent;
border-left: calc(60px / 1.732) solid transparent;
// border-bottom: 60px solid red;
transform-origin: top;
position: absolute;
top: 0;
left: 0;
}
}
&.color1 {
.tool {
border-bottom: 60px solid #d64c3b;
}
}
&.color2 {
.tool {
border-bottom: 60px solid #ffd943;
}
}
&.color3 {
.tool {
border-bottom: 60px solid #ffa328;
}
}
&.color4 {
.tool {
border-bottom: 60px solid #46c2ff;
}
}
&.color5 {
.tool {
border-bottom: 60px solid #8f0eb8;
}
}
&.color6 {
.tool {
border-bottom: 60px solid #232cff;
}
}
}
}
.title {
position: absolute;
top: 40%;
// left: 0%;
width: 100%;
padding: 0 30px;
font-size: 32px;
font-family: PingFangSC-Semibold, PingFang SC;
font-weight: 600;
color: #ffffff;
line-height: 45px;
text-shadow: 0px 0px 10px rgba(30, 198, 255, 0.8);
text-align: center;
}
}
}
}
</style>
大功告成🎉🎉🎉