デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
折れ線グラフの上でポインタを動かすと、ポインタの横の位置からいちばん近い月を選び、縦の案内線・今期と前期の点・ツールチップをその月へ滑らせます。位置と、ツールチップに出す金額は 1 つのオブジェクトに持ち、gsap.to で今の値から次の月の値へ動かして、動くたびに描き直します。前期比もその金額から計算するので、移るあいだは数字が数えながら切り替わります。
点と案内線は 0.35 秒、ツールチップは 0.5 秒で移ります(どちらも power3.out)。この差のぶん吹き出しが少し遅れてついてくるので、ポインタに引っぱられているように見えます。金額は 0.45 秒で数えます。グラフの外から入ったときは、滑らせずにその月へ置いてから 0.2 秒で出し、外へ出たら 0.2 秒で消します。
月は素早く続けて変わるので、どのトゥイーンも overwrite を auto にして、前のトゥイーンの同じ値を止めます。止めないと、古い行き先へ向かう動きが残り、点が行ったり来たりします。ツールチップは高いほうの点の上に置き、画面の端でははみ出さない所まで寄せて、矢印だけが点を指し続けるようにします。上に入らない高さのときは、低いほうの点の下へ回します。
値は表としてページに書き、スクリプトはその表を読んで線を描きます。描いたあとの表は見えなくするだけにして、読み上げには残します。グラフの枠は焦点を受け、左右の矢印キーで月を移れます。ツールチップには role の status を付け、キーで移った月の値が読み上げられるようにします。狭い画面ではグラフを横にスクロールさせ、キーで選んだ月が見える所まで送ります。動きを減らす設定では、滑らせずにその場で切り替えます。
つまみは、点とツールチップが移る時間の差、数える時間、目盛りの上端です。時間の差を広げるほど、吹き出しが大きく遅れてついてきます。目盛りの上端を最高値の 1.4 倍ほどにしておくと、いちばん高い月でもツールチップが点の上に収まります。線を描いて見せる入り方ではなく、描き終えたグラフの値を 1 か月ずつ読ませるための動きで、売上や利用者数の推移を見比べるダッシュボードや、管理画面のレポートに向いています。
コード
<!-- data-max は目盛りの上端、data-step は目盛りの間隔 -->
<figure class="line-tip" data-max="2000" data-step="500">
<figcaption class="line-tip__caption">
<span class="line-tip__title">月ごとの売上</span>
<span class="line-tip__note">2025年10月〜2026年9月・単位:万円</span>
<span class="line-tip__legend">
<span class="line-tip__key">今期</span>
<span class="line-tip__key line-tip__key--prev">前期</span>
</span>
</figcaption>
<!-- 値はこの表が正。JS が表から折れ線を描き、表は読み上げ用に残す(スクリプトが無いときは表のまま見える) -->
<table class="line-tip__table">
<thead>
<tr><th scope="col">月</th><th scope="col">今期(万円)</th><th scope="col">前期(万円)</th></tr>
</thead>
<tbody>
<tr><th scope="row">2025年10月</th><td>620</td><td>540</td></tr>
<tr><th scope="row">2025年11月</th><td>710</td><td>600</td></tr>
<tr><th scope="row">2025年12月</th><td>880</td><td>760</td></tr>
<tr><th scope="row">2026年1月</th><td>690</td><td>720</td></tr>
<tr><th scope="row">2026年2月</th><td>760</td><td>640</td></tr>
<tr><th scope="row">2026年3月</th><td>840</td><td>700</td></tr>
<tr><th scope="row">2026年4月</th><td>920</td><td>760</td></tr>
<tr><th scope="row">2026年5月</th><td>1,010</td><td>820</td></tr>
<tr><th scope="row">2026年6月</th><td>960</td><td>800</td></tr>
<tr><th scope="row">2026年7月</th><td>1,120</td><td>880</td></tr>
<tr><th scope="row">2026年8月</th><td>1,240</td><td>930</td></tr>
<tr><th scope="row">2026年9月</th><td>1,380</td><td>1,010</td></tr>
</tbody>
</table>
<!-- グラフの枠。JS が表から折れ線を描いて見せる。左右の矢印キーでも月を移れる -->
<div class="line-tip__frame" tabindex="0" role="group" aria-label="月ごとの売上のグラフ。左右の矢印キーで月を移れます" hidden>
<!-- role="status" なので、キーで月を移ると値が読み上げられる -->
<div class="line-tip__tip" role="status">
<span class="line-tip__date"></span>
<span class="line-tip__row"><span class="line-tip__key">今期</span><b></b></span>
<span class="line-tip__row"><span class="line-tip__key line-tip__key--prev">前期</span><b></b></span>
<span class="line-tip__row line-tip__row--delta">前期比<b></b></span>
</div>
</div>
</figure>
.line-tip {
--bg: #f2f1ed;
--ink: #262626;
--mute: #898989;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--grid: rgb(38 38 38 / 0.12);
--now: #475c6a; /* 今期の線・点・面 */
--tip-now: #93b2c9; /* ツールチップの中の、今期の線の見本 */
--prev: #898989; /* 前期の点線 */
--tip-bg: #262626; /* ツールチップは明暗を反転する */
--tip-ink: #eeece6;
--tip-mute: #a8a6a0;
--up: #6ba37f; /* 前期比(暗い地の上の小さな文字でも読める色) */
--down: #e07a62;
/* 見えなくした表が、狭い画面で横にはみ出さないよう切る */
position: relative;
overflow: clip;
margin: 0;
padding: 40px clamp(16px, 5vw, 64px);
background: var(--bg);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.line-tip__caption { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 24px; margin-bottom: 24px; }
.line-tip__title { flex-basis: 100%; font-size: clamp(28px, 4vw, 44px); font-weight: 800; letter-spacing: -0.02em; }
.line-tip__note { font-size: 16px; letter-spacing: 0.04em; color: var(--mute); }
.line-tip__legend { display: flex; gap: 16px; margin-left: auto; font-size: 14px; color: var(--mute-small); }
/* 凡例とツールチップの線の見本 */
.line-tip__key::before { content: ""; display: inline-block; width: 16px; margin-right: 8px; border-top: 3px solid var(--now); vertical-align: middle; }
.line-tip__key--prev::before { border-top: 3px dotted var(--prev); }
.line-tip__tip .line-tip__key:not(.line-tip__key--prev)::before { border-top-color: var(--tip-now); }
/* 狭い画面では、字が小さくなりすぎないよう、グラフを横にスクロールさせる */
.line-tip__frame { position: relative; overflow-x: auto; border-radius: 4px; }
.line-tip__frame:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.line-tip__plot { display: block; width: 100%; min-width: 560px; height: auto; }
.line-tip__grid { stroke: var(--grid); stroke-dasharray: 4 6; }
.line-tip__axis { stroke: var(--grid); stroke-width: 2; }
.line-tip__tick,
.line-tip__month { fill: var(--mute-small); font-size: 12px; }
.line-tip__tick { text-anchor: end; }
.line-tip__month { text-anchor: middle; }
.line-tip__area { fill: var(--now); fill-opacity: 0.08; }
.line-tip__now,
.line-tip__prev { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.line-tip__now { stroke: var(--now); stroke-width: 3; }
.line-tip__prev { stroke: var(--prev); stroke-width: 2; stroke-dasharray: 5 6; }
/* 案内線・点・ツールチップは、なぞるまで隠しておく。下のグラフへポインタを通す */
.line-tip__guide,
.line-tip__dot,
.line-tip__tip { opacity: 0; pointer-events: none; }
.line-tip__guide { stroke: var(--ink); stroke-opacity: 0.35; }
.line-tip__dot { fill: var(--now); stroke: #ffffff; stroke-width: 2; }
.line-tip__dot--prev { fill: var(--bg); stroke: var(--prev); }
.line-tip__tip {
position: absolute;
left: 0;
top: 0;
display: grid;
gap: 6px;
width: 176px;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 4px;
background: var(--tip-bg);
color: var(--tip-ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
}
/* 矢印は --arrow の位置(JS が点の x に合わせる)。点の下へ回ったときは上向きにする */
.line-tip__tip::after {
content: "";
position: absolute;
top: 100%;
left: var(--arrow, 50%);
margin-left: -7px;
border: 7px solid transparent;
border-top-color: var(--tip-bg);
}
.line-tip__tip.is-below::after { top: auto; bottom: 100%; border-color: transparent transparent var(--tip-bg); }
.line-tip__date { font-size: 12px; font-weight: 400; color: var(--tip-mute); }
.line-tip__row { display: flex; justify-content: space-between; gap: 16px; }
.line-tip__row--delta { font-size: 12px; font-weight: 400; color: var(--tip-mute); }
.line-tip__row--delta b { color: var(--up); }
.line-tip__row--delta b.is-down { color: var(--down); }
/* 描いたあとの表は、読み上げ用に見えなくするだけ(display: none にすると、読み上げからも消える) */
.line-tip__table { border-collapse: collapse; font-size: 14px; }
.line-tip__table th,
.line-tip__table td { padding: 8px 24px 8px 0; border-bottom: 1px solid var(--grid); text-align: left; }
.line-tip.is-ready .line-tip__table { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
import { gsap } from "gsap";
const BOX = { width: 720, height: 400, left: 48, right: 16, top: 16, bottom: 36 }; // SVG の座標での大きさと余白
const GAP = 16; // ツールチップと点の間(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const time = (seconds) => (reduceMotion ? 0 : seconds); // 動きを減らす設定では、その場で切り替える
const yen = (v) => `${Math.round(v).toLocaleString("ja-JP")}万円`;
function el(parent, name, attrs = {}, text = "") {
const node = parent.appendChild(document.createElementNS("http://www.w3.org/2000/svg", name));
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
node.textContent = text;
return node;
}
// 点を通るなめらかな線。前後の点から 3 次ベジェの制御点を決める(Catmull-Rom)
function curve(points) {
return `M${points[0]}` + points.slice(1).map((p2, i) => {
const [p0, p1, p3] = [points[i - 1] ?? points[i], points[i], points[i + 2] ?? p2];
return `C${p1[0] + (p2[0] - p0[0]) / 6},${p1[1] + (p2[1] - p0[1]) / 6} ${p2[0] - (p3[0] - p1[0]) / 6},${p2[1] - (p3[1] - p1[1]) / 6} ${p2}`;
}).join("");
}
document.querySelectorAll(".line-tip").forEach((chart) => {
// 月と値は表から読む。値を持つ場所が 1 つなので、表を直せば線も直る
const rows = [...chart.querySelectorAll("tbody tr")];
const months = rows.map((row) => row.cells[0].textContent);
const [now, prev] = [1, 2].map((c) => rows.map((row) => Number(row.cells[c].textContent.replace(/,/g, ""))));
const { width, height, left, right, top, bottom } = BOX;
const [max, step, last] = [Number(chart.dataset.max), (width - left - right) / (rows.length - 1), rows.length - 1];
const x = (i) => left + step * i;
const y = (v) => height - bottom - ((height - top - bottom) * v) / max;
const [frame, tip] = [chart.querySelector(".line-tip__frame"), chart.querySelector(".line-tip__tip")];
const [date, nowText, prevText, deltaText] = tip.querySelectorAll(".line-tip__date, b");
const svg = el(frame, "svg", { class: "line-tip__plot", viewBox: `0 0 ${width} ${height}`, "aria-hidden": "true" });
const scale = () => svg.clientWidth / width; // SVG は枠の幅に合わせて伸び縮みする
for (let v = 0; v <= max; v += Number(chart.dataset.step)) {
el(svg, "line", { class: v ? "line-tip__grid" : "line-tip__axis", x1: left, x2: width - right, y1: y(v), y2: y(v) });
el(svg, "text", { class: "line-tip__tick", x: left - 10, y: y(v) + 4 }, v.toLocaleString("ja-JP"));
}
months.forEach((m, i) => el(svg, "text", { class: "line-tip__month", x: x(i), y: height - 12 }, m.replace(/^\d+年/, "")));
const line = curve(now.map((v, i) => [x(i), y(v)]));
el(svg, "path", { class: "line-tip__area", d: `${line}V${y(0)}H${x(0)}Z` });
el(svg, "path", { class: "line-tip__prev", d: curve(prev.map((v, i) => [x(i), y(v)])) });
el(svg, "path", { class: "line-tip__now", d: line });
const guide = el(svg, "line", { class: "line-tip__guide", y1: top, y2: y(0) });
const dot2 = el(svg, "circle", { class: "line-tip__dot line-tip__dot--prev", r: 4 });
const dot1 = el(svg, "circle", { class: "line-tip__dot", r: 6 });
frame.hidden = false;
chart.classList.add("is-ready");
// 案内線・点・ツールチップの位置(SVG の座標)と、数えている値。変わるたびに render で描く
const state = { x: 0, y1: 0, y2: 0, tipX: 0, tipY: 0, v1: 0, v2: 0, alpha: 0 };
const render = () => {
const tx = gsap.utils.clamp(0, svg.clientWidth - tip.offsetWidth, state.tipX * scale() - tip.offsetWidth / 2);
const arrow = gsap.utils.clamp(14, tip.offsetWidth - 14, state.tipX * scale() - tx); // 端で詰めても、矢印は点を指す
gsap.set(tip, { x: tx, y: state.tipY * scale(), opacity: state.alpha, "--arrow": `${arrow}px` });
gsap.set(guide, { attr: { x1: state.x, x2: state.x }, opacity: state.alpha });
gsap.set(dot1, { attr: { cx: state.x, cy: state.y1 }, opacity: state.alpha });
gsap.set(dot2, { attr: { cx: state.x, cy: state.y2 }, opacity: state.alpha });
const rate = (Math.round(state.v1) / Math.round(state.v2) - 1) * 100; // 前期比も、数えている値から出す
[nowText.textContent, prevText.textContent, deltaText.textContent] = [yen(state.v1), yen(state.v2), `${rate < 0 ? "−" : "+"}${Math.abs(rate).toFixed(1)}%`];
deltaText.classList.toggle("is-down", rate < 0);
};
// どれも今の値から向かい直す(overwrite: "auto" で、前のトゥイーンの同じ値を止める)
const go = (vars, duration, ease = "power3.out") => gsap.to(state, { ...vars, duration: time(duration), ease, overwrite: "auto", onUpdate: render });
let index = -1;
const show = (i) => {
if (i === index) return;
const enter = index < 0 && state.alpha < 0.05; // 外から入ったら、その月へ飛んでから出す
index = i;
date.textContent = months[i];
const to = { x: x(i), y1: y(now[i]), y2: y(prev[i]) };
// ツールチップは高いほうの点の上。上に入らないときは、低いほうの点の下へ回す
const above = Math.min(to.y1, to.y2) - (tip.offsetHeight + GAP) / scale();
tip.classList.toggle("is-below", above < 0);
const at = { tipX: to.x, tipY: above < 0 ? Math.max(to.y1, to.y2) + GAP / scale() : above };
if (enter) Object.assign(state, to, at, { v1: now[i], v2: prev[i] });
go(to, 0.35); // 点と案内線
go(at, 0.5); // ツールチップは少し遅れてついてくる
go({ v1: now[i], v2: prev[i] }, 0.45, "power2.out");
go({ alpha: 1 }, 0.2, "none");
};
const hide = () => { index = -1; go({ alpha: 0 }, 0.2, "none"); };
const pick = (e) => show(gsap.utils.clamp(0, last, Math.round((((e.clientX - svg.getBoundingClientRect().left) / svg.clientWidth) * width - left) / step)));
["pointermove", "pointerdown"].forEach((type) => frame.addEventListener(type, pick)); // 指で触れたときも、その月を出す
frame.addEventListener("pointerleave", (e) => e.pointerType !== "touch" && hide());
// キーボードでは最後の月から出し、左右の矢印キーで移る。狭い画面では、選んだ月が見える所まで送る
const byKey = (i) => {
show(i);
frame.scrollLeft = x(i) * scale() - frame.clientWidth / 2;
};
frame.addEventListener("focus", () => frame.matches(":focus-visible") && byKey(index < 0 ? last : index)); // マウスで押したときは除く
frame.addEventListener("blur", hide);
frame.addEventListener("keydown", (e) => {
const move = { ArrowLeft: -1, ArrowRight: 1, Home: -last, End: last }[e.key];
if (!move) return;
e.preventDefault();
byKey(gsap.utils.clamp(0, last, Math.max(index, 0) + move));
});
});