デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
売上の内訳を、分類ごとの面積の四角で並べます。上のタブ(今月・先月・前年同月)を押すと、四角が新しい面積と位置へ滑って組み替わり、中の割合と右上の合計も数えながら変わります。並び順は月ごとに大きい順で決め直すので、順位が入れ替わった分類は別の場所へ移ります。棒の高さや輪の長さで比べるグラフと違い、全体を 1 枚の面に割って見せるので、どの分類がどれだけの場所を占めているかが一目でわかります。
並べ方は squarify という計算で決めます。値の大きい順に、入れ物の短い辺に沿って 1 列ずつ詰め、列に次の四角を足すと縦横の比が悪くなるところで次の列へ移ります。細長い四角ができにくいので、中に名前と割合を置けます。位置と大きさは入れ物に対する % で書くので、画面の幅が少し変わっても比は保たれ、入れ物の大きさが変わったときだけ計算し直します。
組み替えは Flip プラグインで作ります。押す前の見た目を Flip.getState で覚え、新しい位置を書いてから Flip.from で 0.6 秒補間します。大きさは scale ではなく幅と高さで補間するので、中の文字がつぶれません。組み替えの途中で別のタブを押しても、その時点の見た目から次の並びへ向かい直します。四角の中の名前と割合は、CSS のコンテナクエリで、幅 88px か高さ 64px を下回ると隠すので、補間の途中で細くなった四角からも文字がはみ出しません。
値は分類ごと・月ごとの表としてページに書き、スクリプトはその表を読んで四角を組みます。組んだあとの表は見えなくするだけにして、読み上げに残します。タブは button 要素で、選んでいる月を aria-pressed で伝え、切り替えた月と合計を見えない status に書きます。動きを減らす設定では、組み替えずにその場で次の並びへ替えます。
つまみは、組み替えの時間、四角のすき間(4px)、文字を隠す幅と高さです。分類が 8 つを超えると小さな四角ばかりになって名前が隠れるので、細かいものは「その他」にまとめます。色は分類ごとに明るさの段をずらし、明るい四角には暗い文字、暗い四角には明るい文字を載せています。売上やアクセスの内訳、予算の配分を、月や年で見比べる節に向いています。
コード
<figure class="tree">
<figcaption class="tree__caption">
<span class="tree__eyebrow">Sales mix</span>
<span class="tree__title">分類ごとの売上の内訳</span>
<span class="tree__note"><span>2026年9月時点・</span><span>オンラインストアの月の売上・</span><span>単位:万円</span></span>
</figcaption>
<div class="tree__side">
<!-- タブの並びは、表の列(今月・先月・前年同月)の並びと同じにする -->
<div class="tree__tabs" role="group" aria-label="表示する月">
<span class="tree__knob" aria-hidden="true"></span>
<button class="tree__tab" type="button" aria-pressed="true">今月</button>
<button class="tree__tab" type="button" aria-pressed="false">先月</button>
<button class="tree__tab" type="button" aria-pressed="false">前年同月</button>
</div>
<p class="tree__total"><span class="tree__total-label">合計</span> <strong class="tree__sum">2,510万円</strong></p>
</div>
<!-- 四角はスクリプトが表から組む。並びは面積の計算(squarify)で決める -->
<ul class="tree__tiles" aria-hidden="true"></ul>
<p class="tree__status" role="status"></p>
<!-- 値はこの表が正。行が分類、列がタブ -->
<table class="tree__table">
<caption>分類ごとの売上(万円)</caption>
<thead>
<tr><th scope="col">分類</th><th scope="col">今月</th><th scope="col">先月</th><th scope="col">前年同月</th></tr>
</thead>
<tbody>
<tr><th scope="row">家具</th><td>820</td><td>640</td><td>700</td></tr>
<tr><th scope="row">照明</th><td>460</td><td>380</td><td>300</td></tr>
<tr><th scope="row">寝具</th><td>380</td><td>520</td><td>260</td></tr>
<tr><th scope="row">ギフト</th><td>70</td><td>110</td><td>40</td></tr>
<tr><th scope="row">雑貨</th><td>340</td><td>300</td><td>420</td></tr>
<tr><th scope="row">キッチン</th><td>280</td><td>360</td><td>220</td></tr>
<tr><th scope="row">グリーン</th><td>160</td><td>120</td><td>240</td></tr>
</tbody>
</table>
</figure>
.tree {
--bg: #111111;
--ink: #eeece6;
--mute: #898989;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgb(238 236 230 / 0.14);
--gap: 4px; /* 四角どうしのすき間 */
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px 32px;
overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
margin: 0;
padding: 48px clamp(16px, 5vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.tree > :not(.tree__caption, .tree__side) { grid-column: 1 / -1; }
.tree__caption { display: grid; gap: 10px; }
.tree__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.tree__title { font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.tree__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.tree__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */
/* タブ。罫線の枠の中を、明るいつまみが滑る */
.tree__side { display: grid; gap: 16px; align-content: end; justify-items: end; }
.tree__tabs { position: relative; display: flex; padding: 4px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); }
.tree__knob { position: absolute; top: 4px; bottom: 4px; left: 0; border-radius: 999px; background: var(--ink); }
.tree__tab { position: relative; padding: 0 20px; height: 36px; border: 0; border-radius: 999px; background: none; color: var(--mute-small); font: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; cursor: pointer; transition: color 0.45s; }
.tree__tab[aria-pressed="true"] { color: var(--bg); }
.tree__tab:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.tree__total { margin: 0; color: var(--mute); font-size: 16px; letter-spacing: 0.04em; white-space: nowrap; }
.tree__sum { color: var(--ink); }
/* 四角。位置と大きさは入れ物に対する %。すき間は透明な縁で作る */
.tree__tiles { position: relative; aspect-ratio: 1088 / 432; margin: 0; padding: 0; list-style: none; }
.tree__tile { position: absolute; box-sizing: border-box; overflow: hidden; border: calc(var(--gap) / 2) solid transparent; border-radius: 4px; /* 縁の 2px を含むので、見える角は 2px */ background: var(--tile) padding-box; color: var(--tile-ink, var(--bg)); container-type: size; }
.tree__tile:nth-child(1) { --tile: #d9ba92; } /* グレージュ */
.tree__tile:nth-child(2) { --tile: #8dadc4; } /* スレート */
.tree__tile:nth-child(3) { --tile: #958ab2; } /* ラベンダーグレー */
.tree__tile:nth-child(4) { --tile: #748c9b; } /* 墨のスレート */
.tree__tile:nth-child(5) { --tile: #5c705a; } /* セージ */
.tree__tile:nth-child(6) { --tile: #714D49; } /* くすみローズ */
.tree__tile:nth-child(7) { --tile: #504D2D; } /* オリーブグレー */
.tree__tile:nth-child(n + 5) { --tile-ink: var(--ink); } /* 暗い四角の文字は明るくする */
.tree__label { position: absolute; top: 14px; left: 14px; display: grid; gap: 8px; white-space: nowrap; transition: opacity 0.2s; }
.tree__name { font-size: 15px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; }
.tree__share { font-size: 24px; font-weight: 800; letter-spacing: -0.01em; line-height: 1; }
/* 狭くなった四角では、名前と割合を隠す(組み替えの途中でも効く) */
@container (width < 88px) or (height < 64px) {
.tree__label { opacity: 0; }
}
/* 狭い画面では、タブを見出しの下に置き、四角の入れ物を縦長にする */
@media (max-width: 640px) {
.tree { grid-template-columns: minmax(0, 1fr); }
.tree__side { justify-items: start; }
.tree__tiles { aspect-ratio: 4 / 5; }
}
@media (prefers-reduced-motion: reduce) {
.tree__tab, .tree__label { transition: none; }
}
/* 読み上げだけに届ける。表は、スクリプトが四角を組むまで見えたまま残す */
.tree__status,
.tree.is-live .tree__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tree__table { width: 100%; border-spacing: 0; }
.tree__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.tree__table th, .tree__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.tree__table th:first-child { text-align: left; }
.tree__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
gsap.registerPlugin(Flip);
// 動きを減らす設定では、組み替えずにその場で次の並びへ替える
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const sum = (list) => list.reduce((a, b) => a + b, 0);
const make = (tag, className, parent) => parent.appendChild(Object.assign(document.createElement(tag), { className }));
// squarify: 大きい順に並べ、短い辺に沿って 1 列ずつ詰める。列に足すと縦横の比が悪くなるところで次の列へ移る
function squarify(values, width, height) {
const scale = (width * height) / sum(values);
const rest = values.map((v, i) => ({ i, a: v * scale })).sort((p, q) => q.a - p.a);
const worst = (row, side, s = sum(row.map((r) => r.a))) => Math.max(...row.map((r) => Math.max((side * side * r.a) / (s * s), (s * s) / (side * side * r.a))));
const rects = [];
let [x, y, w, h] = [0, 0, width, height];
while (rest.length > 0) {
const side = Math.min(w, h);
let n = 1;
while (n < rest.length && worst(rest.slice(0, n + 1), side) <= worst(rest.slice(0, n), side)) n++;
const row = rest.splice(0, n);
const across = w >= h; // 横長の残りなら左に縦の列を、縦長の残りなら上に横の列を置く
const thick = sum(row.map((r) => r.a)) / (across ? h : w);
let offset = 0;
for (const r of row) {
const long = r.a / thick;
rects[r.i] = across ? { x, y: y + offset, w: thick, h: long } : { x: x + offset, y, w: long, h: thick };
offset += long;
}
if (across) [x, w] = [x + thick, w - thick];
else [y, h] = [y + thick, h - thick];
}
return rects;
}
document.querySelectorAll(".tree").forEach((chart) => {
const list = chart.querySelector(".tree__tiles");
const tabs = [...chart.querySelectorAll(".tree__tab")];
const knob = chart.querySelector(".tree__knob");
const sumText = chart.querySelector(".tree__sum");
const status = chart.querySelector(".tree__status");
const label = chart.querySelector(".tree__total-label").textContent;
const unit = sumText.textContent.replace(/[\d,.\s]/g, ""); // 金額の単位(万円)は HTML から読む
// 値は表が正。行が分類、列がタブ
const rows = [...chart.querySelectorAll("tbody tr")];
const months = tabs.map((_, k) => rows.map((row) => Number(row.cells[k + 1].textContent.replace(/[^\d.]/g, ""))));
const tiles = rows.map((row, i) => {
const el = make("li", "tree__tile", list);
const text = make("span", "tree__label", el);
make("span", "tree__name", text).textContent = row.cells[0].textContent;
return { el, share: make("span", "tree__share", text), v: months[0][i] };
});
const els = tiles.map((tile) => tile.el);
const total = { v: sum(months[0]) };
chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す
// 割合と合計を、数えている途中の値どうしで書く
const write = () => {
for (const tile of tiles) tile.share.textContent = `${Math.round((tile.v / total.v) * 100)}%`;
sumText.textContent = `${Math.round(total.v).toLocaleString("ja-JP")}${unit}`;
};
// 今の入れ物の大きさで計算し、% で書く(入れ物の幅が変わっても比は保たれる)
const place = (k) => {
const { width, height } = list.getBoundingClientRect();
squarify(months[k], width, height).forEach((r, i) => {
Object.assign(els[i].style, { left: `${(r.x / width) * 100}%`, top: `${(r.y / height) * 100}%`, width: `${(r.w / width) * 100}%`, height: `${(r.h / height) * 100}%` });
});
};
const knobTo = (k, duration) => gsap.to(knob, { x: tabs[k].offsetLeft, width: tabs[k].offsetWidth, duration, ease: "power3.inOut", overwrite: true });
let current = 0;
let flip = null;
const show = (k) => {
if (k === current) return;
current = k;
tabs.forEach((tab, i) => tab.setAttribute("aria-pressed", String(i === k)));
knobTo(k, reduceMotion ? 0 : 0.45);
// 今の見た目(組み替えの途中でも)を覚えてから並べ直し、そこから新しい位置と大きさへ補間する
const state = Flip.getState(els);
flip?.kill();
place(k);
if (!reduceMotion) flip = Flip.from(state, { duration: 0.6, ease: "power3.inOut", delay: 0.08 });
gsap.to(tiles, { v: (i) => months[k][i], duration: reduceMotion ? 0 : 0.6, ease: "power3.inOut", delay: reduceMotion ? 0 : 0.08, overwrite: true, onUpdate: write });
gsap.to(total, { v: sum(months[k]), duration: reduceMotion ? 0 : 0.6, ease: "power3.inOut", delay: reduceMotion ? 0 : 0.08, overwrite: true, onUpdate: write });
status.textContent = `${tabs[k].textContent}・${label} ${sum(months[k]).toLocaleString("ja-JP")}${unit}`;
};
tabs.forEach((tab, k) => tab.addEventListener("click", () => show(k)));
// 入れ物やタブの大きさが変わったら(書体の読み込み・画面の回転)、その場で並べ直す
const observer = new ResizeObserver(() => {
flip?.progress(1);
place(current);
knobTo(current, 0);
});
[list, knob.parentElement].forEach((el) => observer.observe(el));
write();
});