企鹅辅导学习报告-数据图表可视化应用的实践总结

2,061 阅读11分钟

本文作者:IMWeb IMWeb团队 原文出处:IMWeb社区 未经同意,禁止转载

需求简介

腾讯企鹅辅导在学生上课结束后推送“学习报告”,是课程所提供的一项重要服务。家长在“学习报告”中能查看孩子上课时间及互动情况,答题及掌握知识点,作业考试分数,班级排名等诸多数据,继而让学生家长及时掌握孩子的学习情况。

此次改版升级是针对旧学习报告的的数据和展示进行的一次优化:增加考试模块、知识点采用更简单的表达形式、在视觉交互上更加年轻活泼、并运用了更多数据图表可视化在其数据展示中。

考试模块-数据图表可视化的应用

1.数据可视化组件库的选择及应用

在考试模块中,需要展示学生成绩变化趋势的曲线图,而这需要用到第三方的可视化组件库,继而快速回忆起比较知名的几款:国外的HighChart,百度家的Echart,阿里的AntV(移动端F2)等。当然也希望腾讯有一天也能有同样知名好用的的可视化组件库。

在选择可视化组件库时,我们主要考虑以下几点:1.能够良好支持移动端且轻量。2.支持React。3.具备足够自由的可定制化配置样式的能力。

其中第三点尤其重要,因为这里要准确还原交互视觉(不得不说我们交互和视觉给的要求很高)。根据经验,纵使强大的可视化组件库配置非常繁多,但往往可配置的内容太多,根本找不到用什么配置项达到目的,而且一些配置相互影响,变化很多。

最终我们发现并使用了Recharts recharts.org 它是一个使用React和D3构建的Redefined图表库。具备以下特性:

  1. 支持React组件,声明式的标签,写图表和写 HTML 一样简单。
  2. 原生SVG支持,依赖于轻量级的 D3 子模块构建 SVG 元素。
  3. 接口式的 API,解决各种个性化的需求。

以下是部分需求代码,展示了其用法和特性:

<ResponsiveContainer width="100%" height={200}>
        <LineChart data={data}>
          <CartesianGrid horizontal={false} strokeDasharray="2 3" />
          <Line type={lineStyle} dataKey="avgScore" stroke="#CCCCCC" fill="#CCCCCC"
            label={
              <CustomizedLabel direction="down" data={data}
                relateKey="actualScore"/>
            }
            isAnimationActive={false}  />
          <Line
            type={lineStyle} dataKey="actualScore" stroke="#08CB6A" fill="#08CB6A"
            label={<CustomizedLabel data={data} relateKey="avgScore" />}
            isAnimationActive={false} />
          <Legend
            align="left" verticalAlign="top" iconSize={4}
            iconType="rect" height={36}
            formatter={(value) => {
              return { actualScore: '我的成绩', avgScore: '班级平均分' }[value];
            }}
            wrapperStyle={{
              left: -13,
              fontSize: 12,
            }} />
          <XAxis
            dataKey="name" padding={{ left: padding, right: padding }}  axisLine={false} tickLine={false} />
          <YAxis domain={[-8, 108]} hide />
        </LineChart>
      </ResponsiveContainer>

除了样式配置项外,还提供了诸如“strokeDasharray”贴近原生SVG的配置项。对于熟悉SVG的同学就能能很准确写图形样式了。

2. 如何画好一跟曲线[贝塞尔曲线]

说道贝塞尔曲线,前端的同学很容易想到的是CSS transition中的cubic-bezier,一般是起始点和两个控制点 来生成两点间的一条曲线,也就是常用三阶贝塞尔曲线。 关于贝塞尔曲线就不再赘述了,其原理和SVG中Path中贝塞尔曲线的使用,可查阅下面两篇文章。 贝塞尔曲线原理 SVG Path 曲线

OK,根据需求,我们考试成绩已经确定两个点了,那么这根曲线到底具被怎样的“性格”,弯一点还是平滑一点?但是这需要和视觉的同学反复调整得出一个让她满意的“参数”。当然如果要做到完全满意,可能还要针对不同情况计算不同的参数。

下面代码为:通过D3 shape(可视化的图形基元),除了终点,两个控制点的x值通过参数设置。将其实例作为props 的type值传入Recharts中的<Line/>中,即可得到想要的曲线。

BezierLineShape.prototype = {
  lineStart() {
    this._x0 = this._x1 = this._y0 = this._y1 = this._t0 = NaN;
    this._point = 0;
    console.log('lineStart', this._line, this._point);
  },
  lineEnd() {
    console.log('lineEnd', this._line, this._point);
  },
  point(x, y) {
    console.log('point', x, y, this._line);
    (x = +x), (y = +y);
    if (x === this._x1 && y === this._y1) {
      return;
    } 
    switch (this._point) {
      case 0: {
        this._point = 1;
        this._x1 = x;
        this._y1 = y;
        this._context.moveTo(x, y);
        break;
      }
      case 1: {
        const mint = (x - this._x1) * 0.35;//此为控制点位置参数
        const x1 = this._x1 + mint;
        const y1 = this._y1;
        const x2 = x - mint;
        const y2 = y;
        this._x1 = x;
        this._y1 = y;
        console.log('bezierCurveTo', x1, y1, x2, y2, x, y);
        this._context.bezierCurveTo(x1, y1, x2, y2, x, y);
        break;
      }
      default:
        break;
    }
  },
};

最后效果图:

基于SVG做的客制化修改

Scalable Vector Graphics,意思为可缩放的矢量图形,它基于XML,是一种开放标准的矢量图形语言。recharts提供基于react组件的写法,去写可定制化svg图形。比如下面:用组件svg 来定制的Label的位置样式。

export default class CustomizedLabel extends React.PureComponent {
  static defaultProps = {
    direction: 'up', //
    stroke: '#777',
  };
  render() {
    const { x, y, stroke, value, direction, index, relateKey, data } = this.props;
     let settedDirect = direction;
    try {
      const relateValue = data[index][relateKey];
      if (value > relateValue) {
        settedDirect = 'up';
      } else if (value < relateValue) {
        settedDirect = 'down';
      }
    } catch (e) {
      // BJ_Report
    }
    const dy = settedDirect === 'up' ? -10 : 18;
    return (
      <text x={x} y={y} dy={dy} fill={stroke} fontSize={14} textAnchor="middle">
        {value}
      </text>
    );
  }
}

学习回顾-轮播柱状图结合实现

在学习回顾模块,用户可以左右滑动/点击柱状图,来切换不同课程信息展示。 很显然可以通过一个轮播组件来实现,但是这个模块还具备柱状图的展示。要选择一个兼具轮播和图表的组件,还要保证两者的功能和样式都可按需求定制。显然这并不容易,即便存在这样组件也要花上不少时间去寻找和筛选。

轮播柱状图的效果

这时就要权衡,到底是在一个轮播组件添加图表,还是改造图表组件为轮播。这里我选择基于轮播组件来写里面的柱状图的这个方案。原因是:这里的柱状图并不复杂,可以用dom+css样式来实现,并且正好实现样式定制化的需求。虽然图表组件(比如antV的F2)也提供类似滑动图表的功能,但是由于轮播不是它主要特性,诸如多item展示以及居中item选中等特性,改起来也不容易。

确定在轮播组件实现柱状图方案后,发现在实现仍有难点:第一个item的左边和最后一个item的右边仍有虚线轴。最开始想到通过添加空item来实现,但实际需求是在滑至第一个和最后一个是不允许继续滑动的,所以不能直接添加空item。

那怎么办呢?我们都知道轮播都是由视窗加container组成,通过计算定位container的位置来轮播。我不能在container里面直接添加DOM元素,否则会影响轮播组件的计算。但是我们可以在container前后添加伪元素,这样就不会妨碍轮播定位的计算了。

.time-chart-item:nth-of-type(1):after {
  content: '';
  width: pxToRem(116);
  height: pxToRem(144);
  display: block;
  position: absolute;
  background: url(./img/dashline.png) pxToRem(29) 0 no-repeat,
    url(./img/dashline.png) pxToRem(29+58) 0 no-repeat;
  top: pxToRem(28);
  left: pxToRem(-116);
}

这里有个平时很少用到的background的都多背景方法,由于左右两边最多有两根虚线展示,backgound设置两个虚线图片即可。

本次上课-如何用CSS mask实现状态条

当看到视觉稿 学生在线时间状态条的时候,一眼看去ok完全没有难度,不就一个简单的状态条吗,只不过不连续罢了。写个div,overflow-hidden,只需计算绿色块的width值和left值即可,撸起袖子就是干,十分钟搞定。

可是设计走查时,却逃不过视觉设计同学的火眼金睛:“这里的绿色应该是覆盖灰色边框上面的!” 接下来为了满足视觉同学的要求可花费了不少功夫。

因为在线状态条及其相关计算已经写好,最开始没有使用图表组件,因为我觉得这很简单,不需要杀鸡用牛刀,直接CSS可以实现。

写来改写代码,为了让绿色在线条覆盖背景border,我将绿色状态条覆盖在上层,但这又出现另外一个问题。 绿色条块左右两侧由于不被父级overflowhiden遮住,在值未达到极值时,无法做到圆角转直线的效果。

最后探索了不少css属性,其实我需要一个类似PS遮罩的效果。谷歌一下[css 遮罩],找到这个平时基本用不到的,但又意外强大的属性。

这就是css的 -webkit-mask。它所提供类似于遮罩到的能力,让原本CSS无法实现的shape通过图片也能做到。看了下面这个图就清楚了。

那么怎么应用-webkit-mask来实现不连续的状态条呢?其实只需要一个非透明的极小的png图,计算好宽度以及位置,再进行样式设置即可。 这里的-webkit-mask和所有background的多背景图使用是一样的,需要注意的是,这里的第一个参数值不要把它误会成是的x值,而是图片的x%与容器x%的重合点,这里很容易出错。 以下是计算的代码和生成的css样式:

const maskArray = [];
InClassState.map((item) => {
  const { start_inclass: start, end_inclass: end } = item;
  const left = (start - lessonBeginTime) / allTime;
  const width = (end - start) / allTime;
  const maskLeft = left / (1 - width);
  maskArray.push(`url(${maskimg}) no-repeat ${maskLeft.toFixed(2) * 100}% 0/
  ${width * 100}% 100%`);
});
style.WebkitMask = maskArray.join(',');
-webkit-mask:
url(//fudao.qq.com/block_0bb81cb….png) 0% 0px / 60% 100% no-repeat, 
url(//fudao.qq.com/block_0bb81cb….png) 76% 0px / 15% 100% no-repeat,
url(//fudao.qq.com/block_0bb81cb….png) 106% 0px / 15% 100% no-repeat;

只需要通过一个元素.

总结与思考

关于需求: 整个学习报告的前端开发排期不到两周时间,虽然交互逻辑不多,但是涉及到很多数据的展示,在数据图表可视化的实践上花费了不少时间,按要求还原视觉,并最终如期上线。 在开发这个需求时,需要意识到决不能有展示数据的bug,原因在于,当学习报告推送到家长的时候,如果发生因为展示bug让家长理解产生偏差,最终会让学习报告准确性的信任度大打折扣。

关于可视化组件选型: 在为数不多做和图表数据可视化的需求中,和以往ToB或内部使用不同,ToC的图表需要按视觉设计的要求高度还原。而一般的可视化组件往往遵循一定设计规范,样式通用,不方便改造。在组件库选型的时候,在按需加载(体积),移动端兼容性,以及定制化等多个维度中,重点放在了样式定制化,需要对于交互和样式的可定制化程度要心里有数才选用。

关于前端: 在这个需求的前后,我似乎对前端本身多了些看法。我(或者我们)不断将注意力集中在语言,框架,工程化等等,甚至能用简单单服务来构建一些工具系统。但是已经很少花时间深挖一些界面展示代码的技巧,了解一些CSS细节、SVG/Canvas图形语言,以及图形学相关知识。一切似乎变得越来越后而非前。虽然理所当然将精力来集中在快速实现需求与解决开发痛点上,但前端所掌控的交互界面每一点细节都是离用户最近的。希望各位能在编写样式和交互的代码中,能找到属于自己的价值和乐趣。

关注我们

IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。

我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂、企鹅辅导 及 ABCMouse 三大产品。

社区官网

imweb.io/

加入我们

lagou.com/jobs/446578…

扫码关注 IMWeb前端社区公众号,获取最新前端好文

微博、掘金、Github、知乎可搜索 IMWebIMWeb团队关注我们。