js基于div丝滑实现贝塞尔曲线

JavaScript/前端
339
0
0
2023-02-26
目录
  • 引言
  • 分析
  • 实现
  • 布局
  • 如何计算y的偏移量
  • Math.sin() 和 Math.cos()
  • 正弦曲线
  • 余弦曲线
  • 计算更多信息
  • 完善剩余
  • 完成跑马灯制作
  • 贝塞尔曲线
  • 简单封装一下方法
  • 完整示例
  • style
  • SCript

引言

今天遇到朋友发来的一个ui图,询问我如何实现下图这样的效果【vue项目】,(听说是类似LED灯的展示效果),于是便有了今天的小demo,要实现一个类似下图的动效,上面的灯会一直重复滚动,但是这个并不是什么难点,主要在于如何实现这种平滑的曲线,日常我们的开发的div在我们的脑海中通常就是一个网格状,涉及到平滑曲线的往往是图表,于是我们需要找一个方案来完成这种布局(非真实ui图,是完成之后的效果)

分析

我们需要先简单分析一下这个ui,当我们拿到这个UI图的时候,脑海中的第一反应是,一个大的DIV中间套了很多的小的DIV,并且小的的上下位置出现了偏移,但是偏移多少目前我们不得而知,但是基础的布局方案已经完成。

第二步我们考虑球体的颜色,可以看到,轨道是一种颜色,需要一直移动的球体是另一种颜色,这个非常简单,我们定义两组数据,一组是轨道,一组是高亮的球,通过不段改变高亮的这组数据,即可响应式的完成灯的移动,第二点我们也解决了

第三点,初始的时候考虑的是y的坐标是0, 2, 4, 6, 8, 10 , 8, 6, 4 , 2 ..... ,但是很显然,这样的坐标出来的形状一定是一个折线图,而不是平滑的曲线,于是我们需要用到数学知识了:需要使用到圆的弧度的概念,在javascript中有两个方法**Math.sin()和Math.cos()**都是关于弧度的公式,关于这两个方法,我们下面再说。

实现

布局

实现这个的布局非常简单,外层一个大的div,内层很多小的span,通过flex一排即可到一排

<template>
  <div class="container">
    <div class="content">
      <span class="circle" v-for="(item,index) in list" :key="index"></span>
    </div>
  </div>
</template>

如何计算y的偏移量

这一步是我们比较重要的一步,我们有一个400px的容器,容器中放置了20个span,现在他们在一排,我们只需要给他动态绑定样式**transform: translateY(?px)**即可,重要的是我们如何计算这个的坐标,我们先来了解下两个方法的用处:

Math.sin() 和 Math.cos()

Math.sin(x)     x 的正玄值。返回值在 -1.0 到 1.0 之间;

Math.cos(x)    x 的余弦值。返回的是 -1.0 到 1.0 之间;

可以看到其分别是x点的正弦,这两个函数中的x指的是弧度而不是角度弧度的计算公式是:2π/360°

这里涉及到数学知识,我们先看看这张图

我们看我们关注的sin和cos

sin(∠A) = 对边比斜边(a / c)
cos(∠A) = 临边比斜边 (b / c)

可大致了解一下即可,当然,我们今天所需要使用的和这个关系不大,这里只是帮大家回顾一下高中知识(手动狗头)。

好了这里直接推荐一个在线网站,图形计算器可以直接在线调试各种曲线

我们看看基础的正弦余弦曲线

正弦曲线

余弦曲线

我们知道圆周率(π), 1π=180°2π=360°,就是一周,所以我们只需要截图(0-2π)一个周期的曲线即可,后续不管要什么曲线,都在这个上面进行变换即可,通过上面对比,发现正弦曲线的起始点是(0,0),比余弦的(0,1)更好计算,我们就直接用正弦吧,那么我们列出已知条件:

  • 在曲线中 y = cos(x)
  • 在曲线中,曲线的宽度是
  • 在曲线中,曲线的高度最高点到最低点是2
  • 在我们的需求中,总宽度是400px
  • 在我们需求中, 共有二十个圆圈,所以我们可以算出每个球的宽度平均是20px,所以坐标就是(index+1)*20
  • 现在我们知道了很多信息,我们就可以计算出更多信息了

计算更多信息

我们知道曲线的宽度和我们的物理实际宽度就可以得出宽度比: 400 / 2π

这个时候我们需要通过这个比例计算出物理的x坐标对应的曲线中的x坐标,那么 物理宽度/x坐标 = 2π/曲线中x坐标

/* 400 / x = 2π / y, 我们的x是已知的,等下自己可以拿,这样拿到了曲线中实际的x坐标 */
const z = 400 x / 400 * Math.PI*2

有个曲线中的对应x坐标,通过公式我们就可以拿到其曲线中实际y坐标了

/* 这样就拿到了曲线中的y坐标 */
y = Math.sin(z)

拿到了曲线中的y坐标,那么们又知道,曲线中的总高是2,通过xy的坐标对比,我们可以计算出我们所需的真实的y

/* 真实宽度400/曲线宽度2π = 真实高度y/曲线中的y 通过对比得到真实的y点 */
Y = Math.sin(z) * 400 / Math.PI * 2 / 2

然后通过这样的一个计算公式把这个y值赋值给我们的y点就可以得到这样的曲线

完善剩余

看起来有点意思了,这就是一个完整的2π,或者我们理解为就是曲线的一个周期,但是很明显曲线的度数不对,我们如何调整呢,回到刚刚的那个网站之中,我们要想曲线更加平滑,只需要对sin()除以/x即可,x最大线越平,我们到刚刚的网站去自己调试到自己理想的高度,

我们调试发现除以4就得到了差不多我们想要的曲线,所以我们只需要在上面的基础上/4就得到了我们真正想要的y。

此时我们的曲线就已经完成了,所以其实是不是就是我们的高中数学知识呢

完成跑马灯制作

前面的曲线画完,后面就已经不难了,我们只需要定义一段高亮的下标数组,我们写一个方法,创建一个自己想要高亮几个就生成0-x的数组

createActiveIndex(len = 6){
  return Array.from({length:len}, (v,k) => k)
},

然后在给span动态绑定一个背景颜色。当index属于高亮的时候就给高亮的颜色,不是则反之,然后我们写一个定时器一直修改这个高亮的数组即可,每次让其里面所有元素加1,就可以让他一直跑下去了,当然边界的时候我们需要对他进行归0

changeIndex(){
   this.activeIndex = this.activeIndex.map( item => item === this.list.length - 1 ? 0 : item + 1)
},

最后我们启动即可,就实现了我们开头想要的效果。

至此这个需求算是完成了,这只是一个小的场景通过这样的方式我们可以绘制出更多好玩的东西,你可以改变各种参数对齐进行调整修改,看看是不是你想要的效果

贝塞尔曲线

我们知道,前端的动画经常出现一个名词贝塞尔曲线,就是动画的执行过程,我们刚刚的曲线其实就是同理,如果此时我们需要去手动书写一个贝塞尔曲线我们应该怎么做呢,刚刚我们知道,我们容器的总宽度是400,曲线的周长是2π,比例就是400/2π,同理,当我们换算成时间的时候,假如动画是1秒。

那么我们需要60帧,一帧动画的时间就是1000/60=16.7ms,我们通过2π/60就知道我们每一帧动画在什么位置了,当我们手写贝塞尔曲线的时候,利用差不多的公式一样可以完成。

简单封装一下方法

看起来似乎很复杂,但是实际上我们所需要的其实只是利用真实的x点,拿到对应曲线求出我们y的坐标,所以我们需要的参数有,我们真实场景的总宽,总宽之中的个数,我们所需要的曲线的倍率,三个参数即可,我们尽量分开步骤写,这样你看会理解的更清楚

js中π就是Math.PI

function getCoordinate(width, count,  mag = 1){
      /* 通过总宽和个数计算出一个单个的宽 */
      const singleWidth = width / count
      /* 通过物理宽度/曲线周长计算出比率 */
      const ratio = 400 / Math.PI*2
      /* 上面实例代码我们是动态一次计算一个,而现在是方法,我们应该一次去拿到所有,所以我们返回一个数组对象记录xy */
      let result = new Array(count).fill({})
      /* 遍历总长度的dom个数,在数组中填充宽高 */
      result = result.map( (item,index) => {
          /* x的坐标 */
          const x = (index + 1) * singleWidth
          /* 定义变量z计算曲线中x的坐标 */
          const z = x / width * Math.PI*2
          /* 计算出真实的y的坐标 */
          let y = Math.sin(z) / 4  * 400 / Math.PI * 2 / 2    
          /* y还需要通过倍率改变曲线,得到最终我们想要的y */
          y = y / mag
          /* 写入数组对象中 */
          return {x, y}
      })
      return result;
    } 

完整示例

style

.container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background-color: #000;
}
.content{
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 400px;
  height: 50px;
}
.container .circle{
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background-color: #befbf7;
}

SCript

<template>
  <div class="container">
    <div class="content">
      <span class="circle" v-for="(item,index) in list" :key="index" :style="{transform: `translateY(${calcY(index)}px)`,backgroundColor: getCurrentBgColor(index)}"></span>
    </div>
     <div class="content" style="margin-top: 50px">
      <span class="circle" v-for="(item,index) in list" :key="index" :style="{transform: `translateY(${calcY(index)}px)`,backgroundColor: getRandomBgColor(index)}"></span>
    </div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      list: [], //定义总长度
      activeIndex: [], // 运动中的球的颜色
      interval: 300, //运动速度
      colors: { // 定义轨道颜色和高亮颜色
        active: '#2b88ff',
        basic: '#b6f3f7' 
      },
      cache: []
    };
  },
  methods: {
    init() {
      this.list = new Array(20).fill(0)
      this.start()
    },
    getCurrentBgColor(index){
      return this.activeIndex.includes(index) ? '#6e9cae' : this.getRandomColor()
    },
    getRandomBgColor(index){
      const color = this.activeIndex.includes(index) ? 'active' : 'basic'
      return this.colors[color]
    },
    start(){
      this.activeIndex = this.createActiveIndex()
      setInterval(() => this.changeIndex(), this.interval)
    },
    changeIndex(){
      this.activeIndex = this.activeIndex.map( item => item === this.list.length - 1 ? 0 : item + 1)
    },
    /* 生成需要动的球的个数 */
    createActiveIndex(len = 6){
      return Array.from({length:len}, (v,k) => k)
    },
    getRandomColor(){
        return `#${Math.floor(Math.random() * 0xffffff) .toString(16)}`;
    },
    getCoordinate(width, count,  mag = 1){
      /* 通过总宽和个数计算出一个单个的宽 */
      const singleWidth = width / count
      /* 通过物理宽度/曲线周长计算出比率 */
      const ratio = 400 / Math.PI*2
      /* 上面实例代码我们是动态一次计算一个,而现在是方法,我们应该一次去拿到所有,所以我们返回一个数组对象记录xy */
      let result = new Array(count).fill({})
      /* 遍历总长度的dom个数,在数组中填充宽高 */
      result = result.map( (item,index) => {
          /* x的坐标 */
          const x = (index + 1) * singleWidth
          /* 定义变量z计算曲线中x的坐标 */
          const z = x / width * Math.PI*2
          /* 计算出真实的y的坐标 */
          let y = Math.sin(z) / 4  * 400 / Math.PI * 2 / 2    
          /* y还需要通过倍率改变曲线,得到最终我们想要的y */
          y = y / mag
          /* 写入数组对象中 */
          return {x, y}
      })
      return result;
    } 
  },
  created(){
    this.cache = this.getCoordinate(400, 20, 1)
    this.init()
  },
  computed:{
    calcY(){
      return (index) => {
        /* 使用封装的方法计算 */
        // return this.cache[index].y
        const x = (index + 1) * 20
        const z = x / 400 * Math.PI*2
        const y = Math.sin(z) * 400 / Math.PI * 2 / 2 / 4
        return y
      }
    }
  }
};
</script>