Google Sheets で管理している人事データベースから組織図を動的に作成する

# はじめに

組織図は企業の人員構成を視覚的に示すのに便利だが、最新の情報を反映し続けるには手間がかかる。 **Google Sheets** を組織データベースとして活用し、**Google Apps Script (GAS) のHTMLサービス** を使って 動的な組織図 を生成・配信する方法を検討した。

これにより、**Google Sheets** 上でデータを更新するだけで組織図も自動更新され、常に最新の状態を保てる。また、HTMLとして出力することで Google Sitesに簡単に埋め込め、独立したサーバーを用意する必要もない。社内向けサイトに組織図を表示する場合など、Googleのクラウド環境だけで完結する手軽さが大きなメリットになるだろう。 組織図の描画には MITライセンスの [**d3-org-chart**](https://www.npmjs.com/package/d3-org-chart) というオープンソースのライブラリを利用した。

**d3-org-chart** は 修正BSDライセンスの [**D3.js**](https://d3js.org/) を基盤にした柔軟な組織図コンポーネントで、ノード（各人物枠）のカスタマイズやインタラクティブな操作が簡単に実装でき、月間14万超のnpmダウンロードがある人気ライブラリとのことだ。

# d3-org-chart とは

**d3-org-chart** は、**D3.js** を用いてブラウザ上に階層型データ（組織ツリー）を描画するライブラリだ。シンプルなAPIと高いカスタマイズ性を備えており、数行のコードで基本的な組織図を表示できる。

**Google Apps Script (GAS)** 上で動作する組織図表示コードは下記のようになる。**GAS** の HTML サービスで **doGet()** によりHTMLテンプレートにデータを埋め込み、クライアント側 JavaScript で **d3-org-chart** を利用して描画する。


## 3d-org-chart で作成可能な組織図

次の図のように動的に操作可能な組織図を作成することができる。

![3d-org-chart-sample1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1017489/6ab80dba-ea3b-4f47-b76e-d90b5942ed55.gif)



下記の公式ページで多数の機能が確認できる

https://github.com/bumbeishvili/org-chart



## Google Sheets と 3d-org-chart を使った組織図の作り方

### Google Sheets のデータ

次のような人事データベースが **Google Sheets** のファイルで管理され、データは **Sheet1** シートに保存しているもとする。


| id | parentId | name       | role       | photo |
|----|----------|------------|------------|------|
| 1  |          | 田中 太郎  | 社長       | https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEin0TfQCAEqcOCuDAOzVoVt-Qq4uOWESiZC6gUN5QJ1DsCKppgAJPd76fG4lMf5Wpz8M5x2G_FW_t9gSW9jxkShrxh1HSFlhu46ckHLXTvHGauD8BYAQC1db5T-LIc710aH2gB1f9DTyM4/s800/animal_arupaka.png |
| 2  | 1        | 鈴木 一郎  | 技術部長   | https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjwG0xXIptaOP2F8qwAxGh3weCF0xugPbOgOFCwEIenI0j6FBGDjdxqYN4VgUDkgVWc8n3ef_jZ-1m6BAuhEync9TJoejgyIeHycpXiB1oZJ88u99yC0C3cnap7MUNNZ5WQQwqfV9gaTHA/s800/animal_buta.png |
| 3  | 1        | 佐藤 花子  | 営業部長   | https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhN7osrsPNvmQxjKg_YHSI3rsq1eqAhi3fE_JVPGhTrYeTdttYs6YE4PYJMebkwTEBEiOaSAQp_G_waa_Wy-eLybp4UwTOaIGMnVSZVohyhGmZfdi-2bzcysj8aNaZVoou-KRuL1DP4_IY/s800/animal_hamster.png |
| 4  | 2        | 高橋 次郎  | エンジニア | https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEglifCABrZm2GZQGyRTgMxlB3OxvUfh-MB8lBX-c-Xk6k2Vc9iZ36Gfy_ha1Pjw_OeSwgsBBVrVOzh406buRWDy7e4FyZkDmp7Ap64rWQOBGkVE-McTUXqw5onds4h2zYrjCqpux1cDjn4/s800/animal_hiyoko.png|
| 5  | 2        | 中村 理恵  | デザイナー | https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj_Jb2dSHvFPcUjxl753C-AkJDQdD71J9cwskYmrwpw2lcR7CoLEZU77s6ZWcgLsTJ_Rjsn2onNx1TkwlYv2_ziUm49HGN4fsMDccNN2HJBq3Wp-agn5U9Fc45FzDVKDJR81H4HYYF-zhE/s800/animal_inu.png |

Photoデータは「いらすとや」のデータを利用させて頂いた。

### GASのソースコード

人事データベースを管理する **Google Sheets** のメニューから **[拡張機能]** - **[App Script]** を選択して、Code.gs に **doGet()** 関数と、index.htmlファイルを作成する。

1. doGet() でスプレッドシートのデータを JSON にして HTML に渡す
1. index.html 側でその JSON を読み取り、d3-org-chart でブラウザに組織図を描画
1. ブラウザではインタラクティブに展開・折りたたみ・ハイライトなど可能

```javascript:Code.gs
function doGet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
  const values = sheet.getDataRange().getValues();           // スプレッドシート全体のデータ取得
  const headers = values.shift();                            // 1行目（列名）を取り出す
  const data = values.map(row => {
    let obj = {};
    headers.forEach((header, i) => obj[header] = row[i]);     // 各行をオブジェクトに変換
    return obj;
  });
  const tpl = HtmlService.createTemplateFromFile('index');    // index.html を読み込みテンプレート化
  tpl.dataJson = JSON.stringify(data);                        // シートから得たデータをJSON文字列として渡す
  return tpl.evaluate().setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // HTML出力
}
```

```html: index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>組織図</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-org-chart@3"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-flextree@2.1.2/build/d3-flextree.js"></script>
    <style>
      .chart-container { width: 100%; height: 100%; }
      .node-card { background: #fff; border: 1px solid #ccc; border-radius: 6px; padding: 6px; text-align: center; }
      .face-icon { width: 40px; height: 40px; margin-bottom: 6px; }
      .name { font-weight: bold; font-size: 14px; }
      .title { font-size: 12px; color: #666; }
    </style>
  </head>
  <body>
    <div id="chart" class="chart-container"></div>
    <script>
      const data = JSON.parse('<?= dataJson ?>');
      const chart = new d3.OrgChart()
        .container("#chart")
        .data(data)
        .nodeContent(({ data }) => `
          <div class="node-card">
            <img src="${data.photo}" class="face-icon" alt="">
            <div class="name">${data.name}</div>
            <div class="title">${data.role}</div>
          </div>
        `)
        .nodeWidth(() => 120)
        .nodeHeight(() => 100)
        .initialExpandLevel(2)
        .onNodeClick(d => chart.setCentered(d.id).setHighlighted(d.id).render())
        .render();
    </script>
  </body>
</html>
```

### ウェブアプリとしてデプロイした組織図


![3d-org-chart-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1017489/a24188e9-534f-49c7-83bc-6d502a5706ab.gif)


### デザインの変更

**3d-org-chart** の組織図のデザインはCSSで簡単に変更可能だ。[サンプル組織図のデザイン](https://stackblitz.com/edit/js-q8n1mh?file=index.html)を参考にデザインを変更する。

1. ノードカードを横長レイアウト
1. 写真を左上寄せで配置し、その横にテキストを並べる構造
1. Photoを円で切り抜き

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1017489/bbf348b8-2d48-404f-b7e5-4a89793f30b2.png)

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>シンプル組織図</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-org-chart@3.0.1"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-flextree@2.1.2/build/d3-flextree.js"></script>
    <style>
      body {
        margin: 0;
        background-color: #f5f5f5;
        font-family: 'Segoe UI', 'Roboto', 'Arial', sans-serif;
      }

      .header {
        position: fixed;
        top: 0;
        width: 100%;
        background: #ffffff;
        padding: 10px;
        display: flex;
        justify-content: flex-start;
        gap: 10px;
        z-index: 10;
        border-bottom: 1px solid #ddd;
      }

      .chart-container {
        width: 100%;
        height: 100vh;
        padding-top: 60px;
        box-sizing: border-box;
        overflow: auto;
      }
    </style>
  </head>
  <body>
    <div id="chart" class="chart-container"></div>
    <script>
      const data = JSON.parse('<?= dataJson ?>');

      const chart = new d3.OrgChart()
        .nodeHeight(() => 110)
        .nodeWidth(() => 260)
        .childrenMargin(() => 40)
        .compactMarginBetween(() => 30)
        .compactMarginPair(() => 25)
        .neighbourMargin(() => 20)
        .nodeContent((d) => {
          // Top offset for image and background circle positioning (25px base + 2px border or padding correction)
          const imageTopOffset = 25 + 2;
          return `
            <div style="width:${d.width}px;height:${d.height}px;padding-top:${imageTopOffset - 2}px;padding-left:1px;padding-right:1px">
              <div style="
                background-color:#FFFFFF;
                width:${d.width - 2}px;
                height:${d.height - imageTopOffset}px;
                border-radius:10px;
                border:1px solid #E4E2E9;
                position:relative;
                font-family:'Segoe UI', sans-serif;
              ">
                <!-- Optional: Node ID displayed in the top-right corner -->
                <div style="display:flex;justify-content:flex-end;margin-top:5px;margin-right:8px;font-size:10px;color:#999">
                  #${d.data.id ?? ''}
                </div>

                <!-- Decorative background circle behind the profile image -->
                <div style="
                  background-color:#FFFFFF;
                  margin-top:-${imageTopOffset + 20}px;
                  margin-left:15px;
                  border-radius:100px;
                  width:50px;
                  height:50px;
                "></div>

                <!-- Profile image positioned above the white circle -->
                <div style="margin-top:-${imageTopOffset + 20}px;">
                  <img src="${d.data.photo}" style="
                    margin-left:20px;
                    border-radius:100px;
                    width:40px;
                    height:40px;
                    object-fit:cover;
                    border: 1px solid rgba(0, 0, 0, 0.2); /* Thin black border for circular image */
                  " />
                </div>

                <!-- Display name -->
                <div style="font-size:15px;color:#08011E;margin-left:20px;margin-top:10px;font-weight:bold;">
                  ${d.data.name}
                </div>

                <!-- Display role/position -->
                <div style="color:#716E7B;margin-left:20px;margin-top:3px;font-size:12px;">
                  ${d.data.role}
                </div>
              </div>
            </div>
          `;
        })
        .container("#chart")
        .data(data)
        .initialExpandLevel(2)
        .onNodeClick((d) => chart.setCentered(d.id).setHighlighted(d.id).render())
        .render();
    </script>
  </body>
</html>
```

### 機能を追加

[さまざまな便利な機能](https://www.npmjs.com/package/d3-org-chart#features)を追加することができる。便利そうな機能を追加してみる。

- 全ノード展開
- 全ノード折りたたみ
- 全体表示
- レイアウト切り替え
- フルスクリーン表示
- 組織図をSVGで出力
- 検索

![3d-org-chart-functions.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1017489/d10e75b0-da7a-4d65-8c67-cc8d09b7977c.gif)

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>シンプル組織図</title>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-org-chart@3.0.1"></script>
    <script src="https://cdn.jsdelivr.net/npm/d3-flextree@2.1.2/build/d3-flextree.js"></script>
    <style>
      body {
        margin: 0;
        background-color: #f5f5f5;
        font-family: 'Segoe UI', 'Roboto', 'Arial', sans-serif;
      }

      .header {
        position: fixed;
        top: 0;
        width: 100%;
        background: #ffffff;
        padding: 10px;
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
        z-index: 10;
        border-bottom: 1px solid #ddd;
        align-items: center;
      }

      button {
        padding: 8px 14px;
        border: 1px solid #ccc;
        border-radius: 6px;
        background: #f9f9f9;
        color: #333;
        font-weight: bold;
        cursor: pointer;
        transition: background 0.3s;
      }

      button:hover {
        background: #e0e0e0;
      }

      .chart-container {
        width: 100%;
        height: 100vh;
        padding-top: 80px;
        box-sizing: border-box;
        overflow: auto;
      }

      input[type="search"] {
        padding: 8px;
        border: 1px solid #ccc;
        border-radius: 6px;
        font-size: 14px;
        width: 200px;
      }
    </style>
  </head>
  <body>
    <div class="header">
      <button onclick="chart.expandAll()">全ノード展開</button>
      <button onclick="chart.collapseAll()">全ノード折りたたみ</button>
      <button onclick="chart.fit()">全体表示</button>
      <button onclick="toggleLayout()">レイアウト切替</button>
      <button onclick="toggleFullscreen()">フルスクリーン</button>
      <button onclick="exportSvg()">SVGエクスポート</button>
      <input
        type="search"
        placeholder="search by name"
        oninput="filterChart(event)"
      />
    </div>

    <div id="chart" class="chart-container"></div>

    <script>
      const data = JSON.parse('<?= dataJson ?>');

      let currentLayout = "top";

      const chart = new d3.OrgChart()
        .nodeHeight(() => 110)
        .nodeWidth(() => 260)
        .childrenMargin(() => 40)
        .compactMarginBetween(() => 30)
        .compactMarginPair(() => 25)
        .neighbourMargin(() => 20)
        .nodeContent((d) => {
          const imageTopOffset = 27;
          return `
            <div style="width:${d.width}px;height:${d.height}px;padding-top:${imageTopOffset}px">
              <div style="background:#fff;width:${d.width - 2}px;height:${d.height - imageTopOffset}px;
                          border-radius:10px;border:1px solid #E4E2E9;position:relative;">
                <div style="display:flex;justify-content:flex-end;margin:5px 8px 0 0;font-size:10px;color:#999">#${d.data.id}</div>
                <div style="margin-top:-${imageTopOffset + 20}px;margin-left:15px;border-radius:100px;width:50px;height:50px;"></div>
                <div style="margin-top:-${imageTopOffset + 20}px;">
                  <img src="${d.data.photo}" style="margin-left:20px;border-radius:100px;width:40px;height:40px;object-fit:cover;border:1px solid rgba(0,0,0,0.2);" />
                </div>
                <div style="font-size:15px;color:#08011E;margin-left:20px;margin-top:10px;font-weight:bold;">${d.data.name}</div>
                <div style="color:#716E7B;margin-left:20px;margin-top:3px;font-size:12px;">${d.data.role}</div>
              </div>
            </div>
          `;
        })
        .container("#chart")
        .data(data)
        .initialExpandLevel(2)
        .onNodeClick((d) => chart.setCentered(d.id).setHighlighted(d.id).render())
        .render();

      function toggleLayout() {
        currentLayout = currentLayout === "top" ? "left" : "top";
        chart.layout(currentLayout).render();
      }

      function toggleFullscreen() {
        const el = document.getElementById("chart");
        if (document.fullscreenElement) {
          document.exitFullscreen();
        } else {
          el.requestFullscreen();
        }
      }

      function exportSvg() {
        chart.exportSvg();
      }

      function searchNode() {
        const keyword = document.getElementById("searchBox").value.trim();
        if (!keyword) return;
        const found = data.find(d => d.name.includes(keyword));
        if (found) {
          chart.setHighlighted(found.id).setCentered(found.id).render();
        } else {
          alert("見つかりませんでした");
        }
      }

      function filterChart(e) {
        // Get input value
        const value = e.srcElement.value;

        // Clear previous higlighting
        chart.clearHighlighting();

        // Get chart nodes
        const data = chart.data();

        // Mark all previously expanded nodes for collapse
        data.forEach((d) => (d._expanded = false));

        // Loop over data and check if input value matches any name
        data.forEach((d) => {
          if (value != '' && d.name.toLowerCase().includes(value.toLowerCase())) {
            // If matches, mark node as highlighted
            d._highlighted = true;
            d._expanded = true;
          }
        });

        // Update data and rerender graph
        chart.data(data).render().fit();

        console.log('filtering chart', e.srcElement.value);
      }
    </script>
  </body>
</html>

```

# Google Drive 上の画像をd3-org-chart に埋め込む方法

https://qiita.com/rairaii/items/34160d61de010f179268