使用d3绘制柱状图

419 阅读4分钟

Html结构

<!DOCTYPE html>
<html lang="zh_CN">
  <head>
    <meta charset="UTF-8" />
    <title>柱状图</title>
    <style>
      html {
        height: 100%;
      }
      body {
        height: 100%;
        margin: 0;
      }
      #main {
        width: 100%;
        height: 100%;
        background-color: antiquewhite;
      }
      #tip {
        position: absolute;
        margin-left: 10px;
        margin-top: 30px;
        line-height: 22px;
        background-color: rgba(0, 0, 0, 0.6);
        padding: 4px 9px;
        font-size: 13px;
        color: #fff;
        border-radius: 3px;
        pointer-events: none;
        display: none;
      }
    </style>
  </head>
  <body>
    <div id="main"></div>
    <script src="https://d3js.org/d3.v6.min.js"></script>
    <script>
    // ...
    </script>

  </body>
</html>

1. 必备数据

// X轴数据源
const categories=['html','css','js'];
// Y轴数据源
const source = [
        [30, 20, 40], //学习人数
        [40, 30, 50], //就业人数
      ];
/*dimensions 维度信息*/
const dimensions=['学习人数','就业人数'];
// 调色盘
const color=['#c23531','#2f4554', '#61a0a8', '#d48265', '#91c7ae','#749f83',  '#ca8622', '#bda29a','#6e7074', '#546570', '#c4ccd3'];

const width=600
const height=600

/*优化数据源
 * 使用map方法遍历数据源
 * 将其中的数据变成对象:
 * {
 *  rectData:柱状体数据,
 *  rectInd:柱状体索引,
 *  rectName:柱状体名称,
 *  seriesInd:系列索引,
 *  seriesName:系列名称
 * }
 **/
const source2=source.map((seriesData,seriesInd)=>{
  const seriesName=dimensions[seriesInd]
  return seriesData.map((rectData,rectInd)=>{
    const rectName=categories[rectInd]
    return {rectData,rectInd,rectName,seriesInd,seriesName}
  })
})

2. 创建svg

const main=d3.select('#main')
const svg=main.append('svg')
        .attr('version',1.2)
        .attr('xmlns','http://www.w3.org/2000/svg')
        .attr('width','100%')
        .attr('height','100%')
        .attr('viewBox',`0 0 ${width} ${height}`)

3. 绘制X轴

  • 创建X轴基础数据

    /*计算类目数量 len*/
    const len=categories.length
    /*用range()方法,基于类目数量,获取X轴的在图表坐标系中的数据 xChartData,如[0,1,2]*/
    const xChartData=d3.range(len)
    /*X轴在像素坐标内的起始点和结束点 xPixelRange,左右各偏移50*/
    const xPixelRange=[50,width-50]
    
  • 创建X轴比例尺

    /*
     * 用scaleBand()方法建立分段比例尺 xScale
     * 用domain()方法在比例尺中写入图表数据xChartData
     * 用rangeRound()方法在比例尺中写入像素数据,即像素的起始位和结束位xPixelRange
     * */
    const xScale=d3.scaleBand()
      .domain(xChartData)
      .rangeRound(xPixelRange)
    
  • 创建X轴对象

    /*基于比例尺xScale,用axisBottom()方法创建刻度朝下的坐标轴生成器 xAxisGenerator*/
    const xAxisGenerator=d3.axisBottom(xScale)
    
  • 绘制X轴

    /**	利用坐标轴生成器绘制坐标轴
      * 在svg中append 加入g 对象
      * 用transform 属性中的translate设置X轴的y位置
      * 用call()方法调用xAxisGenerator轴生成器,生成坐标轴
      * 用selectAll()方法选择所有的text文本
      * 用text()方法将图表数据设置为类目数据
      * 用attr()方法设置字体大小
      * */
    svg.append('g')
      .attr('transform',`translate(0,${height-50})`)
      .call(xAxisGenerator)
      .selectAll('text')
      .text(d=>categories[d])
      .style('font-size','12px')
    

4. 绘制Y轴

  • 创建Y轴数据

    /** 计算数据源中所有数据的极值 maxY
      * 用js原生方法flat()展开数据源,再通过max()方法取极值
      * */
    const maxY=Math.max(...source.flat())
    
    /*声明y轴在图表坐标系中的数据起点和结束点 yChartRange*/
    const yChartRange=[0,maxY]
    
    /*声明y轴在像素坐标系中的数据起点和结束点 yPixelRange*/
    const yPixelRange=[height-50,50]
    
    
  • 创建Y轴比例尺

    /**
      * 用scaleLinear()方法建立线性比例尺 yScale
      * 用domain()方法在比例尺中写入图表数据yChartRange
      * range()方法在比例尺中写入像素数据,即像素的起始位和结束位yPixelRange
      * */
    const yScale=d3.scaleLinear()
    .domain(yChartRange)
    .range(yPixelRange)
    
  • 创建Y轴对象

    /*基于比例尺yScale,用axisLeft()方法创建刻度朝左的坐标轴生成器 yAxisGenerator*/
    const yAxisGenerator=d3.axisLeft(yScale)
    
  • 绘制Y轴

    /** 利用坐标轴生成器生成坐标轴
      * 在svg中append 加入g 对象
      * 用transform 属性中的translate设置y轴的x位置
      * 用call()方法调用xAxisGenerator轴生成器,生成坐标轴
      * 用style()方法设置字体大小
      * */
    svg.append('g')
      .attr('transform','translate(50 0)')
      .call(yAxisGenerator)
      .style('font-size','12px')
    

5. 绘图区(柱状图绘制)

  • 建立基础数据

    /*用x轴比例尺xScale的bandwidth()方法获取x轴上一个类目的像素宽xBandW*/
    const xBandW=xScale.bandwidth()
    
    /*获取系列的数量n*/
    const n=source.length
    
    /*用类目宽除以系列数,得到一个类目中每个系列元素的宽,即列宽colW*/
    const colW=xBandW/n
    
    /*计算调色盘颜色数量colorLen*/
    const colorLen=color.length
    
  • 构建绘图区

    /* 在svg中建立系列集合seriesObjs,在系列集合中建立系列对象
     * 在svg中append 加入g 对象
     * selectAll() 选择所有g元素,此处重点不在选择,而是建立一个选择集对象
     * 用data() 方法将具备系列信息的数据源source绑定到系列集合中
     * 用join() 基于数据源批量创建g元素,一个g代表一个系列,之后每个g元素里都会放入三个不同类目的柱状体
     * 用transform 属性中的translate设置系列的x像素位——列宽乘以系列索引
     * 基于系列索引,从调色盘中取色,然后将其作为一个系列中所有图形的填充色
     **/
    const seriesObjs=svg.append('g')
        .selectAll('g')
        .data(source2)// 使用优化后的数据源
        .join('g')
        .attr('transform',(seriesData,seriesInd)=>{
          const seriesX=colW*seriesInd
          return `translate(${seriesX},0)`
        })
        .attr('fill',(seriesData,seriesInd)=>color[seriesInd%colorLen])
        
    /* 在系列集合中建立柱状体集合rects
     * 用系列集合seriesObjs 的selectAll()方法选择所有的rect元素,用于建立选择集对象
     * 用data()方法将之前绑定在每个系列集合中的数据绑定到柱状体集合中
     * 用join()基于每个系列的数据数据批量创建rect元素
     * 用classed() 方法为其添加item属性
     **/
    const rects=seriesObjs.selectAll('rect')
        .data(seriesData=>seriesData)
        .join('rect')
        .classed('item',true)
    
    /* 设置柱状体的x像素位
     * 从回调参数中获取柱状体在当前系列中的索引rectInd,系列索引 seriesInd
     * 基于柱状体在当前系列中的索引rectInd,用x轴比例尺xScale()获取柱状体在当前系列中的x像素位
     * 设置柱状体像素宽width为列宽colW
     * 设置柱状体的y像素位
     * 从回调参数中解构柱状体数据rectData
     * 基于柱状体数据rectData,用y轴比例尺yScale()获取柱状体的y像素位
     * 设置柱状体的像素像素高
     * 从回调参数中解构柱状体数据rectData
     * 让y轴上刻度为0的像素位,减去刻度为柱状图实际数据的像素位,即为柱状图的像素高
     **/
    rects
      .attr("x", ({ rectData, rectInd }) => xScale(rectInd))
      .attr("width", colW)
      .attr("y", ({ rectData }) => yScale(rectData))
      .attr("height", ({ rectData }) => yScale(0) - yScale(rectData));
    
  • 创建动画

    /* 第一个关键帧-柱状体的初始状态
     * y y轴中刻度0的像素位
     * height 0
     * */
    rects.attr('y',()=>yScale(0))
        .attr('height',0)
    
    /*第二个关键字-柱状体的完整状态
     * transition() 建立补间动画
     * duration() 动画时间
     * delay 动画延迟
     * ease 补间动画的插值算法,如d3.easeBounce,参考https://github.com/d3/d3-ease
     * */
    rects.transition()
        .duration(1000)
        .delay(({rectInd,seriesInd})=>(seriesInd+rectInd)*300)
        .ease(d3.easeBounce)
        .attr('y',({rectData})=>yScale(rectData))
        .attr('height',({rectData})=>yScale(0)-yScale(rectData))
    

6. 鼠标事件(鼠标经过提示信息)

  • 创建提示对象

    const tip = main.append("div").attr("id", "tip");
    
  • 为柱状图加鼠标事件

    /* 鼠标划入事件 mouseover
     * 从事件中的第一个回调参数解析目标对象和鼠标位置
     * 鼠标位置 clientX,clientY
     * 从事件中的第二个回调参数解析当前柱状体的数据
     * 柱状体数据 rectData
     * 柱状体名称 rectName
     * 系列名 seriesName
     * 基于鼠标位置和柱状体信息显示提示
     * style()设置display 为block
     * style()设置left、top位置
     * html()设置元素的html 内容
     **/
    rects.on("mouseover",({ clientX, clientY }, { rectData, rectName, seriesName }) => {
        tip.style("display", "block")
          .style("left", `${clientX}px`)
          .style("top", `${clientY}px`).html(`
              <div>${rectName}</div>
              <div>${seriesName}${rectData}</div>
          `);
      }
    );
    
    /* 鼠标移动事件 mousemove
     * 设置提示left、top位置
     **/
    rects.on("mousemove", ({ clientX, clientY }) => {
      tip.style("left", `${clientX}px`).style("top", `${clientY}px`);
    });
    
    /* 鼠标划出事件 mouseout
     * 隐藏提示
     **/
    rects.on("mouseout", () => {
      tip.style("display", "none");
    });
    

7. 缓动跟随

  • 创建类

    /* EaseObj 缓动对象
     * target 缓动目标
     * fm 当前动画帧
     * pos 绘图位置
     * endPos 目标位置
     * ratio 移动比例,如0.1
     * _play 是否开始缓动跟随
     **/
    class EaseObj{
      /*构造函数*/
      constructor(target){
        this.target=target
        this.fm=0
        this.pos={x:0,y:0}
        this.endPos={x:0,y:0}
        this.ratio=0.1
        this._play=false
      }
      /*play 属性的取值器*/
      get play(){
        return play
      }
      /*play 属性的赋值器
       * 现在的值不等于当过去值时
       * 当现在的值为true时
       * 缓动跟随
       * 更新目标对象
       * 连续渲染
       * 当现在的值为false时
       * 删除动画帧,取消连续渲染
       **/
      set play(val){
        if(val!==this._play){
          if(val){
            this.render()
          }else{
            this.cancel()
          }
          this._play=val
        }
      }
      /*render 渲染方法
       * 按比值,让pos位置接近终点endPos
       * 更新目标对象target的样式
       * 连续渲染
       **/
      render(){
        const {pos,endPos,ratio,target}=this
        pos.x+=(endPos.x-pos.x)*ratio
        pos.y+=(endPos.y-pos.y)*ratio
        target.style('left',`${pos.x}px`)
          .style('top',`${pos.y}px`)
        this.fm=requestAnimationFrame(()=>{
          this.render()
        })
      }
    
      /*cancel 删除动画帧,取消连续渲染*/
      cancel(){
        cancelAnimationFrame(this.fm)
      }
    }
    /*easeTip 实例化缓动对象*/
    const easeTip=new EaseObj(tip)
    
  • 修改鼠标事件

    /* 鼠标划入事件 mouseover
     * 从事件中的第一个回调参数解析目标对象和鼠标位置
     * 鼠标位置 clientX,clientY
     * 从事件中的第二个回调参数解析当前柱状体的数据
     * 柱状体数据 rectData
     * 柱状体名称 rectName
     * 系列名 seriesName
     * 基于鼠标位置和柱状体信息显示提示
     * style()设置display 为block
     * html()设置元素的html 内容
     * 设置缓动跟随
     **/
    rects.on('mouseover',({clientX,clientY},{seriesName,rectName,rectData})=>{
        tip.style('display','block')
            .html(`
                <div>${seriesName}</div>
                <div>${rectName}${rectData}</div>
            `)
        easeTip.endPos={x:clientX,y:clientY}
        easeTip.play=true
    })
    
    /* 鼠标移动事件 mousemove
     * 设置提示left、top位置
     **/
    rects.on("mousemove", ({ clientX, clientY }) => {
      easeTip.endPos={x:clientX,y:clientY}
    });
    
    /* 鼠标划出事件 mouseout
     * 隐藏提示
     **/
    rects.on("mouseout", () => {
      tip.style("display", "none");
      easeTip.play=false
    });