デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
曜日を行、週を列にしたマス目の上を、明るい帯が左上から右へ斜めに走ります。帯が通り過ぎたマスは、その週のその曜日の件数に応じた濃さのスレート(青みの灰色)に落ち着くので、格子全体がひと筆書きのように塗られていきます。
帯は列がひとつ進むごとに0.03秒、行がひとつ下がるごとにその1.2列ぶんの遅れを足して動くので、ゆるやかな斜めの筋になります。帯の色でいる時間は0.16秒、そこから件数ごとの色に落ち着くまでは0.5秒です。ずらす間隔を広げると波はより寝て見えますが、全体が塗り終わるまでの時間ものびます。
件数と週の始まりの日付は、格子の下に添えた表の値をそのまま使います。値を書く場所をこの表ひとつにまとめておくと、あとで数を直しても格子とずれません。空のマスの色(--empty)と帯の色(--flash)はCSSの変数に持たせ、getComputedStyleで読んでからGSAPに渡します。文字列の変数名のまま渡すと、色を補間できずに塗りが動きません。
色は 1 つの色相の段にして、少ない日ほど淡く、多い日ほど濃くします。色相を変えずに明るさだけで段を付けると、色の見え方の違いがあっても多い少ないが読めます。波の速さや傾き、件数を色に変える境目の値は、どれもスクリプトの先頭でまとめて変えられます。画面に入ったときに一度だけ塗り、動きを減らす設定では最初から塗りきった状態で置きます。活動量や投稿数、売上のように、日ごとの分布を一覧で見せたいダッシュボードに向いています。
コード
<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>
.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;
}
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);
});