(原创)mat-table-with-d3的示例以及code历程

代码repo地址:https://github.com/kxc1573/mat-table-with-d3-sample
foreword Recently, I got my first formal frontend task to implement a tree-table by mat-table and to draw chart of detail row by d3.
The technologies involved included Angular, Material table, animation and d3, they were all new to me.
I quickly picked up these skills with examples had been implemented by my colleagues.
But I still had doubts about some details, so I implemented a new demo from zero.
The code is mat-table-with-d3-sample, and I write this document to record the implementation process.
1. 需求设定 假设我们需要实现一个这样的表:

  • 一共有3层结构
  • 第1层和第2层每行都展示一些数值,点击第1层可以控制第2层数据的展开和伸缩
  • 第3层是一个图表,点击第2层来进行相应的展开和伸缩
    如下面图1所示,当然这也是这个demo的最终效果了。
    (原创)mat-table-with-d3的示例以及code历程
    文章图片
    图1
2. 初始化(branch step0) 1)项目初始化 Angular的开发可以参考教程(Tutorial-ES、Tutorial-CN)。
  • 首先使用Angular-Cli创建项目, 并把materialanimations 的module安装、引用
  • 其次创建table componentpost service
  • 再就是定义好用来示例的数据,具体见/assets/data.json
2)读取数据 在post.service中实现数据读取操作,代码如下:
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'@Injectable({ providedIn: 'root' }) export class PostService {constructor(private http: HttpClient) { }readData() { return this.http.get('assets/data.json') } }

Key Point:
  • this.http.get(path) 是异步操作,不论path是本地的data file path还是data api url,其实它的内部是封装了Promise
  • 所以它的调用需要使用subscribe,如下为table.component.ts的调用代码
post.readData() .subscribe( res => { console.log(res); })

3)页面初始化 使用mat-cardmat-grid-list进行页面布局,具体参考mat-card文档和mat-grid-list文档。
mat-grid-list的使用如下:

Key Point:
  • mat-grid-list通过属性cols来设置列数,通过属性rowHeight来设置行高,至于行数则是根据mat-grid-tile的列数和数量自适应调整。
  • mat-grid-tile通过[colspan][rowspan]两个特性来定义列数和行数。
此处效果如图2

(原创)mat-table-with-d3的示例以及code历程
文章图片
图2 3. mat-table简单实现(branch step1) 1) 表格实现 上一步中已经完成了数据的提供和页面的布局,剩下的就是实现表格展示数据了。
一般的table是逐行实现的,下面是w3school的一个示例:
Month Savings
January $100

mat-table的实现思路则不一样:它是逐列来定义的,包括表头和单元格数据;然后再按行提供数据来完成表格的渲染。
下面是demo的表格代码,具体参考mat-table文档
Name {{element.name}} {{column}} {{element[column]}}

Key Point:
  • 开头申明这不是常规的table
  • 通过dataSource来提供数据, 传入数组/列表是最简单的数据格式.
  • ng-container matColumnDef 定义列的模板, th mat-header-cell 定义了这一列中的表头, td mat-cell定义了这一列的单元格内容.
  • tr 定义了行的模板, tr mat-header-row提供表头这一行要展示的数据,tr mat-row提供数据行要展示的内容.
  • 2)数据处理 为了能在表格中展示,当然需要将读取到的数据进行相应的修改,如何修改就不细说了。上文提到过数据读取是异步操作,这就意味着在数据返回之前页面就已经完成渲染了,拿到数据之后需要刷新页面才能将数据显示出来。而通过dataSource来提供数据是不会主动检查数据的更新的,原文是这么说的:
    If you are providing a data array directly to the table, don't forget to call renderRows() on the table, since it will not automatically check the array for changes.

    但是具体如何调用,却没有说明,也是困扰了我许久,幸得同事ZhenYi指点迷津,代码如下:
    import { ViewChild } from '@angular/core'; ...@ViewChild(MatTable) table: MatTable; ...this.table.renderRows(); ...

    此处的效果图如下

    (原创)mat-table-with-d3的示例以及code历程
    文章图片
    图3 4.嵌入动画事件显示子行数据(branch step2) 经过上一步,基本的表格已经实现了,但只有一层数据,这一步要实现的效果就是点击后展开第二层数据。
    由于之前的demo中有用到angular/animations来实现点击展开事件,所以我也依葫芦画瓢地用了一番,然后由于当时对mat-table的理解不够,第二层数据是通过原生的table嵌入在动画事件中来实现的。
    angular/animations也是一个巨坑,animation文档我也没看,不过读了这篇angular-animations 动画 BrowserAnimationsModule 详解,这里就不详细说了。
    【(原创)mat-table-with-d3的示例以及code历程】这一部分的代码改动主要就是三部分:
    1)在table.component.ts中定义触发器
    import { animate, state, style, transition, trigger } from '@angular/animations'; ...animations: [ trigger('detailExpand', [ state('collapsed', style({height: '0px', minHeight: '0', visibility: 'hidden'})), state('expanded', style({height: '*'})), transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')), ]), ]

    2)在table.componenet.html添加子行数据的行并绑定事件 这里首先将两层数据定义为parentRowchildRow,并添加了一个扩展标志位属性expand
    然后加了when: isParentRowwhen: isChildRow作为过滤条件
    其次增加了是否expandedElement的判断
    还添加了点击事件Click,其中包含了当前行expand取反和数据更新updateChildRow操作
    最后就是添加了childRow专属的行,所提供的数据同parentRow是不一样的,因为内部的实现也是不一致的。

    我认为isParentRowisChildRowupdateChildRow代码调用实现包含了函数式编程的思想。
    3)用原生table在展开行中嵌入实现子行数据 这里关注点应该就是虽然同样为ng-container,但expandedDetail的格式是自成一格的,不只是定义一列,而是定义所有通过动画展开的行的全部格式,这与上面代码中columns: ['expandedDetail']是对应的。

    4)Bug:行间存有黑线 如图4,如果parentRow未展开,那么行间就存在黑线,这是因为虽然childRow通过animation定义的高度为0px,但在html也是占了一行的,且行高1px,因而隐藏了几条childRow就有相应高度的黑线。

    (原创)mat-table-with-d3的示例以及code历程
    文章图片
    图4
    5.动态更新数据显示子行数据(branch step3) 对于行间黑线的问题,多次尝试从CSS角度解决均失败,最后依然是在阅读同事的代码时开窍——应该从数据的动态更新入手,具体参考代码中的updateChildExpand方法实现即可。
    这里列出代码中我感觉好用的两个js编程技巧:
    • splice...的妙用,怕误导就不细说,自行实践理解更好。
      this.dataSource.splice(i + 1, 0, ...rows)
    • 通过字符串处理实现深拷贝的一种方法
      JSON.parse(JSON.stringify(this.parentData))
    6. 使用D3动态画图(branch step4) 将step2step3中不同的展开childRow方法结合起来,就得到了我们实现tree-table-with-chart的思路:
    点击parentRow更新dataSource展示childRow,点击childRow通过animation渲染D3动态绘制的detail chart
    为了保证每次渲染都是正确的,dataSource都会由updateChildExpand(i, element)updateDetailGraph(element)两个方法进行更新,内部逻辑不复杂也不简单,此处不细说。
    1)D3画图 D3的全称是Data-Driven Documents,用来画矢量图的,是一个贼拉牛逼的前端神器,具体的看D3官网吧,这里大致说一下我理解的画图实现。
    首先当然是安装d3库,简单的npm install d3即可。
    基本实现过程为
    • 根据selectorId找到对应的selector,添加svg,然后在svg上一顿操作
    • 具体操作对于这个demo里绘制的图表而言,就是
      先定义x轴、定义y轴
      再格式化处理数据
      然后根据数据画线,再根据线和数据渲染区域背景
    具体代码查看createChart方法的实现吧,看懂了就可以自行裁剪,看不懂去啃官方文档则更好了。
    2)为每个childRow匹配一个detail chart 上面画图过程中说到,第一步就是要根据selectorId来新建一个svg,每一个childRow都要有自己的detail chart,那么就需要相应独立的selectorId。这里采用的方法是在数据处理时为每行数据加一个position属性作为行标,这样在html中通过如下代码即实现了自适应生成slectorId的功能了。

    Key Point:
    • 1)bug: 想象中的gia-chart-wrapper并没有出现
      在代码实现过程却并不如设想中的顺利,出现了图5中的问题,点击childRow时报错:TypeError: Cannot read property 'clientWidth' of null,对应的代码是var widther = d3.select(selectorId).node().clientWidth;
      按说拿到数据重新渲染页面后每一个childRow对应的gia-chart-wapper div都应该已经有了,这里报错有些头大,因为这是第二次了,第一次确实是页面渲染前画图导致的,修改画图操作在页面渲染后就解决了。
      (原创)mat-table-with-d3的示例以及code历程
      文章图片
      图5
      最终经过一番代码比对后发现是需要对mat-table添加一个multiTemplateDataRows属性,代码如下,这个的具体作用我还没去查过,有待学习。
    {{element.name}} {{element[column]}}
    ...

    • 2)代码...isChildRow的判断不可少,否则点击parentRow的时候也会画一个图表。
    • 3)对进行画图的数据要进行deep copy处理,否则第二次使用时会报错,这说明内部数据传递是基于引用或者内存地址的。
    7.添加datepickerbranch step5) 到了step4基本就实现预期目标了,不过当前设定的数据全部是2018年的,如果想显示更多年份数据的话,我们可以通过datepicker来选择时间,可以实现为只选择year的,同样Angular material有自己的matDatepicker,具体用法还是看matDatepicker文档。
    这里要强调的依然是一个bug,如图6所示,点击datepicker后的弹窗位置是不对的。

    (原创)mat-table-with-d3的示例以及code历程
    文章图片
    图6
    最终在另一个同事 HongYi的调研下找到了该 issue的讨论和解答: angular-material-datepicker-popup-position
    8.改进方向?
    • 利用现成的treetable组件进行扩展?
      pick up过程中,看到有一个名为ng-material-treetable的开源组件,github地址:https://www.npmjs.com/package/ng-material-treetable,只是阅后感觉对数据格式要求有些严格,就放弃了。
      不知道是不是自己理解不够,如果理解深一些是否可以轻松将数据处理成所要求的样子,这样就可以简单套用已有的组件了。
    • 在文档中有这么一句话
    able's default role is grid, and it can be changed to treegrid through role attribute. 表格的默认角色是 grid,可以通过 role 属性来把它改为 treegrid

    一个gridtreegrid让我有了些想法,不过怎么都没找到相应的解释,只能作罢。
    9.最后 放上昨天在朋友圈看到的一张图“假如让写编程书的那群人来出数学书”,确实好多编程教程的基本操作就是先写Hello World,然后接着就是实战了。

    (原创)mat-table-with-d3的示例以及code历程
    文章图片

      推荐阅读