D3.jsで作るフォーム1: チェックボックス付き折りたたみツリー


動くサンプルがObservableにあります。D3.jsはversion 5です。
https://beta.observablehq.com/@mojaie/collapsible-tree-with-checkboxes-d3-js

(2018/09/05追記)
やや改良したバージョンのものをObservableに置きました。
https://beta.observablehq.com/@mojaie/d3-js-collapsible-tree-with-checkboxes-revised



![スクリーンショット 2018-08-21 9.35.44.png](https://qiita-image-store.s3.amazonaws.com/0/17925/b3a449ef-29c3-04a9-cb18-0a1696f10777.png)


元データ。{id: 'root'}は仮想の最上階層です。

```javascript
data = [
  {id: 'root'},
  {id: 'Hominidae', parent: 'root'},
  {id: 'Ponginae', parent: 'Hominidae'},
  {id: 'Pongini', parent: 'Ponginae'},
  {id: 'Pongo', parent: 'Pongini'},
  {id: 'P. pygmaeus', parent: 'Pongo'},
  {id: 'P. abelii', parent: 'Pongo'},
  {id: 'P. tapanuliensis', parent: 'Pongo'},
]
```

HTML部分。Checked nodesのところに選択中の項目が表示されます。

```html
<script src="https://d3js.org/d3.v5.js"></script>
<div>Checked nodes: <span id="selected"></span></div>
<div id="view"></div>
```

ツリーの大枠を描画するtree関数

```javascript
  function tree(selection) {
    selection
        .classed('viewport', true)
        .style('overflow-y', 'scroll')
        .style('height', '500px')
      .append('div')
        .classed('body', true)
        .style('transform', 'scale(1.5)')
        .style('transform-origin', 'top left');
  }
```

- ツリーの中身はupdateTreeで描画します。元データに追加や削除などの変更があった場合は、updateTreeでツリーの項目を再描画します。
- d3.stratifyはd3-hierarchyモジュールに含まれます。上記元データのようなid, parentで記述された親子関係のレコードをツリー状のデータ構造に変換します。

```javascript
  function updateTree(selection, items) {
    const root = d3.stratify()
      .id(d => d.id)
      .parentId(d => d.parent)(items);
    selection.select('.body')
        .call(nextLevel, root);
    // Remove dummy root node
    selection.select('.body > span').remove();
    selection.select('.body > ul').style('padding-left', 0);
  }
```

- nextLevel関数によって、ツリー構造のデータを再帰的にたどってツリーの各項目を描画します。
- 元データが変更された場合、exit-enterにより差分のDOMのみが描画されます。
- 子ノードがあるノードは、展開、折りたたみを切り替えるクリックイベントを設置します。

```javascript
  function nextLevel(selection, node) {
    const label = selection.append('span');
    const arrow = label.append('span').classed('arrow', true);
    label.call(renderNode, node.data);
    if (!node.hasOwnProperty('children')) return;
    const items = selection.append('ul')
        .style('list-style-type', 'none')
      .selectAll('li')
        .data(node.children, d => d.id);
    items.exit().remove();
    items.enter()
      .append('li').merge(items)
        .each(function (d) {
          d3.select(this).call(nextLevel, d);
        });
    label.select('.arrow')
        .text('▼ ')
        .on('click', function () {  // Collapse on click
          const childList = selection.select('ul');
          if (!childList.size()) return;
          const expanded = childList.style('display') !== 'none';
          d3.select(this).text(expanded ? '▶ ' : '▼ ');
          childList.style('display', expanded ? 'none' : 'inherit');
        });
  }
```

renderNode関数で各ノードの描画を行います。チェックボックスが変更された際は、checkboxValues関数でチェックされている全ノードのIDを取得し、HTMLのChecked nodesの部分に反映します。

```javascript
  function renderNode(selection, rcd) {
    selection.append('input')
        .attr('type', 'checkbox')
        .on('change', function () {
          d3.select('#selected')
              .text(checkboxValues(d3.select('#view')));
        });
    selection.append('span')
        .text(rcd.id);
  }

  function checkboxValues(selection) {
    return selection.select('.body')
       .selectAll('input:checked').data().map(d => d.id);
  }
```

ツリーを出力します。一度ツリーを出力した後、元データに変更があった場合はupdateTreeだけを実行します。

```javascript
  d3.select('#view').append('div')
      .call(tree)
      .call(updateTree, data);
```