可视化大屏项目积累
由于最近一直在做可视化大屏,所以积累了一些经验,分享给大家。内容主要是大屏一些可复用的模块,项目技术栈:vite + vue3/vue2.7 + echarts。
一、关于适配
1.vw、vh方案
此方案是利用插件postcss-px-to-viewport 将px单位转换成vw、vh相对单位,在开发时可以对标设计稿写px,插件会帮你自动转,大致实现代码如下: 首先安装插件
import createPlugin from 'postcss-px-to-viewport'
然后在根目录下新建postcss.config.js
export default {
plugins: [
createPlugin({
// Options...
unitToConvert: 'px',
viewportWidth: 1920,
unitPrecision: 2,
propList: ['*'],
viewportUnit: 'vw',
fontViewportUnit: 'vw',
selectorBlackList: [],
minPixelValue: 2,
mediaQuery: false,
replace: true,
exclude: undefined,
include: undefined,
landscape: false,
landscapeUnit: 'vh',
landscapeWidth: 568,
}),
],
}
2.根节点css缩放方案
此方案就是使用css transform: scale(${scaleRatioX})属性去缩放根节点,采取了宽高比例一致,可根据不同场景自己衡量修改。代码如下:
/* 可视化大屏自适应缩放 */
//css部分
body.page-pos{
position: absolute;
top: 0;
left:50%;
overflow: hidden;
transform-origin: 0 0;
--un-translate-x: -50%;
--un-translate-y: 0;
transform: translateX(var(--un-translate-x)) translateY(var(--un-translate-y));
overflow: hidden;
}
//js部分
export default function useScalePage(option) {
onMounted(() => {
document.body.classList.add('page-pos')
triggerScale()
window.addEventListener('resize', triggerScale)
})
onUnmounted(() => {
window.removeEventListener('resize', triggerScale) // 释放
document.body.classList.remove('page-pos')
document.body.style = ''
})
function triggerScale() {
// 1.设计稿的尺寸
const {targetX=1920,targetY=1080}=option
// 2.拿到当前设备(浏览器)的宽度
const currentX = document.documentElement.clientWidth || document.body.clientWidth
// 3.计算缩放比例
const scaleRatioX = currentX / targetX
document.body.style = `width:${targetX}px; height:${targetY}px; transform: scale(${scaleRatioX}, ${scaleRatioX}) translateX(-50%)`
}
}
//组件内使用Hook
useScalePage({ targetX: 3840, targetY: 2160 })
二、全屏组件
<template>
<div @click="onToggle">
<img v-if="!fullScreen" src="@/assets/images/full.png" title="全屏" alt="">
<img v-else src="@/assets/images/exitfull.png" title="退出全屏" alt="">
</div>
</template>
<script setup>
let map = null
const functionsMap = [
[
'requestFullscreen',
'exitFullscreen',
'fullscreenElement',
'fullscreenEnabled',
'fullscreenchange',
'fullscreenerror',
],
// New WebKit
[
'webkitRequestFullscreen',
'webkitExitFullscreen',
'webkitFullscreenElement',
'webkitFullscreenEnabled',
'webkitfullscreenchange',
'webkitfullscreenerror',
],
// Old WebKit
[
'webkitRequestFullScreen',
'webkitCancelFullScreen',
'webkitCurrentFullScreenElement',
'webkitCancelFullScreen',
'webkitfullscreenchange',
'webkitfullscreenerror',
],
[
'mozRequestFullScreen',
'mozCancelFullScreen',
'mozFullScreenElement',
'mozFullScreenEnabled',
'mozfullscreenchange',
'mozfullscreenerror',
],
[
'msRequestFullscreen',
'msExitFullscreen',
'msFullscreenElement',
'msFullscreenEnabled',
'MSFullscreenChange',
'MSFullscreenError',
],
]
const fullScreen=ref(false)
const isSupported=ref(false)
const checkSupported = () => {
if (!document) {
return false
}
else {
for (const m of functionsMap) {
if (m[1] in document) {
map = m
//dispatch event
return true
}
}
}
return false
}
const onToggle = () => {
if (!isSupported.value) return
const [REQUEST, EXIT, ELEMENT] = map
if (!fullScreen.value) {
document?.querySelector('html')?.[REQUEST]()
} else {
if (document?.[ELEMENT]) {
document?.[EXIT]()
}
}
}
}
const onChange=()=>{
const [, , ELEMENT] = map
fullScreen.value = !!document?.[ELEMENT]
}
onMounted(()=>{
isSupported.value = checkSupported()
const [, , , , EVENT] = map
document.addEventListener(EVENT, onChange)
})
onUnmounted(() => {
const [, , , , EVENT] = map
window.removeEventListener(EVENT, onChange)
})
</script>
三、数字滚动组件
index.vue
<script setup>
const props = defineProps({
targetNum: {
type: Number,
default: 0,
},
duration: Number,
circleNum: {
type: Number,
default: 0,
},
isStart: {
type: Boolean,
default: false,
},
height: {
type: Number,
default: 40,
},
})
const startNum = ref(0)
watch(() => props.targetNum, (n, o) => {
startNum.value = o
})
const { targetNum, duration, circleNum, isStart, height } = toRefs(props)
const targetNumArr = computed(() => {
return `${targetNum.value}`.split('')
})
const startNumArr = () => {
const arr = `${startNum.value}`.split('')
while (arr.length < targetNumArr.value.length)
arr.unshift('0')
return arr
}
// 获取同一数位上的两个起止数字
const getTwoFigure = (i) => {
return [startNumArr()[i], targetNumArr.value[i]]
}
const direction = (i) => {
const [start, target] = getTwoFigure(i)
return +start > +target ? 'down' : 'up'
}
// 针对两个起止数字,生成动画需要的数组
const getFigureArr = (i) => {
let [start, target] = getTwoFigure(i)
const result = []
let direction = 'up'
if (+target === 0) {
if (+start > 5) {
for (let i = +start; i <= 9; i++)
result.unshift(i)
result.unshift(0)
}
else {
direction = 'down'
for (let i = 0; i <= +start; i++)
result.push(i)
}
}
else if (+start === 0) {
if (+target > 5) {
direction = 'down'
for (let i = +target; i <= 9; i++)
result.unshift(i)
result.unshift(0)
}
else {
for (let i = 0; i <= +target; i++)
result.push(i)
}
}
else {
if (+start > +target) {
direction = 'down';
[start, target] = [target, start]
}
for (let i = +start; i <= +target; i++)
result.push(i)
}
return result
}
</script>
<template>
<div class="cui-ani-number">
<SingleNumber v-for="figure, i in targetNumArr" :key="i" :direction="direction(i)" :figure-arr="getFigureArr(i)"
:duration="duration" :is-start="isStart" :height="height" :delay="0.2" />
</div>
</template>
<style scoped>
.digital-transform {
display: inline-flex;
}
.digital-transform-item {
display: inline-block;
transition: opacity 0.3s, transform 0.3s;
}
.vdt-slide-y-enter,
.vdt-slide-y-leave-to {
opacity: 0;
transform: translateY(10px);
}
.cui-ani-number {
display: inline-flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
</style>
SingleNumber.vue 单个数字滚动代码如下:
<script setup>
const props = defineProps({
figureArr: {
type: Array,
default: () => [],
},
duration: {
type: Number,
default: 2,
},
direction: {
type: String,
default: 'up',
},
delay: {
type: Number,
default: 0,
},
isStart: {
type: Boolean,
default: false,
},
height: {
type: Number,
default: 40,
},
})
const startAni = ref(false)
const { figureArr, duration, direction, delay, isStart, height } = toRefs(props)
watch(() => figureArr.value, (e) => {
startAni.value = true
}, { immediate: true })
const figureArrReverse = computed(() => { return Array.isArray(figureArr) && figureArr.reverse() })
const heightPx = computed(() => { return `${height.value}px` })
const totalHeight = computed(() => { return height.value * (figureArr.value.length - 1) })
const translateValPx = computed(() => `-${totalHeight.value}px`)
const styleObj = computed(() => {
return {
'--translate': translateValPx.value,
'--duration': `${duration.value}s`,
'--delay': `${delay.value}s`,
'height': heightPx.value,
}
})
const itemStyleObj = computed(() => {
return { 'line-height': heightPx.value, 'height': heightPx.value }
})
const onEnd = (e) => {
startAni.value = false
}
</script>
<template>
<div :style="styleObj" class="cui-ani-s-number">
<div class="box" :class="[startAni ? `${direction} ani` : '', direction === 'up' ? 'trans' : '']" @webkitAnimationEnd="onEnd">
<template v-if="Array.isArray(figureArr)">
<div v-for="figure, i in figureArr" :key="i" class="item" :style="itemStyleObj">
{{ figure }}
</div>
</template>
</div>
</div>
</template>
<style scoped>
.cui-ani-s-number {
font-size:inherit;
color: inherit;
overflow: hidden;
}
.box {
overflow: hidden;
}
.trans{
transform: translateY(var(--translate));
}
.ani {
animation-timing-function: ease-in-out;
animation-iteration-count: 1;
animation-duration: var(--duration);
animation-fill-mode: both;
animation-delay: var(--delay);
}
.up {
animation-name: up
}
.down {
animation-name: down;
}
.item {
text-align: center;
}
@keyframes up {
0% {
transform: translateY(0);
}
100% {
transform: translateY(var(--translate));
}
}
@keyframes down {
0% {
transform: translateY(var(--translate));
}
100% {
transform: translateY(0);
}
}
</style>
四、有背景图,可拖缀缩放的echarts地图(须于UI设计师配合)
由于之前做的echarts地图需要撒点位,故需要拖缀缩放地图模块,网上一些资源如果要添加背景图的话就无法进行拖缀缩放,此处完美解决了美观(绚丽的地形图)与功能(撒点功能具备拖缀与缩放)兼得。
<template>
<div id="eMap" ref="eMap"></div>
</template>
<script setup>
import echarts from "@/utils/echarts"
import mapBg from "@/assets/images/bg1840.png"//像素大小与#eMap地图div一致
import mapJson from "@/assets/json/mapgeo.json";
import wyJson from "@/assets/json/wy.json";
let mapChart = null
let domImg = null
const draw = () => {
mapChart = echarts.init(this.$refs.eMap);
echarts.registerMap("渭源", mapJson);
echarts.registerMap("wy", wyJson);
const option = {
tooltip: {
show: false,
},
series: [
{
type: "scatter",
coordinateSystem: "geo",
rippleEffect: {
brushType: "fill",
},
label: {
show: false,
color: "#f00",
offset: [0, 25],
formatter(obj) {
return obj.name;
},
},
symbol(_, params) {
return `image://${params.data.icon}`;
},
symbolSize: [72, 88],
showEffectOn: "render", // 加载完毕显示特效
itemStyle: {
borderColor: "#fff",
},
zlevel: 20,
data: pointAll //点位数据
},
],
geo: [
{
zlevel: 10,
show: true,
type: "map",
geoId: "555",
map: "渭源",
zoom: 1,
aspectScale: 1,
scaleLimit: { min: 0.6 },
layoutCenter: ["50%", "50%"],
layoutSize: 1690,
animationDurationUpdate: 0,
tooltip: {
show: true,
position: "top",
trigger: "item",
transitionDuration: 0,
formatter: (item) => {
if (item.componentType === "series") return `${item.name}`;
},
textStyle: {
fontSize: 28,
color: "#fff",
},
backgroundColor: "rgba(50,50,50,0.7)",
},
label: {
// 静态的时候展示样式
show: true, // 是否显示地图省份得名称
color: "#fff",
fontSize: 28,
fontFamily: "Arial",
},
blur: {
label: { show: true, color: "#000" },
},
roam: true, // 是否开启鼠标滚轮缩放
itemStyle: {
label: {
show: true,
color: "#fff",
},
color: "#fff",
borderColor: "#1db7ae",
borderWidth: 1,
opacity: 1,
areaColor: {
image: domImg,
repeat: "no-repeat",
},
},
emphasis: {
label: {
show: true,
color: "#fff",
fontSize: 28,
},
itemStyle: {
color: "#fff",
borderColor: "#01cebb",
borderWidth: 2,
areaColor: {
image: domImg,
repeat: "no-repeat",
},
},
},
},
{
show: true,
geoId: "666",
map: "wy",
zoom: 1,
aspectScale: 1,
scaleLimit: { min: 0.6 },
layoutCenter: ["50%", "50%"],
layoutSize: 1690,
animationDurationUpdate: 0,
tooltip: {
show: true,
position: "top",
trigger: "item",
transitionDuration: 0,
formatter: (item) => {
if (item.componentType === "series") return `${item.name}`;
},
textStyle: {
fontSize: 14,
color: "#fff",
},
backgroundColor: "rgba(50,50,50,0.7)",
},
label: {
// 静态的时候展示样式
show: true, // 是否显示地图省份得名称
color: "#fff",
fontSize: 28,
fontFamily: "Arial",
},
blur: {
label: { show: true, color: "#000" },
},
roam: false, // 是否开启鼠标滚轮缩放
itemStyle: {
color: "#fff",
borderColor: "#01cebb",
borderWidth: 20,
shadowColor: "#01cebb",
shadowBlur: 20,
shadowOffsetX: -5,
shadowOffsetY: 15,
opacity: 1,
areaColor: {
image: domImg,
repeat: "no-repeat",
},
},
},
],
};
mapChart.setOption(option);
mapChart.on("georoam", function (params) {
let option = mapChart.getOption(); //获得option对象
if (params.zoom != null && params.zoom != undefined) {
//捕捉到缩放时
option.geo[1].zoom = option.geo[0].zoom; //下层geo的缩放等级跟着上层的geo一起改变
option.geo[1].center = option.geo[0].center; //下层的geo的中心位置随着上层geo一起改变
} else {
//捕捉到拖曳时
option.geo[1].center = option.geo[0].center; //下层的geo的中心位置随着上层geo一起改变
}
mapChart.setOption(option); //设置option
});
}
domImg = document.createElement("img")
domImg.style.width = "1690px"
domImg.style.height = "1690px"//地图可视区域的宽高,即去除四周的白边部分
domImg.src = mapBg
domImg.onload = () => {
draw()
}
</script>
<style scoped>
#eMap {
position: relative;
pointer-events: visible;
width: 1840px;/*//地图的画布大小,拖缀缩放区域*/
height: 1840px;
}
</style>
五、封装Chart组件
1.按需引入 src/components/chart/index.ts
import Chart from './Chart.vue'
import * as echarts from 'echarts/core'
import {
BarChart,
} from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent
} from 'echarts/components'
import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import { App } from 'vue'
export const install = (app: App) => {
app.component('v-chart', Chart)
}
echarts.use([
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
LabelLayout,
UniversalTransition,
CanvasRenderer
])
export default {
install
}
2.Chart 组件 src/components/chart/Chart.vue
<template>
<div ref="chartRef" className='es-chart'></div>
</template>
<script setup lang='ts'>
import { onMounted, PropType, shallowRef, watch } from 'vue'
import * as echarts from 'echarts'
import { ECharts, EChartsCoreOption } from 'echarts'
const props = defineProps({
option: {
type: Object as PropType<EChartsCoreOption>,
required: true,
default: () => ({})
},
loading: Boolean
})
const chartRef = shallowRef<HTMLElement | null>(null)
const chart = shallowRef<ECharts | null>(null)
function init() {
if (props.option) {
chart.value = echarts.init(chartRef.value!)
setOption(props.option)
}
}
function setOption(option, notMerge?: boolean, lazyUpdate?: boolean) {
chart.value!.setOption(option, notMerge, lazyUpdate)
}
function resize() {
chart.value!.resize()
}
watch(() => props.option, () => {
setOption(props.option)
})
// show loading
watch(() => props.loading, (val) => {
if (!chart.value) return
if (val) {
chart.value!.showLoading()
} else {
chart.value!.hideLoading()
}
})
onMounted(() => {
init()
})
defineExpose({
chart,
setOption,
resize
})
</script>
<style lang='scss' scoped>
.es-chart {
width: 100%;
height: 100%;
}
</style>
3.注册组件 src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import * as chart from './components/chart'
createApp(App).use(chart).mount('#app')
<template>
<v-chart ref="chartRef" :option="option" />
</template>
<script setup lang='ts'>
import { ref } from 'vue'
const chartRef = ref()
const option = ref({
xAxis: {
type: 'category',
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
},
yAxis: {
type: 'value'
},
series: [
{
data: [120, 200, 150, 80, 70, 110, 130],
type: 'bar',
showBackground: true,
backgroundStyle: {
color: 'rgba(180, 180, 180, 0.2)'
}
}
]
})
</script>
六、拖拽布局
这里使用了 SortableJS 来实现拖拽
SortableJS是一个强大的JavaScript库,用于创建可排序、可拖放和可交互的列表。它提供了一种简单的方法来实现拖放排序功能,使用户可以通过拖动列表项来重新排序它们。
1.安装依赖
pnpm add sortablejs
2.封装useSortable
// src/utils/useSortable.ts
import { ref, onMounted, Ref } from 'vue'
import Sortable from 'sortablejs'
export const useSortable = (listRef: Ref<any[]>) => {
// 容器元素
const containerRef = ref()
onMounted(() => {
Sortable.create(containerRef.value!, {
swapThreshold: 1,
animation: 150,
onUpdate(e) {
const item = listRef.value[e.oldIndex]
listRef.value.splice(e.oldIndex, 1)
listRef.value.splice(e.newIndex, 0, item)
}
})
})
return {
containerRef
}
}
3.使用方式
<template>
<div ref="containerRef">
<component
v-for="item in components"
:key="item.name"
:is="item.component"
class="es-screen-right-item"
>
{{ item.name }}
</component>
</div>
</template>
<script setup lang='ts'>
import { shallowRef } from 'vue'
import { useSortable } from '@/utils/useSortable'
import Right1 from './Right1.vue'
import Right2 from './Right2.vue'
import Right3 from './Right3.vue'
const components = shallowRef([
{ name: 'right1', component: Right1 },
{ name: 'right2', component: Right2 },
{ name: 'right3', component: Right3 }
])
const { containerRef } = useSortable(components)
</script>
<style lang='scss' scoped>
.es-screen-right-item {
width: 100%;
height: 300px;
background-color: var(--es-block-bg);
padding: 16px;
& + & {
margin-top: 20px;
}
}
</style>
七、无缝滚动封装
无缝滚动在大屏可视化项目中非常常见,本小节使用animejs实现了一个支持横纵无缝滚动的自定义钩子
1.安装 animejs 依赖
pnpm add animejs
2.实现
import { onMounted, shallowRef, Ref } from 'vue'
import anime from 'animejs/lib/anime.es.js'
export type OptionsType = {
direction?: 'horizontal' | 'vertical'
gap?: number
duration?: number
}
export function useSeamlessScroll(listRef: Ref<HTMLElement | null>, options: OptionsType = {}) {
const {
direction = 'horizontal',
gap = 10,
duration = 10000
} = options
const animation = shallowRef<ReturnType<typeof anime>>(null)
function init() {
const isHorizontal = direction === 'horizontal'
const translateKey = isHorizontal ? 'translateX' : 'translateY'
const transKey = isHorizontal ? 'x' : 'y'
// items
const children = listRef.value?.children || []
if (!children.length) return
// 第一个元素
const firstEl = children[0] as HTMLElement
const firstDiff = (isHorizontal ? firstEl.offsetWidth : firstEl.offsetHeight ) + gap
// 默认将list元素向左或向上移动一个item的距离
listRef.value!.style.transform = `${translateKey}(-${firstDiff}px)`
const transList: any = []
let total = 0 // 总宽/高
// 设置初始位置
anime.set(children, {
[translateKey]: (el: HTMLElement, i) => {
const distance = (isHorizontal ? el.offsetWidth : el.offsetHeight ) + gap
total += distance
transList[i] = { [transKey]: total - distance }
}
})
// 设置list容器的宽或高
listRef.value!.style[isHorizontal ? 'width' : 'height'] = total + 'px'
// 添加动画
animation.value = anime({
targets: transList,
duration,
easing: 'linear',
direction: isHorizontal ? undefined : 'reverse',
[transKey]: `+=${total}`,
loop: true,
update: () => {
anime.set(children, {
[translateKey]: (el, i) => {
return transList[i][transKey] % total
}
})
}
})
}
// 暂停
function pause() {
animation.value!.pause()
}
// 停止
function play() {
animation.value!.play()
}
onMounted(() => {
init()
})
return {
listRef,
pause,
play,
animation
}
}
useSeamlessScroll 接受两个参数:listRef 和 options。listRef 是一个 Ref 对象,用于引用滚动列表的 DOM 元素。options 是一个配置对象,可以设置滚动的方向、间隙和持续时间。 步骤解析:
(1)根据传入的滚动方向,确定 translateKey 和 transKey,translateKey 表示 CSS 中的移动方向,transKey 表示animejs中的x/y轴方向。
(2)获取滚动列表的子元素,并计算第一个元素的偏移量。因为默认从从第二个元素开始,这样初始移动是才不会出现空白
(3)初始化滚动列表的位置,将其向左(横向滚动)或向上(纵向滚动)移动一个元素的距离。
(4)遍历子元素,计算每个元素的偏移量,并将其设置为初始位置。
(5)根据滚动方向,设置滚动列表容器的宽度或高度。
(6)使用 animejs 库实现无缝滚动效果,在动画更新时,根据计算出的偏移量更新子元素的位置。
3.使用 useSeamlessScroll
<template>
<div class="es-center-bottom">
<div ref="listRef" class="es-bottom-list">
<div v-for="item in 10" class="es-bottom-item">
{{ item }}
</div>
</div>
</div>
</template>
<script setup lang='ts'>
import { ref } from 'vue'
import { useSeamlessScroll } from '@/utils/useSeamlessScroll'
const listRef = ref()
useSeamlessScroll(listRef)
</script>
<style lang='scss' scoped>
.es-center-bottom {
position: relative;
width: 100%;
overflow: hidden;
height: 150px;
.es-bottom-item {
position: absolute;
top: 0;
left: 0;
width: 170px;
height: 150px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--es-block-bg);
font-size: 22px;
font-weight: 600;
.es-item-text {
margin-top: 16px;
}
}
}
</style>
4.抽离成组件简化使用方式
新建 src/components/SeamlessScroll.vue
<template>
<div class="es-seamless-scroll">
<div ref="listRef" class="es-seamless-scroll-list">
<slot />
</div>
</div>
</template>
<script setup lang='ts'>
import { useSeamlessScroll, OptionsType } from '@/utils/useSeamlessScroll'
import { PropType, ref } from 'vue'
const props = defineProps({
width: {
type: [String, Number]
},
height: {
type: [String, Number]
},
option: {
type: Object as PropType<OptionsType>,
default: () => ({})
}
})
const listRef = ref()
useSeamlessScroll(listRef, props.option)
</script>
<style lang='scss' scoped>
.es-seamless-scroll {
overflow: hidden;
width: 100%;
height: 100%;
}
</style>
使用组件
<template>
<SeamlessScroll class="es-center-bottom">
<div v-for="item in 10" class="es-bottom-item">
{{ item }}
</div>
</SeamlessScroll>
</template>
<script setup lang='ts'>
import SeamlessScroll from '@/components/SeamlessScroll.vue'
</script>
<style lang='scss' scoped>
.es-center-bottom {
position: relative;
width: 100%;
overflow: hidden;
height: 150px;
.es-bottom-item {
position: absolute;
top: 0;
left: 0;
width: 170px;
height: 150px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--es-block-bg);
font-size: 22px;
font-weight: 600;
.es-item-text {
margin-top: 16px;
}
}
}
</style>
效果和直接使用钩子一样,不过组件使用起来还是要简单一点; 组件默认是横向滚动,如果想纵向滚动传入direction为vertical即可。
结尾
更多组件钩子后续更新...