デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
12 個の円が、中央の 1 点から、左の円の順に飛び出して、客単価(横)とリピート率(縦)の座標へ収まります。円の大きさは 3 つ目の量(会員数)で、面積が量に比例するよう、半径は平方根で決めています。直径を量に比例させると、大きい円だけが極端に目立ちます。
散らばりは 0.9 秒の power3.out で、隣の円とは 0.06 秒ずらします。大きさは back.out で少し行き過ぎてから止まるので、収まった瞬間に軽く弾みます。位置と半径は整数に丸めています。円の縁が半端な位置にあると、描き直しのたびに縁の 1 画素が変わります。
円に重ねると、その円が 3 割だけ膨らんで吹き出しが開き、同じ系列の円だけが濃く残ります。ほかの系列は濃さ 28% まで淡くするので、系列ごとの広がりを見比べられます。系列の色は明るさを濃い・中間・淡いに離し、淡い円には一段濃い縁を引いて、明るい地の上でも形が分かるようにしています。円から円へ素早く移るので、どのトゥイーンも overwrite を auto にして、前の行き先へ向かう動きを止めてください。止めないと、膨らんだままの円が残ります。
吹き出しは円ごとに 1 つ置き、重ねた円のものだけを開きます。1 つを使い回すと、別の円へ移るときに中身と位置が飛びます。画面の外へ出ないよう左右を寄せ、矢印だけが円を指し続けるようにします。上に入らない円は、吹き出しを下へ回します。
値は表としてページに書き、スクリプトはその表を読んで円を描きます。表は見えなくするだけにして読み上げに残し、枠は焦点を受けて左右の矢印キーで円を移れるようにします。つまみは、散らばりの時間・円どうしのずれ・半径の下限と上限です。3 つの量を一度に見せたいレポートや、店舗・商品の位置づけを見比べる節に向いています。
コード
<figure class="dots" data-x-min="700" data-x-max="3400" data-y-max="70" data-r-max="1560">
<figcaption class="dots__caption">
<span class="dots__eyebrow">Store mix</span>
<span class="dots__title">客単価とリピート率</span>
<span class="dots__note"><span>2026年9月・店舗別・</span><span>円の大きさは会員数</span></span>
</figcaption>
<!-- 目盛りは HTML に置き、円と吹き出しだけを JS が作る。枠は焦点を受け、矢印キーで店舗を移れる -->
<p class="dots__unit">リピート率(%)</p>
<div class="dots__plot">
<div class="dots__grid" tabindex="0" role="group" aria-label="店舗別の客単価とリピート率の散布図。左右の矢印キーで店舗を移れます">
<svg class="dots__svg" aria-hidden="true"></svg>
<ul class="dots__y" aria-hidden="true">
<li style="--v: calc(60 / 70)">60</li>
<li style="--v: calc(40 / 70)">40</li>
<li style="--v: calc(20 / 70)">20</li>
<li style="--v: 0">0</li>
</ul>
<ul class="dots__x" aria-hidden="true">
<li style="--u: calc((1000 - 700) / 2700)">1,000</li>
<li style="--u: calc((1500 - 700) / 2700)">1,500</li>
<li style="--u: calc((2000 - 700) / 2700)">2,000</li>
<li style="--u: calc((2500 - 700) / 2700)">2,500</li>
<li style="--u: calc((3000 - 700) / 2700)">3,000</li>
</ul>
</div>
</div>
<p class="dots__unit dots__unit--x">客単価(円)</p>
<!-- 円の色(--color)と縁の色(--edge。無ければ --color)は、この凡例から読む -->
<ul class="dots__legend">
<li style="--color: #35607c">都心</li>
<li style="--color: #d6584d">住宅街</li>
<li style="--color: #dcb88f; --edge: #c68b43">駅前</li>
</ul>
<p class="dots__status" role="status"></p>
<!-- 値はこの表が正。円の座標と大きさは、ここから読む -->
<table class="dots__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></tr>
</thead>
<tbody>
<tr><th scope="row">A店</th><td>駅前</td><td>920</td><td>24</td><td>1,480</td></tr>
<tr><th scope="row">B店</th><td>駅前</td><td>1,180</td><td>31</td><td>620</td></tr>
<tr><th scope="row">C店</th><td>駅前</td><td>1,320</td><td>26</td><td>340</td></tr>
<tr><th scope="row">D店</th><td>駅前</td><td>1,620</td><td>37</td><td>1,120</td></tr>
<tr><th scope="row">E店</th><td>住宅街</td><td>1,840</td><td>33</td><td>480</td></tr>
<tr><th scope="row">F店</th><td>住宅街</td><td>2,040</td><td>42</td><td>940</td></tr>
<tr><th scope="row">G店</th><td>住宅街</td><td>2,260</td><td>36</td><td>260</td></tr>
<tr><th scope="row">H店</th><td>住宅街</td><td>2,420</td><td>46</td><td>700</td></tr>
<tr><th scope="row">I店</th><td>都心</td><td>2,680</td><td>51</td><td>1,240</td></tr>
<tr><th scope="row">J店</th><td>都心</td><td>2,940</td><td>44</td><td>380</td></tr>
<tr><th scope="row">K店</th><td>都心</td><td>3,080</td><td>58</td><td>860</td></tr>
<tr><th scope="row">L店</th><td>都心</td><td>3,260</td><td>49</td><td>540</td></tr>
</tbody>
</table>
</figure>
.dots {
--bg: #f2f1ed;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgb(38 38 38 / 0.12);
--height: 320px; /* グラフの高さ */
position: relative;
display: grid;
gap: 24px;
overflow: clip; /* 読み上げ用に隠した表が、狭い画面で横にはみ出さないように */
margin: 0;
padding: 48px clamp(16px, 5vw, 96px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.dots__caption { display: grid; gap: 10px; }
.dots__eyebrow { color: var(--mute-small); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.dots__title { font-size: clamp(28px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.dots__note { color: var(--mute); font-size: 16px; letter-spacing: 0.04em; }
.dots__note span { display: inline-block; } /* 狭い画面でも、語の途中で折り返さない */
/* 左に目盛り、下に客単価を置く余白 */
.dots__plot { padding-left: 44px; }
.dots__grid {
position: relative;
height: var(--height);
/* 横の目盛りの線。上端が 70% にあたるので、20% ごとは 28.571% */
background-image: repeating-linear-gradient(to top, var(--line) 0 1px, transparent 1px 28.571%);
}
.dots__grid:focus-visible { outline: 2px solid var(--ink); outline-offset: 6px; }
.dots__svg { display: block; width: 100%; height: 100%; }
.dots__svg circle { fill-opacity: 0.82; stroke-width: 1.5; }
.dots__y,
.dots__x { margin: 0; padding: 0; color: var(--mute-small); font-size: 13px; list-style: none; }
.dots__y li { position: absolute; right: calc(100% + 10px); bottom: calc(var(--v) * 100%); transform: translateY(50%); }
.dots__x li { position: absolute; top: 100%; left: calc(var(--u) * 100%); transform: translate(-50%, 10px); white-space: nowrap; }
/* 縦の目盛りの線は、月の札から上へ伸ばす */
.dots__x li::before { content: ""; position: absolute; bottom: 100%; left: 50%; width: 1px; height: var(--height); margin-bottom: 10px; background: var(--line); }
.dots__unit { margin: 0; color: var(--mute-small); font-size: 13px; letter-spacing: 0.04em; }
/* 月の札は枠の下にはみ出しているので、その分だけ下げる */
.dots__unit--x { margin-top: 28px; text-align: right; }
.dots__tip {
position: absolute;
z-index: 1;
width: max-content;
max-width: 236px;
margin: 0;
padding: 9px 14px;
border-radius: 6px;
background: var(--ink);
color: #fff;
font-size: 13px;
line-height: 1.6;
letter-spacing: 0.04em;
text-align: center;
opacity: 0;
pointer-events: none; /* 下の円のホバーを奪わない */
}
.dots__tip b { display: block; font-size: 14px; }
.dots__tip::after {
content: "";
position: absolute;
top: 100%;
left: calc(50% + var(--arrow, 0px));
border: 7px solid transparent;
border-top-color: var(--ink);
transform: translateX(-50%);
}
/* 上に入らない円では、吹き出しを下へ回す */
.dots__tip.is-down::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: var(--ink); }
/* 狭い画面では、グラフを低くして横軸の札を 1 つおきにする */
@media (max-width: 560px) {
.dots { --height: 260px; }
.dots__x li:nth-child(even) { display: none; }
}
.dots__legend { display: flex; flex-wrap: wrap; gap: 8px 24px; margin: 0; padding: 0; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; list-style: none; }
.dots__legend li { display: flex; align-items: center; gap: 10px; }
.dots__legend li::before { content: ""; width: 12px; height: 12px; box-sizing: border-box; border: 1.5px solid var(--edge, var(--color)); border-radius: 50%; background: var(--color); }
/* 読み上げだけに届ける。表は、スクリプトが円を描くまで見えたまま残す */
.dots__status,
.dots.is-live .dots__table { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.dots__table { width: 100%; border-spacing: 0; }
.dots__table caption { padding-bottom: 12px; color: var(--mute-small); font-size: 13px; text-align: left; }
.dots__table th,
.dots__table td { padding: 10px 12px; border-top: 1px solid var(--line); font-size: 14px; text-align: right; }
.dots__table th:first-child,
.dots__table td:first-of-type { text-align: left; }
.dots__table thead th { border: 0; color: var(--mute-small); font-size: 13px; font-weight: 600; }
import { gsap } from "gsap";
// 動きを減らす設定では、散らばりも膨らみも一瞬で終わらせる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds);
const num = (v) => v.toLocaleString("ja-JP");
const value = (cell) => Number(cell.textContent.replace(/[^\d.]/g, ""));
const [R_MIN, R_MAX] = [7, 34]; // 円の半径の下限と上限
document.querySelectorAll(".dots").forEach((chart) => {
const svg = chart.querySelector(".dots__svg");
const grid = chart.querySelector(".dots__grid");
const status = chart.querySelector(".dots__status");
const range = chart.dataset;
// 色は凡例から、値は表から読む(どちらも直す場所が 1 つで済む)。縁の色(--edge)が無い系列は、塗りと同じ色で縁を引く
const colors = new Map([...chart.querySelectorAll(".dots__legend li")].map((li) => { const css = getComputedStyle(li), fill = css.getPropertyValue("--color"); return [li.textContent, { fill, edge: css.getPropertyValue("--edge") || fill }]; }));
const dots = [...chart.querySelectorAll("tbody tr")].map((row) => {
const place = row.cells[1].textContent;
const dot = { name: row.cells[0].textContent, place, price: value(row.cells[2]), rate: value(row.cells[3]), members: value(row.cells[4]) };
dot.el = svg.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "circle"));
dot.el.style.cssText = `fill:${colors.get(place).fill};stroke:${colors.get(place).edge}`;
dot.tip = grid.appendChild(document.createElement("p"));
dot.tip.className = "dots__tip";
dot.tip.innerHTML = `<b>${place}・会員 ${num(dot.members)}人</b>客単価 ${num(dot.price)}円・リピート率 ${dot.rate}%`;
return dot;
});
chart.classList.add("is-live"); // ここから先は、表は読み上げにだけ残す
let played = false;
const layout = () => {
const { width: w, height: h } = grid.getBoundingClientRect();
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
for (const dot of dots) {
dot.x = Math.round(((dot.price - range.xMin) / (range.xMax - range.xMin)) * w);
dot.y = Math.round(h - (dot.rate / range.yMax) * h);
// 面積が会員数に比例するよう、半径は平方根で決める。狭い画面では円どうしが重なるので、幅に合わせて縮める
const scale = Math.max(0.5, Math.min(1, w / 880));
dot.r = Math.round((R_MIN + (R_MAX - R_MIN) * Math.sqrt(dot.members / range.rMax)) * scale);
gsap.set(dot.el, { attr: { cx: dot.x, cy: dot.y, r: dot.r } });
// 吹き出しは円の上、上に入らない円は下に置く。画面の外へ出ないよう左右を寄せ、矢印だけが円を指す
dot.up = dot.y - dot.r - dot.tip.offsetHeight > -24;
dot.tip.classList.toggle("is-down", !dot.up);
const left = Math.min(Math.max(dot.x - dot.tip.offsetWidth / 2, 0), w - dot.tip.offsetWidth);
dot.tip.style.left = `${Math.round(left)}px`;
dot.tip.style.top = `${Math.round(dot.up ? dot.y - dot.r - 10 - dot.tip.offsetHeight : dot.y + dot.r + 10)}px`;
dot.tip.style.setProperty("--arrow", `${Math.round(dot.x - left - dot.tip.offsetWidth / 2)}px`);
gsap.set(dot.tip, { y: dot.up ? 8 : -8 });
// 出てくる前は、中央の 1 点に集めて隠しておく
if (!played) gsap.set(dot.el, { x: w / 2 - dot.x, y: h / 2 - dot.y, scale: 0, transformOrigin: "50% 50%" });
}
};
new ResizeObserver(layout).observe(grid);
// 画面に入ったら 1 回だけ、中央から各自の座標へ散らす
const play = () => {
played = true;
gsap.to(dots.map((dot) => dot.el), { x: 0, y: 0, scale: 1, duration: time(0.9), ease: "power3.out", stagger: time(0.06) });
};
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
io.unobserve(entry.target);
play();
}
}, { rootMargin: "0px 0px -20%" });
io.observe(chart);
let active = -1;
const show = (index) => {
if (index === active) return;
active = index;
dots.forEach((dot, i) => {
const on = i === index;
// 重ねた円は 3 割膨らみ、ほかの系列の円は淡くなる(同じ系列はそのまま)
gsap.to(dot.el, { attr: { r: Math.round(dot.r * (on ? 1.3 : 1)) }, duration: time(on ? 0.4 : 0.3), ease: on ? "back.out(2)" : "power3.out", overwrite: "auto" });
gsap.to(dot.el, { opacity: index < 0 || dot.place === dots[index].place ? 1 : 0.35, duration: time(0.3), overwrite: "auto" });
gsap.to(dot.tip, { opacity: on ? 1 : 0, y: on ? 0 : dot.up ? 8 : -8, duration: time(on ? 0.3 : 0.2), overwrite: "auto" });
});
const dot = dots[index];
status.textContent = dot ? `${dot.name} ${dot.place} 客単価${num(dot.price)}円、リピート率${dot.rate}%、会員${num(dot.members)}人` : "";
};
dots.forEach((dot, i) => {
dot.el.addEventListener("pointerenter", (e) => e.pointerType !== "touch" && show(i));
dot.el.addEventListener("pointerdown", () => show(i)); // 指で触れたときも、その円を出す
});
svg.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && show(-1));
// キーボードでは最初の円から出し、矢印キーで移る。Esc で閉じる
grid.addEventListener("focus", () => grid.matches(":focus-visible") && show(Math.max(active, 0)));
grid.addEventListener("blur", () => show(-1));
grid.addEventListener("keydown", (e) => {
if (e.key === "Escape") return show(-1);
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!step) return;
e.preventDefault();
show(active < 0 ? 0 : (active + step + dots.length) % dots.length);
});
});