D3.js を使ってお客様の声を可視化する

はじめまして。UX デザインの山崎と申します。

先日、UX デザインチームの勉強会を新宿にある TSUTAYA BOOK APARTMENT さんで実施してきました。

勉強会のテーマは、「メンバー各自業務関係有無に関わらず興味のあることについて勉強」という内容。

会場の様子を少しご紹介しようと思います。

↓ 本がたくさんあるおしゃれ空間です。気に入った本は購入できるそうです。 uxd勉強会

↓ オプションですが女性専用エリアもあります。(ありがたく使用させていただきました)
ドリンクも飲み放題! uxd勉強会

↓ WEB デザイン関連の書籍もありました。CO WORKING スペースは集中できそうですね。
(男女共用エリアは夕方以降、カップルで賑わうそうです) uxd勉強会

本を読みながらのんびりしたい気持ちを抑えつつ、各自勉強に取り掛かります。

せっかくなので業務ではふれる機会のないことを勉強したいと思い、D3.js を使って何かを作ってみよう!と思いました。

Data Driven Documents

D3.js については調べればたくさん出てきますが、改めて

D3.js とは

Data Driven Documents(データ・ドリブン・ドキュメント)の略。
javascript のデータの視覚化ライブラリ。
データをドキュメントに接続するという意味でドライブ(駆動)の部分を受け持つ。

D3.js の特徴

  • 描画は基本的に SVG(HTML のエレメントも操作可能)
  • jQuery のようにメソッドをチェーンすることが可能
  • 要素にデータをバインド(結び付け)できるので for 文を書かずに反復処理ができる
  • 要素にバインドされたデータを解釈し、ビジュアルプロパティを設定して、要素を変換する
  • 必要に応じて新しい要素を作る
    などなど

チャートが簡単に作成できそうな感じがしますが、チャート専用ライブラリではないので簡単にチャートを作成したい場合は、Chart.js や C3.js のほうがおすすめのようです。

基礎の勉強

準備

D3.js のファイルをダウンロードするか、以下を読ませます。

<script src="http://d3js.org/d3.v4.min.js" charset="utf-8"></script>

※今回は、v4 を使用しました

図形を描画してみる

① まず、HTML テンプレートを用意
② スクリプト内に以下のコードを追加
var width = 960,
  height = 700,
  svg = d3.select('body')
    .append('svg')
    .attr('width', width)
    .attr('height', height);

body に、幅 960px・高さ 700px の svg 要素が追加されます。

③ 次に、以下のコードを追加
svg
  .append('circle')
  .attr('cx', 130)
  .attr('cy', 180)
  .attr('r', 30)
  .attr('fill', 'steelblue');

② で追加した svg 要素の中に、半径 30px の 円 が追加されます。

各属性の説明は以下

  • cx = 円の中心の x 座標
  • cy = 円の中心の y 座標
  • r = 円の半径
  • fill = 円の内側を塗りつぶす色
④ 次は、以下のコードを追加
svg
  .append('rect')
  .attr('x', 100)
  .attr('y', 50)
  .attr('width', 300)
  .attr('height', 50)
  .attr('fill', 'blue');

② で追加した svg 要素の中に、幅 300px・高さ 50px の長方形が追加されます。

各属性の説明は以下

  • x = 長方形の左上端の x 座標
  • y = 長方形の左上端の y 座標
  • fill = 塗りつぶす色
⑤ 最後に、以下のコードを追加
svg
  .append('line')
  .attr('x1', 100)
  .attr('x2', 400)
  .attr('y1', 260)
  .attr('y2', 260)
  .attr('stroke-width', 4)
  .attr('stroke', 'gray');

② で追加した svg 要素の中に、太さ 4px・長さ 300px の直線が追加されます。

各属性の説明は以下

  • x1 = 始点の x 座標
  • x2 = 終点の x 座標
  • y1 = 始点の y 座標
  • y2 = 終点の y 座標
  • stroke-width = ラインの太さ
  • stroke = ラインの色
⑥ ブラウザで見てみると、こんな感じになっています。

図形が簡単に作成できますね。

D3demo

データをバインドしてみる

まず適当なデータを用意します
var dataset = [100, 150, 200, 250, 300, 350, 400];
以下のコードを追加します
var circles = svg
  .selectAll('circle')
  .data(dataset)
  .enter()
  .append('circle')
  .attr('cx', function(d) {
    return d;
  })
  .attr('cy', 50)
  .attr('r', 10)
  .attr('fill', 'steelblue');
ポイント

.data()
データをバインドする。データ数をカウント・解析してくれる。
function(d) { return d; }というように値を呼び出すことができる。

.enter()
データがバインドされた新しい要素を作成する。
データの数が要素の数を上回る場合でも.enter()がプレースホルダーを作成し、.append() がその分要素を作成してくれる。

ブラウザで見ると以下のようになっています。

データの数だけ円が作成され、cx(円の中心の x 座標)にデータの数値が入ります。

D3demo

アニメーションを実装してみる

上記のコードに下記を追加してアニメーションを実装してみます。

circles
  .transition()
  .delay(500)
  .duration(function(d) {
    return d * 10;
  })
  .ease(d3.easeBounceOut)
  .attr('cy', function(d) {
    return d;
  })
  .attr('fill', function(d) {
    return colorScale(d);
  });

.transition()で簡単にアニメーションを実装することができます※.attr()よりも先に記述するようにします
.ease()で指定しているエフェクトを変化させるための関数は、D3.js v4 ~たくさんの関数が用意されています。
今回はボールが跳ねるような動きの関数を使用しました。

カラースケール作成

スケールの線形変換を使った方法で、色の指定を行います。

var colorScale = d3.scaleLinear()
  .domain([
    d3.min(dataset, function(d) {
      return d;
    }),
    d3.max(dataset, function(d) {
      return d;
    })
  ])
  .range(['red', 'blue']);

d3.scaleLinear()は.domain()で指定した範囲を、.range()で指定した範囲に変換して出力する関数を設定します。
今回、.domain()では d3.min()と d3.max()を使って配列をループし、配列内の最小値と最大値を指定しています。
.range()では値が小さい順に赤から青にかけて変化するように設定。
以下のように色の指定をすることができます。

colorScale(20);

アニメーションが実装できました。 D3 transition demo

お客様の声を可視化する

さて、D3.js の書き方に慣れてきたところで実際にデータを可視化してみたいと思います。

色々考えた結果、

LOHACO を使ってくれているお客様の声を可視化してみることに決めました!

理由としては...

LOHACOでは毎日寄せられる「お客様の声」が全社員に周知されています。

私自身も社内外で実施するユーザビリティテストにテスト設計の段階から参加させてもらうことが増えて、お客様の声を生で聞ける機会が増えたため、蓄積した知見からデータ作成ができると考えました。
※今回は直近 3 ヶ月以内に 6 回実施したユーザビリティテストの結果から独自データを作成します

アウトプットイメージ

D3 のGalleryからイメージに近いものを探します。

色々な表現が可能なんだなーと感心しつつ、バブルチャートが面白そう!と思いバブルチャートに決めました。

↓D3 の Visual Index D3visualIndex

決定事項

  • たくさんのバブルにお客様の声を表示
  • 多い声ほどバブルを大きする
  • 不満~満足の評価を色で判別できるようにする

データの用意

以下の内容を、json ファイルで用意します。

key value
r(円の半径) 大きさをお客様の声の多さとします(0 ~ 100%として設定)
review 不満~満足までの 5 段階評価
txt 円の中に表示するテキスト(半径が小さい場合、省略したテキストを用意)
title title 属性(円にマウスオンすると表示される)

resource/js/customers_voice.json

[
  {
    "r": 80,
    "review": 5,
    "txt": "写真がきれい!",
    "title": "写真がきれい!"
  },
  {
    "r": 50,
    "review": 3,
    "txt": "配送日時",
    "title": "宅配BOXある場合は配送日時を気にしない"
  },
  {
    "r": 90,
    "review": 3,
    "txt": "キーワード検索のほうが早い",
    "title": "キーワード検索のほうが早く商品を探せる"
  },
  {
    "r": 90,
    "review": 2,
    "txt": "品揃えがもっと増えるといいなぁ",
    "title": "品揃えがもっと増えるといいなぁ"
  },
  {
    "r": 60,
    "review": 5,
    "txt": "見ていて楽しい!",
    "title": "見ていて楽しい!"
  },
  {
    "r": 70,
    "review": 1,
    "txt": "見つからない...",
    "title": "欲しい商品が見つからないなぁ"
  },
  {
    "r": 40,
    "review": 3,
    "txt": "商品画像",
    "title": "商品説明やスペックより商品画像をよく見ます"
  },
  {
    "r": 60,
    "review": 1,
    "txt": "送料が違うんだなぁ",
    "title": "商品によって送料バーが違うんだなぁ"
  },
  {
    "r": 50,
    "review": 4,
    "txt": "Tポイント",
    "title": "Tポイントが使える貯まる!"
  },
  {
    "r": 90,
    "review": 5,
    "txt": "送料無料うれしい!",
    "title": "送料無料うれしい!"
  },
  {
    "r": 70,
    "review": 5,
    "txt": "配達が早い!",
    "title": "配達が早い!"
  },
  {
    "r": 60,
    "review": 3,
    "txt": "商品説明見ない",
    "title": "買ったことある商品は説明見ない"
  },
  {
    "r": 80,
    "review": 3,
    "txt": "レビューを気にする",
    "title": "初めて買う商品はレビューを気にする"
  },
  {
    "r": 50,
    "review": 5,
    "txt": "見やすい!",
    "title": "見やすくて好き!"
  },
  {
    "r": 50,
    "review": 3,
    "txt": "リピート買い",
    "title": "決まった商品しか買わないかな"
  },
  {
    "r": 80,
    "review": 2,
    "txt": "10点満点中7点かなぁ",
    "title": "10点満点中7点かなぁ"
  },
  {
    "r": 70,
    "review": 4,
    "txt": "オリジナル商品おしゃれ!",
    "title": "オリジナル商品おしゃれ!"
  },
  {
    "r": 80,
    "review": 4,
    "txt": "キーワード検索派!",
    "title": "キーワード検索派!"
  },
  {
    "r": 90,
    "review": 2,
    "txt": "一部サービスがよくわからない",
    "title": "一部サービスがよくわからない"
  },
  {
    "r": 100,
    "review": 2,
    "txt": "送料わかりづらいなぁ",
    "title": "送料わかりづらいなぁ"
  }
]

HTML になります。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0">
  <title>D3 demo</title>
  <link href="/resource/css/style.css" rel="stylesheet">
  <link href="https://fonts.googleapis.com/earlyaccess/notosansjapanese.css" rel="stylesheet">
</head>
<body>

  <svg id="content" width="100%" height="800"></svg>

  <script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
  <script type="text/javascript" src="/resource/js/main.js"></script>
</body>
</html>

js になります。ポイントとなる解説はコード内のコメントをご確認ください。
resource/js/main.js

$(function() {
  'use strict';

  //title追加
  d3.select('svg')
    .append('text')
    .attr('class', 'title')
    .attr('x', 10)
    .attr('y', 30)
    .text("Bubbles of customer's voice");

  // json
  var file = '/resource/js/customers_voice.json';

  // データをセット
  d3.json(file, function(json) {
    var data = json;
    var width = document.querySelector('svg').clientWidth;
    var height = document.querySelector('svg').clientHeight;
    var xRamdom = width * Math.random();
    var yRamdom = height * Math.random();
    var nodesData = [];

    // nodesDataにデータをpush
    // x座標と、y座標はランダム
    for (var i = 0, d = data.length; i < d; i++) {
      nodesData.push({
        x: xRamdom,
        y: yRamdom,
        r: data[i].r,
        rev: data[i].review,
        txt: data[i].txt,
        title: data[i].title
      });
    }

    // カラースケール作成
    var colorScale = d3.scaleLinear()
      .domain([1, 5])
      .range(['rgba(0,0,255,0.8)', 'rgba(255,105,180,0.8)']);

    // svg要素を配置
    // callでドラッグ時のイベント関数を登録
    var nodeGroup = d3.select('svg')
      .selectAll('g')
      .data(nodesData)
      .enter()
      .append('g')
      .call(
        d3.drag()
          .on('start', dragstarted)
          .on('drag', dragged)
          .on('end', dragended)
      );

    //gの子要素としてcircleをappend
    nodeGroup
      .append('circle')
      .attr('cx', function(d) {
        return d.x;
      })
      .attr('cy', function(d) {
        return d.y;
      })
      .attr('r', function(d) {
        return d.r;
      })
      .attr('fill', function(d) {
        return colorScale(d.rev);
      })
      .append('title')
      .text(function(d) {
        return d.title;
      });

    //gの子要素としてtextをappend
    nodeGroup
      .append('text')
      .attr('x', function(d) {
        return d.x;
      })
      .attr('y', function(d) {
        return d.y;
      })
      .attr('text-anchor', 'middle')
      .attr('dominant-baseline', 'middle')
      .style('fill', '#fff')
      .text(function(d) {
        return d.txt;
      })
      .append('title')
      .text(function(d) {
        return d.title;
      });

    // forceSimulation設定
    var simulation = d3.forceSimulation()
      .force(
        'collide', d3.forceCollide().radius(function(d) {
          return d.r;
        })
      )
      .force('charge', d3.forceManyBody())
      .force(
        'x', d3.forceX()
          .strength(0.03)
          .x(width / 2)
      )
      .force(
        'y', d3.forceY()
          .strength(0.03)
          .y(height / 2)
      );
    //radius => ノードの半径、defaultは1
    //strength => どのくらいで戻るかの係数、0.0~1.0が推奨

    simulation.nodes(nodesData).on('tick', ticked);

    // forceSimulation 描画更新用関数
    function ticked() {
      nodeGroup.select('circle')
        .attr('cx', function(d) {
          return d.x;
        })
        .attr('cy', function(d) {
          return d.y;
        });
      nodeGroup.select('text')
        .attr('x', function(d) {
          return d.x;
        })
        .attr('y', function(d) {
          return d.y;
        });
    }

    // ドラッグ時のイベント関数
    function dragstarted(d) {
      if (!d3.event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }
    //alphaTarget => シミュレーションを滑らかに繋げるための係数、0~1を指定できる。低いほうが滑らか

    function dragged(d) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }

    function dragended(d) {
      if (!d3.event.active) simulation.alphaTarget(0);
      d.fx = null;
      d.fy = null;
    }
  });

  // x軸の設定
  var color = d3.interpolate('rgba(0,0,255,0.8)', 'rgba(255,105,180,0.8)');
  var number = 10;
  var w = 320;
  var h = 80;
  var padding = 20;

  var scale = d3.select('body')
    .append('svg')
    .attr('width', w)
    .attr('height', h)
    .attr('class', 'scale');

  scale
    .append('text')
    .attr('x', 10)
    .attr('y', 10)
    .text('← 不満');

  scale
    .append('text')
    .attr('x', 270)
    .attr('y', 10)
    .text('満足 →');

  var defs = scale
    .append('defs')
    .append('linearGradient')
    .attr('id', 'gradient');

  var rect = scale
    .append('rect')
    .attr('width', 300)
    .attr('height', 40)
    .attr('x', 10)
    .attr('y', 15)
    .attr('fill', 'url(#gradient)');

  for (var i = 0; i < number; i++) {
    defs
      .append('stop')
      .attr('offset', (i * 100) / number + '%')
      .attr('stop-color', color(i / number));
    defs
      .append('stop')
      .attr('offset', ((i + 1) * 100) / number + '%')
      .attr('stop-color', color((i + 1) / number));
  }

  // x軸追加
  var scaleData = [1, 2, 3, 4, 5];
  var xScale = d3
    .scaleLinear()
    .domain([1, 5])
    .range([0, 300]);
  var xAxis = d3.axisBottom(xScale);

  scale
    .append('g')
    .attr('class', 'axis')
    .attr('transform', 'translate(10,' + (h - padding) + ')')
    .call(xAxis);

  //リサイズ処理
  var content = $('#content');
  $(window).on('resize load', function() {
    var targetWidth = $(window).width();
    var targetHeight = $(window).height();
    content.attr('width', targetWidth);
    content.attr('height', targetHeight);
  });
});
補足

forceSimulation とは
D3.js v4 ~使用できる機能。
動く"ビジュアライゼーション"を作成できる。
円が中心に集まってくる設定などを定義できる。

スタイルも少し調整します。
resource/css/style.css

@charset "UTF-8";

html,
body {
  font-family: 'Noto Sans Japanese';
  color: #333;
}
* {
  margin: 0;
  padding: 0;
}
.title {
  font-size: 18px;
  display: inline-block;
}
#content {
  margin: auto;
  display: block;
}
g {
  cursor: pointer;
}
text {
  display: block;
  font-size: 12px;
  color: #fff;
}
.scale {
  position: fixed;
  bottom: 40px;
  right: 20px;
}

完成!

ゲージも追加しました。

D3visualIndex

D3.js をさわってみた感想

最初はなんだか難しそうと思っていましたが、一度データをバインドすれば for 文を使わず値が呼び出せることろや描画の自由度が高いところなどがとても楽しかったです!

また、情報が多いので学習もしやすいように感じました。

本格的なチャートを作成してみたり、API とさせてみたり...色々と試してみたいなと思いました。

↓ 今回こちらを参考にさせていただきました
https://wizardace.com/d3-forcesimulation-onlynode/

ASKUL Engineering BLOG

2021 © ASKUL Corporation. All rights reserved.