本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
グラフ

1 年分のカレンダーのマス目が順に色づくグラフ

デモ

仕様

用途
グラフ
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

曜日を行、週を列にしたマス目の上を、明るい帯が左上から右へ斜めに走ります。帯が通り過ぎたマスは、その週のその曜日の件数に応じた濃さのスレート(青みの灰色)に落ち着くので、格子全体がひと筆書きのように塗られていきます。

帯は列がひとつ進むごとに0.03秒、行がひとつ下がるごとにその1.2列ぶんの遅れを足して動くので、ゆるやかな斜めの筋になります。帯の色でいる時間は0.16秒、そこから件数ごとの色に落ち着くまでは0.5秒です。ずらす間隔を広げると波はより寝て見えますが、全体が塗り終わるまでの時間ものびます。

件数と週の始まりの日付は、格子の下に添えた表の値をそのまま使います。値を書く場所をこの表ひとつにまとめておくと、あとで数を直しても格子とずれません。空のマスの色(--empty)と帯の色(--flash)はCSSの変数に持たせ、getComputedStyleで読んでからGSAPに渡します。文字列の変数名のまま渡すと、色を補間できずに塗りが動きません。

色は 1 つの色相の段にして、少ない日ほど淡く、多い日ほど濃くします。色相を変えずに明るさだけで段を付けると、色の見え方の違いがあっても多い少ないが読めます。波の速さや傾き、件数を色に変える境目の値は、どれもスクリプトの先頭でまとめて変えられます。画面に入ったときに一度だけ塗り、動きを減らす設定では最初から塗りきった状態で置きます。活動量や投稿数、売上のように、日ごとの分布を一覧で見せたいダッシュボードに向いています。

コード

HTML
<figure class="heatmap">
  <figcaption class="heatmap__caption">
    <span class="heatmap__eyebrow">Activity</span>
    <span class="heatmap__title">週ごとに<wbr>完了した<wbr>タスクの数</span>
  </figcaption>

  <div class="heatmap__body">
    <!-- 見た目の格子。色は下の表の値から JS が付ける -->
    <div class="heatmap__grid" aria-hidden="true"></div>

    <!-- 値はこの表が正。週の始まり(月/日)と、月〜日の 7 つの件数 -->
    <div class="heatmap__scroll">
      <table class="heatmap__table">
        <caption>週ごとの完了タスク数(月曜始まり)</caption>
        <thead>
          <tr><th scope="col">週</th><th scope="col">月</th><th scope="col">火</th><th scope="col">水</th><th scope="col">木</th><th scope="col">金</th><th scope="col">土</th><th scope="col">日</th></tr>
        </thead>
        <tbody>
          <tr><th scope="row">7/6</th><td>4</td><td>6</td><td>3</td><td>7</td><td>5</td><td>1</td><td>0</td></tr>
          <tr><th scope="row">7/13</th><td>2</td><td>5</td><td>8</td><td>4</td><td>6</td><td>0</td><td>1</td></tr>
          <tr><th scope="row">7/20</th><td>6</td><td>3</td><td>5</td><td>9</td><td>7</td><td>2</td><td>0</td></tr>
          <tr><th scope="row">7/27</th><td>5</td><td>7</td><td>4</td><td>6</td><td>8</td><td>1</td><td>0</td></tr>
          <tr><th scope="row">8/3</th><td>3</td><td>6</td><td>7</td><td>5</td><td>9</td><td>0</td><td>2</td></tr>
          <tr><th scope="row">8/10</th><td>7</td><td>8</td><td>6</td><td>10</td><td>9</td><td>2</td><td>1</td></tr>
          <tr><th scope="row">8/17</th><td>6</td><td>9</td><td>8</td><td>11</td><td>10</td><td>3</td><td>1</td></tr>
          <tr><th scope="row">8/24</th><td>8</td><td>7</td><td>9</td><td>6</td><td>8</td><td>2</td><td>0</td></tr>
          <tr><th scope="row">8/31</th><td>4</td><td>6</td><td>5</td><td>7</td><td>6</td><td>1</td><td>0</td></tr>
          <tr><th scope="row">9/7</th><td>5</td><td>4</td><td>6</td><td>8</td><td>7</td><td>2</td><td>1</td></tr>
          <tr><th scope="row">9/14</th><td>3</td><td>5</td><td>4</td><td>6</td><td>5</td><td>0</td><td>1</td></tr>
          <tr><th scope="row">9/21</th><td>2</td><td>4</td><td>3</td><td>5</td><td>4</td><td>1</td><td>0</td></tr>
        </tbody>
      </table>
    </div>
  </div>
</figure>

CSS
.heatmap {
  --ink: #262626;
  --mute: #6b6b6b;
  --surface: #ffffff;
  --line: rgba(38, 38, 38, 0.12);
  --cell: 18px;   /* マスの大きさ */
  --gap: 3px;     /* マスの間 */
  --empty: #ecebe7;  /* 0 件のマスの色 */
  --flash: #b8c7d3;  /* 波が通っているあいだの色 */
  --level-1: #c8d3dd; /* 1〜3 件 */
  --level-2: #91a9ba; /* 4〜6 件 */
  --level-3: #62839D; /* 7〜9 件 */
  --level-4: #3e4f59; /* 10 件以上 */
  margin: 0;
  padding: 32px;
  background: var(--surface);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.heatmap__caption { display: grid; gap: 6px; margin-bottom: 24px; }
.heatmap__eyebrow { font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.heatmap__title { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; word-break: keep-all; }

.heatmap__body { display: grid; gap: 20px; }

/* 週(列)ごとに縦へ積む。曜日(行)の順は表と同じにする */
.heatmap__grid {
  display: grid;
  grid-auto-flow: column;
  grid-template-rows: repeat(7, var(--cell));
  grid-auto-columns: var(--cell);
  gap: var(--gap);
}

.heatmap__cell { border-radius: 3px; background: var(--empty); }

.heatmap__scroll { overflow-x: auto; }

.heatmap__table {
  border-collapse: separate;
  border-spacing: 0;
  font-size: 12px;
  white-space: nowrap;
}

.heatmap__table caption {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.heatmap__table th,
.heatmap__table td {
  padding: 4px 8px;
  text-align: right;
  border-top: 1px solid var(--line);
  color: var(--mute);
}

.heatmap__table thead th {
  border-top: 0;
  padding-bottom: 6px;
  font-weight: 600;
  letter-spacing: 0.04em;
}

.heatmap__table th[scope="row"] {
  text-align: left;
  color: var(--ink);
  font-weight: 600;
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const EACH = 0.03; // 1 列(週)ぶんの遅れ
const SLANT = 1.2; // 1 段下がるごとに、何列ぶん遅れるか(波の傾き)
const FLASH_T = 0.16; // 波の帯の色になっている時間
const SETTLE = 0.5; // 帯の色から件数の色に落ち着くまで
const levelOf = (n) => (n === 0 ? 0 : n <= 3 ? 1 : n <= 6 ? 2 : n <= 9 ? 3 : 4);

document.querySelectorAll(".heatmap").forEach((chart) => {
  const style = getComputedStyle(chart);
  // 色は CSS の変数が正。GSAP は var(--x) のままでは補間できないので、値にして読む
  const empty = style.getPropertyValue("--empty").trim();
  const flash = style.getPropertyValue("--flash").trim();
  const levels = [1, 2, 3, 4].map((k) => style.getPropertyValue(`--level-${k}`).trim());
  levels.unshift(empty);

  const grid = chart.querySelector(".heatmap__grid");
  const days = [...chart.querySelectorAll("thead th")].slice(1).map((th) => th.textContent);
  const weeks = [...chart.querySelectorAll("tbody tr")].map((row) => ({
    date: row.querySelector("th").textContent,
    counts: [...row.querySelectorAll("td")].map((td) => Number(td.textContent)),
  }));

  const cells = [];
  weeks.forEach(({ date, counts }, col) => {
    counts.forEach((n, row) => {
      const el = document.createElement("div");
      el.className = "heatmap__cell";
      el.title = `${date}(${days[row]}): ${n}件`;
      grid.append(el);
      cells.push({ el, col, row, n });
    });
  });

  const targets = cells.map((c) => c.el);
  const delay = (i) => (cells[i].col + cells[i].row * SLANT) * EACH;
  const colorOf = (i) => levels[levelOf(cells[i].n)];

  const tl = gsap
    .timeline({ paused: true })
    .fromTo(targets, { backgroundColor: empty }, { backgroundColor: flash, duration: FLASH_T, ease: "none", stagger: delay })
    .fromTo(targets, { backgroundColor: flash }, { backgroundColor: colorOf, duration: SETTLE, ease: "power2.out", stagger: delay, immediateRender: false }, FLASH_T);

  // 動きを減らす設定では、色づききった状態で見せる
  if (reduceMotion) {
    tl.progress(1);
    return;
  }

  // 画面に入ったら 1 回だけ色づける
  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    io.disconnect();
    tl.play();
  }, { rootMargin: "0px 0px -20%" });
  io.observe(chart);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。