D3.jsのnpmパッケージを使いたいがためにWebpackを設定する

この記事は「[株式会社オープンストリーム Advent Calendar 2019](https://qiita.com/advent-calendar/2019/opst)」の12日目の記事です。

どうも、データから図を作るときにD3.jsを試している @ysd_marrrr です。
[D3.jsの導入方法](https://d3js.org/)を見るとファイルを配置する以外にもこちらのHTMLタグを挿入するだけでD3.jsを使うことができます。


```html
<script src="https://d3js.org/d3.v5.min.js"></script>
```

しかし、「zipファイルを展開して配置するのが面倒くさい」「パッケージで管理できないか」と考えてしまいますよね？ ~~なんと~~ [npmにD3.jsのパッケージがあるんです](https://www.npmjs.com/package/d3)


そこで、npmパッケージになっているD3.jsを利用するためにWebpackを導入する記事になります。
~~パッケージ一つだけであればBrowserfyとかparcelとかより簡単そうなものはありますが~~


## 開発環境

macOSで動作確認をしています。

```bash
$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.1
BuildVersion:   19B88

$ node -v
v13.2.0

$ npm -v
6.13.1

$ yarn -v
1.21.0
```

Windowsの場合は **後述する「Webpackのモード切替」がうまくいかないため調査中です。** ~~余計なことしなければいいのに~~
Webpackのモードを固定にすると正しく動作します。


## プロジェクトの設定: `npm init`

`npm` も `package.json` も何も使っていない状態で始めようとするときは、新しくディレクトリを作って `npm init` を実行します。
いきなりD3.jsのパッケージを `npm` で取得しようとすると **予測できない場所に保存されます(実体験)**

```bash
npm init
```

途中で `entry point: index.js` と質問されますが、主にnpmのモジュールとして公開したときに呼び出される設定です。
今回は作ったものをnpmのパッケージとして公開する予定はないため **何も変更せず** 次に進めます。


## Webpackの導入

`npm` でインストールするD3.jsのパッケージを読み込めるようにWebpackをインストールします。また、動作確認用にWebサーバーがほしいので `webpack-dev-server` も導入します。

```bash
npm install -D webpack webpack-cli webpack-dev-server 
```

## babel-loaderの導入

D3.jsそのものではなく「D3.jsを使って図を描くスクリプト」でECMAScript6を使うため、 `babel-loader` を導入します。

```bash
npm install -D babel-core babel-loader@7 babel-preset-env
```

## もちろんD3.jsの導入

そして、今回の主役であるD3.jsのnpmパッケージをインストールします。

```bash
npm install d3
```


## webpack.config.js

少し長いですが、主に次の4つを設定しています

 * 「D3.jsを使って図を描くスクリプト」を指定する `entry`
 * 「D3.jsそのもの」「D3.jsを使って図を描くスクリプト」を一つにまとめて変換したJavaScriptファイルの出力先を指定する `output`
 * 開発用にWebサーバーを用意するための `devServer`
 * 「D3.jsを使って図を描くスクリプト」を変換するための `balel-loader` のルール( `D3.js` がインストールされている `node_modules` が除外されていますね？)

```
const path = require("path");

module.exports = {
  mode: development,
  entry: "./src/main.js",
  devtool: "source-map",
  output: {
    path: path.join(__dirname, "public", "js"),
    filename: "app.js",
    publicPath: "/js/"
  },
  devServer: {
    open: true,
    openPage: "index.html",
    contentBase: path.join(__dirname, "public"),
    watchContentBase: true,
    port: 3010
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["babel-preset-env"]
          }
        }
      }
    ]
  }
};

```

この設定で、ディレクトリ構成は次の図のようになります。

 * `npm` で導入したD3.jsパッケージが含まれる `node_modules` と
 * 「D3.jsを使って図を描くスクリプト」が入っている `./src/app.js` を
 * 予め用意した公開用ディレクトリ `./public` の配下にある `./public/js` にまとめて
 * 予め用意した `./public/index.html` で呼び出して図を描画する

![20191212.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/0666508e-8fc9-69a8-1f7e-50c213e55224.png)


## D3.jsを使って図を描くページ: `./public/index.html`

HTMLにはD3.jsで描画された図を出すためのボックスのみを用意します。

今回は `npm` でD3.jsを使えるようにするため、スタイルシートは簡単にHTML内に入れています。
Webpackには外部のCSSファイルを下のようにHTML内に埋め込んでくれるプラグインもあります！


```html:./public/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>D3.js Sample with npm</title>
    <style type="text/css">
      #drawarea {
        box-shadow: 5px 5px 15px 5px black;
      }
      .node text {
        font: 16px helvetica;
     }
    </style>
  </head>
  <body>
    <h1>D3.js Sample</h1>
    <p>この下のボックスにD3.jsで描画されたものが来ます！</p>
    <svg id="drawarea" width="400" height="400"></svg>

     <hr />
    <script src="/js/app.js"></script>
  </body>
</html>

```

WebpackでまとめられたJavaScriptのファイルは同じ `./public` にある `index.html` から見てのパスで指定します。

## D3.jsを使って図を描く

https://wizardace.com/d3-forcesimulation-simple/ をベースに、ノード(点)にテキストを表示できるようにするなどカスタマイズしています。

こちらの `forceCenter` をHTML側の `#drawarea` の中心に合わせる必要があり，大きくズレると「あれ？エラーはないのに出ないなぁ…」と困る羽目になります。

`forceSimulation` がノードを自動で配置したり、マウスのドラッグでノードを動かすとほかのノードも追従するものになっています。

```js:./src/main.js
import * as d3 from "d3";

// 1. 描画用のデータ準備
const nodesData = [
  { id: "お母さん" },
  { id: "赤ずきんちゃん" },
  { id: "おばあさん" },
  { id: "オオカミ" },
  { id: "猟師さん" },
  { id: "お花" }
];

const linksData = [
  { source: 0, target: 1 },
  { source: 1, target: 2 },
  { source: 3, target: 1 },
  { source: 3, target: 2 },
  { source: 4, target: 3 },
  { source: 1, target: 5 },
  { source: 5, target: 2 }
];

// JS内に埋め込んだデータでも、d3.json()で外部から読み込んだデータでも描画できるように
// 描画部分は別の関数に分けている
process_network(nodesData, linksData);


function process_network(nodesData, linksData) {
  console.log(linksData);

  // 2. svg要素を配置
  var link = d3
    .select("#drawarea")
    .selectAll("line")
    .data(linksData)
    .enter()
    .append("line")
    .attr("stroke-width", 1)
    .attr("stroke", "black");

  var node = d3
    .select("#drawarea")
    .selectAll("g")
    .data(nodesData)
    .enter()
    .append("g")
    .call(
      d3
        .drag()
        .on("start", dragstarted)
        .on("drag", dragged)
        .on("end", dragended)
    );
  
  node
    .append("circle")
    .attr("r", 24)
    .attr("stroke", "black")
    .attr("stroke-width", 1.5)
    .attr("fill", "peachpuff");

  node
    .append("text")
    .attr("text-anchor", "middle")
    .attr("dominant-baseline", "middle")
    .style("fill", "gray")
    .text(function(d) {
      return d.id;
    })
    .append("title")
    .text(function(d) {
      return d.id;
    });

  // 3. forceSimulation設定
  // ここでエッジの長さや描画の中心などを決める
  var simulation = d3
    .forceSimulation(nodesData)
    .velocityDecay(0.3)
    .alpha(0.7)
    .force("link", d3.forceLink().distance(250))
    .force("charge", d3.forceManyBody())
    .force("center", d3.forceCenter(200, 200));

  simulation.nodes(nodesData).on("tick", ticked);
  simulation.force("link").links(linksData);

  // 4. forceSimulation 描画更新用関数
  function ticked() {
    link
      .attr("x1", function(d) {
        return d.source.x;
      })
      .attr("y1", function(d) {
        return d.source.y;
      })
      .attr("x2", function(d) {
        return d.target.x;
      })
      .attr("y2", function(d) {
        return d.target.y;
      });
    node
      .select("circle")
      .attr("cx", function(d) {
        return d.x;
      })
      .attr("cy", function(d) {
        return d.y;
      });
    
    // 追加: ノードの下に付けたテキストもforceSimulationで追従できるように設定する
    node
      .select("text")
      .attr("dx", function(d) {
        return d.x;
      })
      .attr("dy", function(d) {
        return d.y + 40;
      });
  }

  // 5. ドラッグ時のイベント関数
  function dragstarted(d) {
    if (!d3.event.active) simulation.alphaTarget(0.3).restart();
    d.fx = d.x;
    d.fy = d.y;
  }

  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;
  }
}
```

ここでwebpack-dev-serverを起動します。うまくいけば図が表示されます。

```bash
npm run start
```
![20191212-sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/216963/9bc4a804-5604-3377-36c1-22aa60cbee34.png)



### d3.jsが読み込まれているみたいだけど図を描く操作が反映されない

この場合、スクリプトは `body` タグの一番最後で読み込む必要があります。
https://stackoverflow.com/questions/23803146/d3js-external-javascript-file

> You're probably including the Javascript file in the header. You need to include it in the body after the elements it's operating on are defined. – Lars Kotthoff May 22 '14 at 10:03

[ほかの例](https://code.likeagirl.io/how-to-set-up-d3-js-with-webpack-and-babel-7bd3f5e20df7) でも `body` の一番最後でスクリプトを読み込んでいますね🤔

## D3.jsは簡単に図を描くものではなく、図とデータを結びつけるもの

D3.jsにおける描画はあらかじめ用意された描画用の関数を通して、ではなくSVG/canvasの要素とデータを紐付けて描画します。
そのため「D3.jsがあるから簡単に図が出せる」…というわけではありません。

詳しくカスタマイズするときはSVGの知識が必要にあります。この例を作るときもSVGについて調べました。
(canvasで実現しようとするとD3.jsだけでは足りず、Fubric.jsも同時に使う例がヒットします)

また、[D3.jsのサンプル](https://github.com/d3/d3/wiki/Gallery)を見ると **その種類は多岐に渡り**、「どの図を使おう」「どうやって書いたらいいのかイメージできない」といった事態に陥ります。

特に「簡単にグラフを描きたい！」という場合は[Chart.js](https://www.chartjs.org/)の導入を検討してください。


## 出力されるJavaScriptを圧縮する

Webpackをproductionモードで実行すると出力されるJavaScriptファイルが自動的に圧縮されます。
https://www.konosumi.net/entry/2018/06/23/024057
https://webpack.js.org/configuration/mode/

そのモードの切替ですが、

 * `package.json` でWebpackを起動するスクリプトに環境変数を仕込み
 *  `webpack.config.js` で環境変数を `mode` にセットすると

`npm`コマンドの使い分けでモードの切替ができます。

はじめに、 `package.json` のスクリプトの設定で `webpack` コマンドの前に `WEBPACK_MODE` の環境変数を仕込みます・

```json:package.json
  "scripts": {
    "start": "webpack-dev-server --hot",
    "dev": "WEBPACK_MODE=development webpack",
    "build": "WEBPACK_MODE=production webpack",
    "watch": "webpack --watch",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
```

`webpack.config.js` は環境変数で `mode` を切り替えられるようにします。

```js:webpack.config.js
const path = require("path");

module.exports = {
  mode: process.env.WEBPACK_MODE,
...
```

`npm` のコマンドで開発用・本番用ビルドを切り替えることができます。

```bash
# 開発用ビルド
npm run dev

# 本番用ビルド(JS圧縮有効)
npm run build
```

圧縮にはES6に対応した `TerserPlugin` が使用されます。 

> Sets process.env.NODE_ENV on DefinePlugin to value production . Enables FlagDependencyUsagePlugin , FlagIncludedChunksPlugin , ModuleConcatenationPlugin , NoEmitOnErrorsPlugin , OccurrenceOrderPlugin , SideEffectsFlagPlugin and TerserPlugin .

また、productionモードではWebpackでまとめたファイルと元のファイルの対応関係を出してデバッグを容易にするmapファイルを出力しないようにしましょう。
https://qiita.com/mwatanabe@github/items/42cfdc5fdf193eee5a98

```js:webpack.config.js
module.exports = {
  mode: process.env.WEBPACK_MODE,
  entry: "./src/main.js",

  // この一行を削除して
  devtool: "source-map",
...
};

// ここでモードごとにmapファイルを出力するかどうか決める
if (process.env.WEBPACK_MODE !== 'production') {
    module.exports.devtool = 'source-map';
}
```

## おわりに

この方法でD3.jsなど欲しいライブラリをnpmのパッケージで使うことができました。
すでにプロジェクトで他のnpmパッケージを導入しているのであれば、読み込み方法の統一やパッケージの依存関係の解決の観点でnpmのパッケージでライブラリを導入したくなります。

一応サンプルコードはこちらにあります。
https://github.com/ysd-marrrr/d3-webpack-sample-20191212

~~D3.jsをはじめてみてSVGの書き方を知ることになりました~~

## 参考

【TypeScript+D3.js】Web初心者がWebアプリケーションをでっちあげるまでの道のり - Qiita
https://qiita.com/YSRKEN/items/6683ec1c1de085935a69

npmとwebpack4でビルド - jQueryからの次のステップ - Qiita
https://qiita.com/civic/items/82c0184bcadc50965f91#es2015es6%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B

webpack4対応webpack-dev-serverの主要な設定オプション(CLI,webpack.config.js)の意味と挙動 - Qiita
https://qiita.com/riversun/items/d27f6d3ab7aaa119deab

【初心者向け】NPM・package.jsonを理解する - Qiita
https://qiita.com/righteous/items/e5448cb2e7e11ab7d477

D3.jsにあてはまらないこと
https://postd.cc/what-d3js-is-not/

D3.js v4/v5 force simulation 最小構成 – サンプル
https://wizardace.com/d3-forcesimulation-simple/