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

散布図のグラフの点が中心から散って定位置に収まり、重ねると値が出る

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
グラフ
動作
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 つの量を一度に見せたいレポートや、店舗・商品の位置づけを見比べる節に向いています。

コード

HTML
<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>

CSS
.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; }

JavaScript
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);
  });
});

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

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