はじめに
組織図は企業の人員構成を視覚的に示すのに便利だが、最新の情報を反映し続けるには手間がかかる。 Google Sheets を組織データベースとして活用し、Google Apps Script (GAS) のHTMLサービス を使って 動的な組織図 を生成・配信する方法を検討した。
これにより、Google Sheets 上でデータを更新するだけで組織図も自動更新され、常に最新の状態を保てる。また、HTMLとして出力することで Google Sitesに簡単に埋め込め、独立したサーバーを用意する必要もない。社内向けサイトに組織図を表示する場合など、Googleのクラウド環境だけで完結する手軽さが大きなメリットになるだろう。 組織図の描画には MITライセンスの d3-org-chart というオープンソースのライブラリを利用した。
d3-org-chart は 修正BSDライセンスの D3.js を基盤にした柔軟な組織図コンポーネントで、ノード(各人物枠)のカスタマイズやインタラクティブな操作が簡単に実装でき、月間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 で作成可能な組織図
次の図のように動的に操作可能な組織図を作成することができる。
下記の公式ページで多数の機能が確認できる
Google Sheets と 3d-org-chart を使った組織図の作り方
Google Sheets のデータ
次のような人事データベースが Google Sheets のファイルで管理され、データは Sheet1 シートに保存しているもとする。
Photoデータは「いらすとや」のデータを利用させて頂いた。
GASのソースコード
人事データベースを管理する Google Sheets のメニューから [拡張機能] - [App Script] を選択して、Code.gs に doGet() 関数と、index.htmlファイルを作成する。
- doGet() でスプレッドシートのデータを JSON にして HTML に渡す
- index.html 側でその JSON を読み取り、d3-org-chart でブラウザに組織図を描画
- ブラウザではインタラクティブに展開・折りたたみ・ハイライトなど可能
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出力
}
<!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 の組織図のデザインはCSSで簡単に変更可能だ。サンプル組織図のデザインを参考にデザインを変更する。
- ノードカードを横長レイアウト
- 写真を左上寄せで配置し、その横にテキストを並べる構造
- Photoを円で切り抜き
<!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>
機能を追加
さまざまな便利な機能を追加することができる。便利そうな機能を追加してみる。
- 全ノード展開
- 全ノード折りたたみ
- 全体表示
- レイアウト切り替え
- フルスクリーン表示
- 組織図をSVGで出力
- 検索
<!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>