はじめに (対象読者・この記事でわかること)

この記事は、JavaScriptでのWebアプリケーション開発に慣れている方、データ可視化に興味がある方、またはGitHubのContributionグラフを独自に再現してみたい方を対象としています。

この記事を読むことで、GitHubのContributionグラフがどのような仕組みで動いているのか、そしてそれをJavaScriptとHTML/CSS(特にSVG)を使って自作する方法がわかります。具体的なデータの準備から、マス目の描画、色分け、ツールチップの表示といった実装手順を習得できます。日々の活動を視覚的に表現する楽しさや、データ可視化の基礎を学ぶきっかけとなれば幸いです。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。 * HTML/CSSの基本的な知識 * JavaScriptのDOM操作の基本的な知識(配列、オブジェクト、ループ処理など)

GitHub風Contributionグラフとは?その魅力と仕組み

GitHubのプロフィールページに表示される「Contributionグラフ」は、開発者にとって馴染み深いものでしょう。これは、特定の日付におけるコミット、プルリクエスト、Issue作成などの活動量に応じて、色分けされたマス目が表示されることで、過去1年間の活動履歴を視覚的に把握できるツールです。毎日コツコツと活動していればマス目が緑色に染まり、達成感やモチベーションに繋がる方も多いのではないでしょうか。

このグラフの魅力は、一目で自分の活動状況を俯瞰できる点にあります。各マスは1日を表し、その日の活動量(Contribution数)が多ければ多いほど色が濃くなります。これは単純なデータ表示だけでなく、日々の開発習慣を促すゲーミフィケーションの要素も持ち合わせています。

自作する上で理解すべきは、以下の点です。 1. データ構造: 各日付に対応する活動量をどう保持するか。 2. マス目の配置: 1年間の日付を、曜日と週の概念でグリッド状に配置する方法。 3. 色分けロジック: 活動量に応じてマス目の色をどう変えるか。 4. インタラクティブ性: マウスオーバー時に日付と活動量を表示するツールチップの実装。

これらの要素をJavaScriptとSVG(Scalable Vector Graphics)を使って再現していきます。SVGはWeb上でベクターグラフィックを扱うためのXMLベースの形式で、図形描画に非常に適しており、Contributionグラフのような要素の描画に最適です。

JavaScriptとSVGでContributionグラフを実装する

ここから、GitHub風Contributionグラフを実際にJavaScriptとSVGを使って実装する具体的な手順を解説します。

データの準備:過去1年間の活動データをシミュレート

まず、グラフを描画するために必要なデータを用意します。GitHubのContributionグラフは過去1年間の活動を表示するため、約365日分のデータが必要になります。今回は実際のデータではなく、JavaScriptでランダムな活動量を生成してシミュレートします。

各日付のデータは、{ date: 'YYYY-MM-DD', count: 活動量 }のようなオブジェクトの配列として保持します。

Javascript
// 過去1年間の活動データを生成する関数 function generateContributionData() { const data = []; const today = new Date(); // 過去1年分の日付を生成 (閏年も考慮) for (let i = 365; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); const dateString = d.toISOString().split('T')[0]; // YYYY-MM-DD形式 // ランダムな活動量を生成 (0〜15の範囲) let count = Math.floor(Math.random() * 16); // 稀に高めの活動量にする if (Math.random() < 0.1) { // 10%の確率で count += Math.floor(Math.random() * 10) + 5; // 5〜14を追加 } if (Math.random() < 0.02) { // 2%の確率で count += Math.floor(Math.random() * 20) + 15; // 15〜34を追加 } data.push({ date: dateString, count: count }); } return data; } const contributionData = generateContributionData(); console.log(contributionData.slice(0, 7)); // 先頭7日分のデータを確認

HTMLとSVGコンテナの準備

グラフを描画するためのSVGコンテナをHTMLに用意します。また、基本的なスタイルも設定しておきます。

Html
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GitHub風 Contribution Graph</title> <style> body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f0f2f5; margin: 0; flex-direction: column; } .graph-container { position: relative; background-color: #fff; padding: 20px; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); overflow-x: auto; /* 横スクロール対応 */ } svg { display: block; min-width: 900px; /* ある程度の横幅を確保 */ } .contribution-tooltip { position: absolute; background-color: rgba(0, 0, 0, 0.8); color: #fff; padding: 5px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; pointer-events: none; /* ツールチップ自体ではイベントを発生させない */ opacity: 0; transition: opacity 0.2s ease; z-index: 1000; } .day-label, .month-label { font-size: 10px; fill: #555; } /* 曜日ラベル */ .day-label.hidden-mobile { display: none; /* デフォルトは表示 */ } @media (max-width: 768px) { .day-label.hidden-mobile { display: none; /* スマートフォンでは非表示 */ } } </style> </head> <body> <h1>My Custom Contribution Graph</h1> <div class="graph-container"> <svg id="contribution-graph"></svg> <div class="contribution-tooltip" id="tooltip"></div> </div> <script src="script.js"></script> </body> </html>

JavaScriptによる描画ロジックの実装

いよいよ、JavaScriptを使ってSVG要素を生成し、グラフを描画します。各マス目を描画するために、rect要素を使用します。

Javascript
// script.js // 過去1年間の活動データを生成する関数 (前のステップからコピー) function generateContributionData() { const data = []; const today = new Date(); // 過去1年分の日付を生成 (閏年も考慮) for (let i = 365; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); const dateString = d.toISOString().split('T')[0]; let count = Math.floor(Math.random() * 16); if (Math.random() < 0.1) { count += Math.floor(Math.random() * 10) + 5; } if (Math.random() < 0.02) { count += Math.floor(Math.random() * 20) + 15; } data.push({ date: dateString, count: count }); } return data; } const contributionData = generateContributionData(); // 描画設定 const CELL_SIZE = 12; // 各マスの辺の長さ const CELL_GAP = 3; // マス間の隙間 const WEEK_DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MONTH_LABELS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ]; const COLOR_LEVELS = [ '#ebedf0', // Level 0 (no contributions) '#9be9a8', // Level 1 (low) '#40c463', // Level 2 (medium) '#30a14e', // Level 3 (high) '#216e39' // Level 4 (very high) ]; const svg = document.getElementById('contribution-graph'); const tooltip = document.getElementById('tooltip'); function getContributionColor(count) { if (count === 0) return COLOR_LEVELS[0]; if (count <= 5) return COLOR_LEVELS[1]; if (count <= 15) return COLOR_LEVELS[2]; if (count <= 30) return COLOR_LEVELS[3]; return COLOR_LEVELS[4]; } function renderGraph() { // SVGのクリア (再描画用) svg.innerHTML = ''; const daysInWeek = 7; let currentWeek = 0; let prevMonth = -1; // 月ラベル表示用 // グラフの左側に曜日ラベルを追加 WEEK_DAY_LABELS.forEach((label, index) => { if (index === 1 || index === 3 || index === 5) { // 月、水、金のみ表示 (任意) const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', -25); // SVGの座標 text.setAttribute('y', index * (CELL_SIZE + CELL_GAP) + CELL_SIZE / 2 + 5); // 縦位置調整 text.setAttribute('text-anchor', 'middle'); text.setAttribute('class', 'day-label hidden-mobile'); // スマートフォンでは非表示 text.textContent = label; svg.appendChild(text); } }); // 描画開始位置のオフセット (曜日ラベルや月ラベルのスペース) const offsetX = 40; const offsetY = 20; contributionData.forEach((dayData, index) => { const date = new Date(dayData.date); const dayOfWeek = date.getDay(); // 0: 日曜日, 6: 土曜日 const month = date.getMonth(); // 週の開始(日曜日)の場合、または最初の要素の場合 if (dayOfWeek === 0 && index !== 0) { currentWeek++; } // 月が変わったら月ラベルを追加 if (month !== prevMonth) { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); // 月ラベルのX座標は、その月の最初の週の真ん中あたりに配置 // Y座標はグラフの一番上 text.setAttribute('x', offsetX + currentWeek * (CELL_SIZE + CELL_GAP) + (CELL_SIZE + CELL_GAP) * 2); text.setAttribute('y', offsetY - 5); text.setAttribute('class', 'month-label'); text.textContent = MONTH_LABELS[month]; svg.appendChild(text); prevMonth = month; } const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('width', CELL_SIZE); rect.setAttribute('height', CELL_SIZE); // X座標: 週のインデックス * (セルサイズ + ギャップ) + オフセット rect.setAttribute('x', offsetX + currentWeek * (CELL_SIZE + CELL_GAP)); // Y座標: 曜日のインデックス * (セルサイズ + ギャップ) + オフセット rect.setAttribute('y', offsetY + dayOfWeek * (CELL_SIZE + CELL_GAP)); rect.setAttribute('fill', getContributionColor(dayData.count)); rect.setAttribute('rx', 2); // 角丸 rect.setAttribute('ry', 2); // 角丸 // ツールチップ表示用のイベントリスナー rect.addEventListener('mouseenter', (event) => { const rectX = parseFloat(rect.getAttribute('x')); const rectY = parseFloat(rect.getAttribute('y')); tooltip.textContent = `${dayData.count} contributions on ${dayData.date}`; // SVG要素に対する相対座標から、画面に対する絶対座標に変換 const svgRect = svg.getBoundingClientRect(); const containerRect = svg.parentElement.getBoundingClientRect(); // graph-container // マス目の中心にツールチップを合わせる const tooltipX = containerRect.left + (rectX + CELL_SIZE / 2) + window.scrollX; const tooltipY = containerRect.top + (rectY - CELL_SIZE / 2) + window.scrollY - tooltip.offsetHeight - 5; // ツールチップの上部に表示 tooltip.style.left = `${tooltipX}px`; tooltip.style.top = `${tooltipY}px`; tooltip.style.opacity = 1; }); rect.addEventListener('mouseleave', () => { tooltip.style.opacity = 0; }); svg.appendChild(rect); }); // SVGのサイズをコンテンツに合わせて調整 const totalWeeks = currentWeek + 1; const svgWidth = offsetX + totalWeeks * (CELL_SIZE + CELL_GAP) + CELL_SIZE; const svgHeight = offsetY + daysInWeek * (CELL_SIZE + CELL_GAP) + CELL_SIZE; svg.setAttribute('width', svgWidth); svg.setAttribute('height', svgHeight); svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); // viewboxを設定すると拡大縮小時に便利 } renderGraph();

ハマった点やエラー解決

  • SVGの座標計算: SVGのxyは左上隅を基準とするため、マス目をグリッド状に配置するには、週のインデックスと曜日のインデックスに基づいた適切な計算が必要です。特に、月や曜日のラベルの配置は、全体のオフセットや各セルのサイズを考慮して調整する必要がありました。
  • 日付の処理: JavaScriptのDateオブジェクトは非常に便利ですが、日付の加算・減算や特定の曜日を取得する際には注意が必要です。getDay()で0(日曜日)から6(土曜日)のインデックスが返されることを利用し、週の開始(日曜日)を起点にマス目を並べていきました。
  • 色分けロジック: 活動量に応じた色の閾値をどう設定するか悩みました。GitHubのように5段階の色分けを行うために、活動量の上限値を仮定し、それを均等に分割する、あるいは活動量の分布を考慮して非線形に設定するなど、微調整が必要でした。
  • ツールチップの配置: SVG要素にマウスイベントを設定し、HTML要素であるツールチップを正確な位置に表示するのが少し複雑でした。SVG内の座標から、HTML要素が配置されているページの絶対座標に変換する必要があり、getBoundingClientRect()window.scrollX/Yを組み合わせて計算しました。

解決策

  • 座標計算: offsetXoffsetYでグラフ全体のパディングを設け、各セルのx座標は週インデックス * (CELL_SIZE + CELL_GAP)y座標は曜日インデックス * (CELL_SIZE + CELL_GAP)という単純な計算式で実装しました。これにより、各マスが正確な位置に配置されます。
  • 日付の処理: new Date(today).setDate(today.getDate() - i)のように新しいDateオブジェクトを生成して日付操作を行うことで、元のtodayオブジェクトが変更されないようにしました。これにより、ループ内での意図しない副作用を防ぎました。
  • 色分け: getContributionColor関数で、0, 1-5, 6-15, 16-30, 31以上の5段階の閾値を設定し、それぞれに対応する色を返すようにしました。これにより、活動量に応じて直感的な色分けが実現しました。
  • ツールチップの配置: マス目のSVG rect 要素のmouseenterイベントで、そのrectのSVG座標を取得し、svg.getBoundingClientRect()rectの座標を組み合わせて、HTMLのツールチップ要素のlefttopスタイルを動的に設定しました。pointer-events: none;をツールチップに設定することで、ツールチップ自体がマウスイベントを遮らないようにしました。

まとめ

本記事では、JavaScriptとSVGを用いてGitHub風のContributionグラフを自作する方法を解説しました。

  • データ構造の重要性: 日付ごとの活動量データが、グラフ描画の基盤となることを理解しました。
  • JavaScriptとSVGによる描画の柔軟性: rect要素を使ったマス目の描画、text要素によるラベル追加、イベントリスナーによるインタラクティブなツールチップ表示など、Web標準技術で多様な可視化が可能であることを学びました。
  • カスタマイズの可能性: 色のテーマ、マス目のサイズ、期間などを簡単に変更できるため、独自のデータ可視化ツールとして応用できる基礎を築きました。

この記事を通して、皆さんがGitHub風グラフの自作方法を習得し、データ可視化の基礎概念を理解できたことを願っています。今後は、APIから実際の活動データを取得して表示する方法や、グラフにアニメーションを追加したり、よりインタラクティブなフィルター機能などを実装する発展的な内容についても記事にする予定です。

参考資料