データビジュアライゼーションの教科書を実践してみよう(棒グラフ篇)

こんばんは、たーせるです。

先日、『データビジュアライゼーションの教科書』を買ってきました。

データビジュアライゼーションの教科書

データビジュアライゼーションの教科書

内容は、グラフの選び方や色の使い方といった基礎理論に重きが置かれており、実現手段に関してはほとんど言及がありません。

たとえばビジネスマンが Excel や BI ツールを使って掲載されているグラフをどう描けばよいか・エンジニアがどのようなライブラリを駆使してアプリケーションにグラフを組み込めばよいかといった応用的研究は、読者ひとりひとりに委ねられております。

今回のテーマ

せっかくなので、『データビジュアライゼーションの教科書』を踏まえ、以下に留意した棒グラフをAngular + D3 で描いてみましょう。

  • 5-1.色は強調したい要素に使う
  • 5-10. 無駄な枠線はつけない - 棒グラフ
  • 5-14. 太過ぎず細過ぎず - 棒グラフ
  • 5-16. 目盛り線は控えめに
作例

Stackblitz に投稿しました。読み込みにちょっと時間がかかるのは仕様です(Stackblitzの)。


作る

前回の記事の続きです。 Angular + D3 の連携方法は、こちらを前提にしています。

tercel-tech.hatenablog.com

開発環境(ライブラリのバージョン)

今回の記事を書くにあたり、@angular/cli 8.0.0 を用いました。 また、D3 5.9.2、uuid 3.3.2 をそれぞれ npm install しています。 package.json の該当箇所を以下に示します。

package.json(抜粋)

  "dependencies": {
    "@angular/animations": "~8.0.0",
    "@angular/common": "~8.0.0",
    "@angular/compiler": "~8.0.0",
    "@angular/core": "~8.0.0",
    "@angular/forms": "~8.0.0",
    "@angular/platform-browser": "~8.0.0",
    "@angular/platform-browser-dynamic": "~8.0.0",
    "@angular/router": "~8.0.0",
    "@types/d3": "^5.7.2",
    "@types/uuid": "^3.4.4",
    "d3": "^5.9.2",
    "rxjs": "~6.4.0",
    "tslib": "^1.9.0",
    "uuid": "^3.3.2",
    "zone.js": "~0.9.1"
  },
ソース

SVG はテキスト処理が苦手なので、せめて矩形の中の文字列をいい感じに折り返したりできるよう、<foreignObject> を使って <div> 要素(<xhtml:div>)を配置している点が今回のチャームポイントです。

ただし、<xhtml:div> には特殊文字:が含まれているため、Document.querySelector() に与える際に当該文字を //エスケープしています。 このあたりについては MDN の記事が詳しいです。

では実装を見ていきましょう。

app.component.html

<div>
  <svg [id]="svgId" [attr.width.px]="svgWidth" [attr.height.px]="svgHeight"></svg>
</div>

app.component.ts

import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import * as d3 from 'd3';
import * as uuid from 'uuid';

// データ型の定義
interface Datum {
  name: string;
  value: number;
  primary?: boolean;
}

type Data = Datum[];

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class AppComponent implements AfterViewInit {
  readonly svgWidth = 480;
  readonly svgHeight = 240;

  // svgのid属性にバインドするランダム値
  readonly svgId = `svg_${uuid.v4()}`;

  // SVG内部の余白を定義します
  private readonly svgMargin = {
    left: 25,
    right: 5,
    top: 5,
    bottom: 15
  };

  // svgのルート要素を取得します
  private get svgRootElement() {
    return d3.select(`#${this.svgId}`);
  }

  // 指定したセレクタの全要素を取得します
  private svgSelectAll<T extends SVGElement>(selector: string) {
    return this.svgRootElement.selectAll<T, Datum>(selector);
  }

  // svg配下のrect要素を取得します
  private get svgRectElements() {
    return this.svgSelectAll<SVGRectElement>('rect');
  }

  // svg配下のrect要素を取得します
  private get svgForeignObjectElements() {
    return this.svgSelectAll<SVGForeignObjectElement>('foreignObject');
  }

  /**
   * 初期化処理
   */
  ngAfterViewInit() {
    const data = [
      { name: '競合A社', value: 53 },
      { name: '当社',    value: 72, primary: true },
      { name: '競合B社', value: 81 },
      { name: '競合C社', value: 69 },
      { name: '競合D社', value: 57 },
      { name: '競合E社', value: 34 },
      { name: '競合F社', value: 13 },
    ];
    this.updateSvg(data);
  }

  /**
   * 与えられたデータに基づき、表示を更新します
   * @param data データ配列
   */
  updateSvg(data: Data = []) {
    // 描画順に呼び出します
    this.updateAxises(data);  // 軸の表示を更新します
    this.updateBars(data);    // バーの表示を更新します
    this.updateLabels(data);  // ラベルの表示を更新します
  }

  /**
   * 与えられたデータに基づき、矩形の位置とサイズを計算して
   * D3オブジェクトに設定します。
   * @param selection D3のSelectionオブジェクト
   * @param data バインドするデータ(省略可能)。
   */
  private setSizeAndPositionToRect<T extends d3.BaseType>(
    selection: d3.Selection<T, Datum, d3.BaseType, unknown>,
    data: Data = selection.data()) {

    const [x, y] = [this.xScale(data), this.yScale(data)];

    return selection
      .attr('x', d => x(d.name))
      .attr('y', d => y(d.value))
      .attr('width', x.bandwidth())
      .attr('height', d => this.svgHeight - this.svgMargin.bottom - y(d.value));
  }

  /**
   * x座標計算用のメソッドです
   * 与えられたデータを、svg上の表示用座標に変換します
   */
  private xScale(data: Data = []) {
    return d3.scaleBand()
      .domain(data.map(d => d.name))
      .range([this.svgMargin.left, this.svgWidth - this.svgMargin.right])
      .padding(0.35);
  }

  /**
   * y座標計算用のメソッドです
   * 与えられたデータを、svg上の表示用座標に変換します
   */
  private yScale(data: Data = []) {
    return d3.scaleLinear()
      .domain([0, d3.max(data.map(d => d.value))])
      .range([this.svgHeight - this.svgMargin.bottom, this.svgMargin.top])
      .nice();
  }

  /**
   * グラフ本体の描画を更新します
   */
  private updateBars(data: Data = []) {
    const rects = this.svgRectElements
      .data(data);

    this.setSizeAndPositionToRect(
      rects
        .enter()
        .append('rect')
        .merge(rects))
      .classed('primaryBar', d => d.primary)
      .classed('secondaryBar', d => !d.primary);

    rects.exit().remove();
  }

  /**
   * ラベルの描画を更新します
   */
  private updateLabels(data: Data = []) {
    const dataLabels = this.svgForeignObjectElements
      .data(data);

    this.setSizeAndPositionToRect(dataLabels, data)
      .select('xhtml\\:div')  // コロンは「\\」でエスケープする必要がある
      .text(d => `${d.value}pts`);

    this.setSizeAndPositionToRect(
      dataLabels
        .enter()
        .append('foreignObject'),
      data)
      .append('xhtml:div')
      .classed('label', true)
      .classed('primaryLabel', d => d.primary)
      .classed('secondaryLabel', d => !d.primary)
      .text(d => `${d.value}pts`);

    dataLabels.exit().remove();
  }

  /**
   * 軸の描画を更新します
   */
  private updateAxises(data: Data = []) {
    this.svgRootElement
      .selectAll('.axis')
      .remove();

    this.svgRootElement
      .append('g')
      .attr('class', 'axis')
      .attr('transform', `translate(0, ${this.svgHeight - this.svgMargin.bottom})`)
      .call(d3.axisBottom(this.xScale(data))
        .tickSize(0));

    this.svgRootElement
      .append('g')
      .attr('class', 'axis')
      .attr('transform', `translate(${this.svgMargin.left}, 0)`)
      .call(d3.axisLeft(this.yScale(data))
        .tickSize(- this.svgWidth + (this.svgMargin.left + this.svgMargin.right)));
  }
}

app.component.css

.primaryBar {
  fill: midnightblue;
}

.secondaryBar {
  fill: gray;
}

.label {
  padding: 1px;
  font-size: 0.5em;
}

.primaryLabel {
  color: floralwhite;
}

.secondaryLabel {
  color: ghostwhite;
}

.axis path, .axis line{
  stroke: gainsboro;
}

所感

大量の文字列リテラルマジックナンバーが残っていますが、敢えて定数として括り出すメリットをほぼ感じなかったのでそのままにしています。 勘弁してやって下さい。

毎回感じることですが、この手の「お絵描き実装」を一気呵成にコーディングするのは至難の業です。

これしきの単純な表現であっても、 エンドユーザの目に映える描画結果の微調整とソースコードリファクタリングという両サイドからの攻めが必要であり、少しコードを書く・結果を目視確認する、という試行錯誤を繰り返すことになります。

それはそうと、こんなオーソドックスな棒グラフを描くだけならばわざわざ D3 を選ばなくてももっと簡単なチャートライブラリがたくさんあることにたった今気づいたたーせるなのでした。 ではまた。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.