前端绘制道路鱼骨图
项目背景:需要实现道路情况鱼骨图,根据上下行道路分别显示对应的道路情况和沿路设施状况,箭头根据所示方向平滑移动
1.封装组件,创建FishboneDiagram.vue文件
<template><div class="fishedOneBox flex items-center"><div @click="scrollContent('left')" class="left cursor-pointer leftButtonBox pl-20px box-border w-60px p-15px box-border h-165px flex justify-center items-center"><button class="text-(16px #7BA9FA) font-bold leading-20px">哈密方向</button></div><div class="content" ref="scrollContainers"><div class="upList mb-6px"><!-- 上行公路 --><div class="road"><div class="upRoadItem relative" v-for="(item, index) in upList" :key="index":style="{ width: sectionWidth, background: item.status === 1 ? '#4ACF50' : '#D6D6D6', borderLeft: getLeftBorder(index, upList), borderRight: getRightBorder(index, upList) }"><img class="arrows arrow-lefts" src="/@/assets/images/iconLook/left_arrow.png" alt=""><img class="arrow arrow-left" src="/@/assets/images/iconLook/left_arrow.png" alt=""><!-- 桩号 --><div class="text-(12px #999999) absolute right--30px top--18px">{{ item.id }}</div><!-- 路侧设备(服务区,互通) --><div class="absolute top--51px" v-if="item.staketype !== 4 && item.staketype !== 5"><div class="flex rounded-16px pl-7px pr-7px pt-3px pb-3px" :class="item.staketype === 2 ? 'bg-#E4FEE0' : 'bg-#E4EEFF'"><img class="w-20px h-20px" :src="getImageUrl(item.staketype === 1 ? 'tollStation':item.staketype === 2 ? 'service':item.staketype === 3 ? 'interFlow' : '' , 'fishBoneIcon')" alt=""><span class="text-(14px #333333) ml-2px">{{ item.stakename }}</span></div><div class="line w-full flex justify-center"><img class="w-5px h-24px" :src="getImageUrl(`${item.staketype === 2 ? 'line_up_green' : 'line_up_blue'}`, 'fishBoneIcon')" alt=""></div></div></div></div></div><div class="downList"><!-- 下行公路 --><div class="road"><div class="upRoadItem relative" v-for="(item, index) in downList" :key="index":style="{ width: downSectionWidth, background: item.status === 1 ? '#4ACF50' : '#D6D6D6', borderLeft: getLeftBorder(index, downList), borderRight: getRightBorder(index, downList) }"><img class="downArrow arrow-right" src="/@/assets/images/iconLook/right_arrow.png" alt=""><img class="downArrows arrow-rights" src="/@/assets/images/iconLook/right_arrow.png" alt=""><!-- 桩号 --><div class="text-(12px #999999) absolute right--30px bottom--20px">{{ item.id }}</div><!-- 下行路侧设备(服务区,互通) --><div class="absolute bottom--51px" v-if="item.staketype !== 4 && item.staketype !== 5"><div class="line w-full flex justify-center"><img class="w-5px h-24px" :src="getImageUrl(`${item.staketype === 2 ? 'line_down_green' : 'line_down_blue'}`, 'fishBoneIcon')" alt=""></div><div class="flex rounded-16px pl-7px pr-7px pt-3px pb-3px" :class="item.staketype === 2 ? 'bg-#E4FEE0' : 'bg-#E4EEFF'"><img class="w-20px h-20px" :src="getImageUrl(item.staketype === 1 ? 'tollStation':item.staketype === 2 ? 'service':item.staketype === 3 ? 'interFlow' : '' , 'fishBoneIcon')" alt=""><span class="text-(14px #333333) ml-2px">{{ item.stakename }}</span></div></div></div></div></div></div><div @click="scrollContent('right')" class="right cursor-pointer rightButtonBox pr-20px box-border w-60px h-165px flex justify-center p-15px box-border items-center"><button class="text-(16px #7BA9FA) font-bold leading-20px" >星星峡方向</button></div></div>
</template><script setup lang="ts">
import { onMounted, ref, computed } from "vue"
import { getImageUrl } from '/@/utils'
const myTimeout = ref()
const scrollTimeout = ref()
const scrollContainers = ref()
const maxScroll = ref()const props = defineProps({value: {type: Object,default: {}}
});// 上行
const upList = computed(() => {return props.value.upList
})// 下行
const downList = computed(() => {return props.value.downList
})
// 根据路段设施数量确定区间宽度
const sectionWidth = computed(() => {const widthShow = scrollContainers.value?.offsetWidth >= upList.value.length * 120let sectionWidth: any = "120px"if(upList.value.length >= downList.value.length) {if (widthShow) {sectionWidth = (scrollContainers.value?.offsetWidth / upList.value.length) + 'px'} else {sectionWidth = "120px"}} else {const width = downList.value.length * 120sectionWidth = (width / upList.value.length) + 'px'}return sectionWidth
})
const downSectionWidth = computed(() => {const downWidthShow = scrollContainers.value?.offsetWidth >= downList.value.length * 120let downSectionWidth: any = "120px"if(downList.value.length >= upList.value.length) {if (downWidthShow) {downSectionWidth = (scrollContainers.value?.offsetWidth / downList.value.length) + 'px'} else {downSectionWidth = "120px"}} else {const width = upList.value.length * 120downSectionWidth = (width / downList.value.length) + 'px'}return downSectionWidth
})
// 边框逻辑
const getLeftBorder = (index: number, list: any[]) => {if (index === 0) return ''; // 第一个元素始终显示左边框const prevItem = list[index - 1];const current = list[index];if (prevItem.end === 1 && current.start === 1) {return ''; // 当前元素的左边框不显示} else if (current.start === 1) {return '2px solid #ffffff';}return '';
};const getRightBorder = (index: number, list: any[]) => {if (index === list.length - 1) return ''; // 最后一个元素始终显示右边框const current = list[index];const nextItem = list[index + 1];if (current.end === 1 && nextItem.start === 1) {return ''; // 当前元素的右边框不显示} else if (current.end === 1) {return '2px solid #ffffff';}return '';
};
const scrollContent = (direction: any) => {// 清除之前的防抖计时器(如果存在)clearTimeout(scrollTimeout.value);scrollTimeout.value = setTimeout(() => {clearTimeout(myTimeout.value)const scrollContainer = scrollContainers.value;const scrollStep = 40; // 每次滚动的步长const scrollInterval = setInterval(() => {if (direction === 'left') {if (scrollContainer?.scrollLeft > 0) {scrollContainer.scrollLeft -= scrollStep;} else {clearInterval(scrollInterval);}} else {if (scrollContainer?.scrollLeft < maxScroll.value) {scrollContainer.scrollLeft += scrollStep;} else {clearInterval(scrollInterval);}}}, 20); // 滚动间隔时间,数值越小滚动越快myTimeout.value = setTimeout(() => {clearInterval(scrollInterval)}, 200)}, 200)
}
const updateScrollRange = () => {const scrollContainer = scrollContainers.value;// maxScroll.value = scrollContainer?.scrollWidth - scrollContainer?.clientWidth;maxScroll.value = scrollContainer?.scrollWidth
}
onMounted(() => {updateScrollRange()
})
</script><style lang="scss" scoped>
.fishedOneBox {width: 100%;height: 200px;
}
.leftButtonBox{background: url('/@/assets/images/leftButtonBox.png') no-repeat left center;background-size: 60% 100%;
}
.rightButtonBox{background: url('/@/assets/images/rightButtonBox.png') no-repeat right center;background-size: 60% 100%;
}
.content {display: flex;height: 186px;flex-direction: column;justify-content: center;width: calc(100% - 120px);overflow-x: scroll;overflow-y: hidden;.upList,.downList {display: flex;align-items: center;justify-content: flex-start; /* 确保子项目从左到右排列 */position: relative;.road {display: flex;background: #D6D6D6;.upRoadItem {background: #4ACF50;height: 30px;display: flex;align-items: center;//border-left: 1px solid #ffffff;//border-right: 1px solid #ffffff;.arrow {display: inline-block;position: relative;pointer-events: none;}.arrows {display: inline-block;position: relative;pointer-events: none;}.downArrow {display: inline-block;position: relative;pointer-events: none;}.downArrows {display: inline-block;position: relative;pointer-events: none;}}}}
}
.arrow-right {animation: moveRight 2.5s infinite linear forwards;
}
.arrow-rights {animation: moveRights 2.5s infinite linear forwards;
}
.arrow-left {animation: moveLeft 2.5s infinite linear forwards;
}
.arrow-lefts {animation: moveLefts 2.5s infinite linear forwards;
}
@keyframes moveRight {0% {left: -55%;}100% {left: 45%;}
}
@keyframes moveRights {0% {left: -5%;}100% {left: 95%;}
}
@keyframes moveLeft {0% {left: 40%;}100% {left: -60%;}
}
@keyframes moveLefts {0% {left: 95%;}100% {left: -5%;}
}
</style>
2. 引用组件
<template>
<Fishbone :value="dataInfoList" />
</template><script setup lang="ts">
import Fishbone from "./Fishbone.vue"// 鱼骨图上行下行数据
const dataInfoList: any = ref({upList: [],downList: []
})
</script>
小结:
1. 根据上行下行数据画出上行下行路段
2. 再根据每段路中是否存在一些设备设施,通过v-if渲染出来
3. 道路状况也可以根据当前此段道路的拥堵情况渲染不同的颜色
4. 路段动画根据图标方向对图标做left或者right的平移动画
根据自身业务情况适当修改,鱼骨图可以根据业务方向继续延伸,希望大家能有一点思路,我也是从毫无头绪慢慢画出来,又get到一个新的知识点,希望大家多多指正,一起加油!