前端圖表可視化的應用實踐總結

  • 2019 年 12 月 4 日
  • 筆記

本文作者:IMWeb IMWeb團隊 原文出處:IMWeb社區 未經同意,禁止轉載

需求簡介

騰訊企鵝輔導在學生上課結束後推送「學習報告」,是課程所提供的一項重要服務。家長在「學習報告」中能查看孩子上課時間及互動情況,答題及掌握知識點,作業考試分數,班級排名等諸多數據,繼而讓學生家長及時掌握孩子的學習情況。

此次改版升級是針對舊學習報告的的數據和展示進行的一次優化:增加考試模組、知識點採用更簡單的表達形式、在視覺交互上更加年輕活潑、並運用了更多數據圖表可視化在其數據展示中。

考試模組-數據圖表可視化的應用

1.數據可視化組件庫的選擇及應用

在考試模組中,需要展示學生成績變化趨勢的曲線圖,而這需要用到第三方的可視化組件庫,繼而快速回憶起比較知名的幾款:國外的HighChart,百度家的Echart,阿里的AntV(移動端F2)等。當然也希望騰訊有一天也能有同樣知名好用的的可視化組件庫。

在選擇可視化組件庫時,我們主要考慮以下幾點:1.能夠良好支援移動端且輕量。2.支援React。3.具備足夠自由的可訂製化配置樣式的能力。

其中第三點尤其重要,因為這裡要準確還原交互視覺(不得不說我們交互和視覺給的要求很高)。根據經驗,縱使強大的可視化組件庫配置非常繁多,但往往可配置的內容太多,根本找不到用什麼配置項達到目的,而且一些配置相互影響,變化很多。

最終我們發現並使用了Recharts。它是一個使用React和D3構建的Redefined圖表庫。具備以下特性:

  • 支援React組件,聲明式的標籤,寫圖表和寫 HTML 一樣簡單。
  • 原生SVG支援,依賴於輕量級的 D3 子模組構建 SVG 元素。
  • 介面式的 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遮住,在值未達到極值時,無法做到圓角轉直線的效果。

傳統的辦法

在外面再套一層div,position設置為relative,設置圓角和overflow hidden,綠色塊相對於這一層div定位,如果溢出就會被裁剪。

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;

只需要通過一個元素。

Tags: