前端绘制道路鱼骨图
项目背景:需要实现道路情况鱼骨图,根据上下行道路分别显示对应的道路情况和沿路设施状况,箭头根据所示方向平滑移动。
·
项目背景:需要实现道路情况鱼骨图,根据上下行道路分别显示对应的道路情况和沿路设施状况,箭头根据所示方向平滑移动

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 * 120
let 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 * 120
sectionWidth = (width / upList.value.length) + 'px'
}
return sectionWidth
})
const downSectionWidth = computed(() => {
const downWidthShow = scrollContainers.value?.offsetWidth >= downList.value.length * 120
let 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 * 120
downSectionWidth = (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到一个新的知识点,希望大家多多指正,一起加油!
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)