ECharts实战:对记账记录进行数据可视化

唠一唠

各位掘友这个时候应该都开始上班了吧,哎,再过几天就开学了,上个星期找了个远程兼职,等过几天回到学校之后,我也要开始成为卑微打工人咯,┭┮﹏┭┮。好了,开始今天的话题哦,还记得在之前的ECharts系列文章中,曾分别介绍了ECharts入门使用,以及一些常用的配置,记得当初自学ECharts的目的就是为了完成记账本项目中对记账记录进行数据可视化,本着初心不能忘,这不,今天这篇文章就来简单介绍一下如何实现的。

先来看看整体实现效果吧

在之前的文章中 (ECharts快速上手)中曾介绍了ECharts在Vue中使用总结起来无非就是四步,安装,引入,创建容器,准备配置项和配置图表。前两步我们已经完成,这里就不贴代码出来了,我们要实现数据可视化最重要的就是后面两步,当然第四步是最关键的。

创建容器

xml 复制代码
<div class="diagram">
    <div class="amount" v-if="dataType === 'cost'">
      <div class="sum">总支出:{{ Sum }}</div>
      <div class="average">平均值:{{ Average }}</div>
    </div>
    <div class="amount" v-else>
      <div class="sum">总收入:{{ Sum }}</div>
      <div class="average">平均值:{{ Average }}</div>
    </div>
    <!-- 图表在这里展示 -->
    <div id="myEcharts" style="width: 100%; height: 10rem;"></div>
</div>

上述amount类是为了展示图表标题用的,因为我个人感觉ECharts自带的标题在这里用起来不是很适合,当时ECharts自带的标题也支持样式配置,在之前的文章中曾讲过。其中主要的就是<div id="myEcharts" style="width: 100%; height: 10rem;"></div> 这行代码用来展示图表的。比较简单,就不多介绍了。

准备配置项和配置图表

  • 这些操作我通常习惯写在一个函数内,因为这样写很方便,由于图表的数据是需要改变的,我们只需要在初始化图表的地方调用函数即可,如下
js 复制代码
// 初始化图表函数
function initChart() {
  // 使用myEcharts库初始化图表,传入DOM元素(id为myEcharts的div)并设置主题为"purple-passion"
  let chart = myEcharts.init(document.getElementById("myEcharts") as HTMLDivElement, "purple-passion");

  // 设置图表配置选项
  chart.setOption({
    // X轴配置
    xAxis: {
      type: 'category', // 类型为类别轴
      data: Dates.value, // 数据标签来源
      // 启用滑动条
      scrollbar: {
        show: true
      },
      axisLabel: {
        fontSize: 10 // 调整字体大小以适应更多标签
      }
    },
    
    // Y轴配置
    yAxis: {
      type: 'value', // 类型为数值轴
      axisTick: {
        show: false // 隐藏刻度线
      },
      axisLabel: {
        formatter: function (value:number) {
          // 自定义Y轴刻度数的显示格式
          if (value >= 10000) {
            return (value / 10000).toFixed(1) + ' w'; // 当值大于等于10000时,转换为以'w'(万)为单位的数字
          } else {
            return value; // 其他情况直接返回原始值
          }
        }
      }
    },

    // 数据系列配置
    series: [
      {
        // 数据点配置
        data: Amounts.value.map((value) => ({
          value: value, // 数据点的值
          itemStyle: {
            color: value !== 0 ? '#ff9900' : '#dddddd', // 根据数据点的值是否为0来决定颜色
            borderColor: '#000000', // 数据点边框颜色
            borderWidth: 1 // 数据点边框宽度
          }
        })),
        
        type: 'line', // 图表类型为折线图
        lineStyle: {
          color: ({ seriesIndex, dataIndex }) => {
            // 线条颜色根据数据点索引动态决定
            return dataIndex === Amounts.value.indexOf(Math.max(...Amounts.value)) ? '#000000' : '#eeeeee';
          },
          width: 2 // 线条宽度
        },
        
        // 标记线配置
        markLine: {
          data: [
            { // 最大值标记线
              type: 'max',
              name: '最大值',
              lineStyle: {
                type: 'solid', // 线条样式为实线
                color: '#ff9900', // 线条颜色为橙色
                width: 0.5 // 线条宽度
              },
              label: {
                formatter: function (params:any) {
                  // 处理标签显示格式
                  const value = params.value;
                  if (value >= 10000) {
                    return (value / 10000).toFixed(1) + ' w'; // 数值转换为万,并保留一位小数
                  } else {
                    return value.toFixed(2); // 保留两位小数
                  }
                }
              },
              symbol: 'none', // 不显示两端符号
              symbolSize: [0, 0], // 设置为0确保不显示符号
            },
            { // 平均值标记线
              type: 'average',
              name: '平均值',
              lineStyle: {
                type: 'solid',
                color: '#ff9900',
                width: 0.5
              },
              label: {
                formatter: function (params:any) {
                  const value = params.value;
                  if (value >= 1000) {
                    return (value / 1000).toFixed(1) + ' k'; // 数值转换为千,并保留一位小数
                  } else {
                    return value.toFixed(2); // 保留两位小数
                  }
                }
              },
              symbol: 'none',
              symbolSize: [0, 0],
            },
          ],
          silent: true // 不响应鼠标事件
        }
      }
    ],
  });

  // 当窗口大小发生变化时,自动调整图表大小
  window.onresize = function () {
    chart.resize();
  };
}

上述代码的很多配置项属性都在(ECharts学习:柱状图常见效果和通用配置)中曾介绍过,虽然这里是折线图,但是大致配置都是一样的,我这里也就不多赘述了,代码上附有注释,帮助不太了解的掘友们理解。(其中ts处理的不准确还请大佬们指出哦,我目前还是ts小白,不太会使用。) 我这里就介绍几个之前没介绍过的。

数据是这样的,我想实现记账金额为0时,数据圆点显示成灰色,但记账金额不为0时,为橙色。这样可以给用户带来更好的体验感。也就是上述代码中的数据点配置:

javascript 复制代码
// 数据点配置
data: Amounts.value.map((value) => ({
  value: value, // 数据点的值
  itemStyle: {
    color: value !== 0 ? '#ff9900' : '#dddddd', // 根据数据点的值是否为0来决定颜色
    borderColor: '#000000', // 数据点边框颜色
    borderWidth: 1 // 数据点边框宽度
  }
}))

由于用户当月记账金额过大时可能会造成金额数值超出屏幕外,于是我对数据进行了单位处理,自定义formatter函数,之前文章中也讲过,ECharts的自定义在很多属性配置都很好用。我下面只展示出y轴数据单位自定义代码,最大值,最小值就不列出来了,上面也有。

javascript 复制代码
axisLabel: {
    formatter: function (value:number) {
      // 自定义Y轴刻度数的显示格式
      if (value >= 10000) {
        return (value / 10000).toFixed(1) + ' w'; // 当值大于等于10000时,转换为以'w'(万)为单位的数字
      } else {
        return value; // 其他情况直接返回原始值
      }
    }
}

效果如下:

前端ECharts代码主要的大致就是上面这些了。下面就是围绕图表数据以及其他处理了,我这里也列出来一些大致代码吧。

后端代码

  • 从数据库中筛选出周账单记录,我们这里只写周数据获取,年,月本质都是一样的。
typescript 复制代码
const selectAccountWeekInfo = async (id, year, week,type) => {
  // 使用moment.js库来计算指定年份和周数的起止日期,并将其格式化为字符串。
  const startOfWeek = moment.utc(`${year}-W${week}`, 'YYYY-[W]WW').startOf('week').format('YYYY-MM-DD');
  const endOfWeek = moment.utc(`${year}-W${week}`, 'YYYY-[W]WW').endOf('week').format('YYYY-MM-DD');

  let sql = `
    SELECT * 
    FROM transaction 
    WHERE user_id = "${id}" 
      AND transaction_date BETWEEN "${startOfWeek}" AND "${endOfWeek}" AND transaction_type = "${type}"
  `;

  try {
    let result = await allService.query(sql);
    return result;  
  } catch (error) {
    throw error; // 抛出异常以便在调用方进行处理
  }
}

这里我就要推荐一个特别好用的JavaScript库了 - Moment.js。有了它,处理起时间来可谓是得心应手。

作用 => 用于处理和操作日期与时间。它提供了一套丰富的 API 来解析、验证、操作和显示日期和时间。开发者可以方便地进行日期格式化、比较、加减时间单位(如天、小时等)、时区转换以及本地化等操作。

  • 接口函数
kotlin 复制代码
// 定义查询周记账接口
router.post('/accountWeekInfo', async (ctx) => {
  const { user_id, year, week, dataType } = ctx.request.body;
  if (!user_id || !year || !week || !dataType) {
    ctx.body = {
      code: '8001',
      msg: '参数不全'
    }
    return
  } else {
    try {
      const result = await selectAccountWeekInfo(user_id, year, week, dataType);
      ctx.body = {
        code: '8000',
        msg: '查询成功',
        data: result
      }
    } catch (err) {
      ctx.body = {
        code: '8004',
        msg: '服务器异常',
        data:err
      }
    }
  }
});

前端拿数据

这里我引入了Lodash库中的debounce进行防抖处理,防止有些小可爱们疯狂点击获取后端数据,毕竟服务器按量计费的,挺贵的。

先安装Lodash后在需要的地方引入

javascript 复制代码
import { debounce } from 'lodash';

防抖函数:

scss 复制代码
// 定义防抖版的 getWeekInfo 函数
const debouncedGetWeekInfo = debounce(async () => {
  await getWeekInfo();
  // 确保数据加载完毕后再初始化图表
  if (Amounts.value.length > 0) {
    initChart();
  }
  scrollToActiveWeek();
}, 350); // 设置防抖延时为350毫秒

请求数据函数:

ini 复制代码
const data = {
  user_id: userInfo.id,
  year: 2024,
  week: 'W02',
  dataType: props.dataType
}

// 获取周数据
const getWeekInfo = async () => {
  const res = await axios.post('/accountWeekInfo', data);
  // console.log(res);

  // 需要确保正确处理res.data
  if (res.code === '8000') {
    const startOfWeek = moment.utc(`${data.year}-W${data.week}`, 'YYYY-[W]WW').startOf('week').format('YYYY-MM-DD');
    const endOfWeek = moment.utc(`${data.year}-W${data.week}`, 'YYYY-[W]WW').endOf('week').format('YYYY-MM-DD');
    // console.log(startOfWeek, endOfWeek);

    const startOfWeekMoment = moment.utc(startOfWeek, 'YYYY-MM-DD');

    // 获取指定周内所有交易记录的日期和金额
    weekData = res.data.filter(item => {
      const date = moment.utc(item.transaction_date);
      // 参数null表示不指定任何单位,也就是说默认按照日期(day)进行比较。
      //'[]' 表示闭区间,即包括边界值。这意味着只有那些交易日期恰好在开始和结束日期之间的记录才会被筛选出来并保留到weekData数组中。
      return date.isBetween(startOfWeek, endOfWeek, null, '[]');
    });

    // 初始化dates和amounts数组并填充默认值0
    let dates: string[] = Array(7).fill(null).map((_, i) => startOfWeekMoment.clone().add(i, 'days').format('MM-DD'));
    let amounts: number[] = Array(7).fill(0);

    // 遍历weekData,累加金额到对应的日期索引上
    for (const item of weekData) {
      // 计算当前交易记录发生日期与指定周开始日期之间的天数差值,结果作为索引值赋给index变量。
      const index = moment.utc(item.transaction_date).diff(startOfWeek, 'days');
      amounts[index] += parseFloat(item.amount);
    }
    Dates.value = dates;
    Amounts.value = amounts;
    for (let item of Amounts.value) {
      // 求和计算出总支出
      Sum.value += item
    }
    // 计算平均值
    Average.value = (Number)((Amounts.value.length > 0 ? (Sum.value / Amounts.value.length) : 0).toFixed(2));
    // console.log(Dates, Amounts);
  } else {
    console.error('获取周信息失败:', res.statusText);
  }
};

写了一个工具函数,辅助生成指定某年某周到当前时间的一个数组,数组只包含年份和周数,这里还是用到了万能的Moment库,是真好用啊。

scss 复制代码
// 计算某年某周到现在时间的数据
export function generateWeeksData(startYear, startWeek) {
  // 将起始日期设置为指定年份和周数
  const startDate = moment.utc(`${startYear}-W${startWeek}`, 'YYYY-W');
  
  // 获取当前日期
  const currentDate = moment();

  // 初始化结果数组
  const weeksData = [];

  while (startDate.isBefore(currentDate)) {
    // 创建本周数据的对象
    const weekData = {
      year: startDate.year(),
      week: startDate.format('W'),
      startDate: startDate.clone().startOf('week').format('YYYY-MM-DD'),
      endDate: startDate.clone().endOf('week').format('YYYY-MM-DD')
    };

    // 将当前周的数据添加到结果数组
    weeksData.push(weekData);

    // 将起始日期增加一周
    startDate.add(1, 'weeks');
  }

  // 如果当前日期所在周还未添加,则添加它
  if (startDate.isSame(currentDate, 'week')) {
    weeksData.push({
      year: currentDate.year(),
      week: currentDate.format('W'),
      startDate: currentDate.clone().startOf('week').format('YYYY-MM-DD'),
      endDate: currentDate.clone().endOf('week').format('YYYY-MM-DD')
    });
  }

  return weeksData;
}

上述生成的数组我把它渲染在了ECharts图表上面,也就是这一部分。

为了保证点击哪周,周数滚动到对应的周数,写了一个列表滚动函数

js 复制代码
// 定义一个名为scrollToActiveWeek的函数,其功能是滚动到当前活动周的位置
function scrollToActiveWeek() {
  // 检查dateContainer是否有值(可能是一个引用DOM元素的React ref或者其他库的ref对象)以及activeWeek是否存在有效的活动周信息
  if (dateContainer.value && activeWeek.value) {
    // 使用querySelector在dateContainer所引用的DOM元素下查找class为'date-item'且同时具有'active-week'类的元素
    const targetItem = dateContainer.value.querySelector('.date-item.active-week');
    
    // 如果找到了目标元素(即当前活动周对应的DOM元素)
    if (targetItem) {
      // 调用scrollIntoView方法使该元素平滑滚动至视口可见位置
      // 参数behavior: 'smooth'表示以动画效果滚动
      // 参数block: 'nearest'表示尽可能滚动至离视口最近的边缘
      targetItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }
}

讲到这里,其实大致的实现代码都已经讲完了,一看时间已经晚上11点了,也该睡觉咯。明天起来又是打工人的一天。

假如您也和我一样,在准备春招。欢迎加微信shunwuyu,这里有几十位一心去大厂的友友可以相互鼓励,分享信息,模拟面试,共读源码,齐刷算法,手撕面经。来吧,友友们!

相关推荐
2401_8576226626 分钟前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
正小安30 分钟前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
2402_8575893630 分钟前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没2 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch2 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光2 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   2 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   2 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web2 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常2 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式