デモ
デモを読み込めませんでした。
仕様
- 用途
- グラフ
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
色で区分を分けた半円のゲージで、針がばねのように振れて値を少し行き過ぎ、戻って止まります。針は値 × 1.8° だけ回し、区分の帯は薄く敷いておいて、値までを濃い円弧で塗ります。区分の範囲と色は凡例の行から読むので、行を直せばゲージの帯も直ります。
針の動きは、減衰振動(ばね)の式をそのまま GSAP の ease に渡しています。減衰 0.5・速さ 12 で、振れ幅の 16% だけ行き過ぎ、1.8 秒で止まります。elastic.out より立ち上がりが柔らかく、計器の針らしく見えます。振れ始めて 1.1 秒後、針がほぼ止まったころに状態のバッジを左から開き、凡例の該当する行を色づけます。
塗りと中央の数字を針にそのまま合わせると、行き過ぎから戻るあいだに、数字が 93 → 92 → 93 と揺れます。針が値を初めて越えた時点で、塗りと数字を値に止めると、数字は値へ向かって一方向にだけ動きます。
値は、ゲージの枠に meter の役割(role)と aria-valuenow を持たせて、ここを正にします。読み上げには、針の動きを待たずに aria-valuetext で「93点(非常に良好)」を届け、動いている数字と針は読み上げから外します。
揺れ方は、ばねの減衰と速さで変えられます。減衰を小さくすると大きく揺れ、速さを上げると小刻みになります。区分の数や範囲を変えるときは、凡例の行を足し引きするだけで済みます。区分の色は、エラーから成功までの意味の色を、彩度を少し落として段階に並べています。バッジの塗りは区分の色を文字の色へ 25% 寄せ、地の色の文字でも読める明るさにします。スコア・速度・健康度・達成率の表示に向いています。
コード
<section class="gauge" aria-labelledby="gauge-title">
<h2 class="gauge__title" id="gauge-title">サイトの健康度</h2>
<p class="gauge__lead" id="gauge-lead">監視しているすべてのページの総合スコア</p>
<div class="gauge__body">
<!-- 値はここが正(aria-valuenow)。読み上げには、針の動きを待たずに値と区分の名前が届く -->
<div class="gauge__meter" role="meter" aria-labelledby="gauge-title" aria-describedby="gauge-lead"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="93" aria-valuetext="93点(非常に良好)">
<svg class="gauge__dial" viewBox="0 0 440 256" aria-hidden="true">
<!-- 帯と目盛りは、下の区分から JS がここに描く -->
<g class="gauge__scale"></g>
<!-- 針は左向きに描いて rotate で振る。影は同じ形をずらして重ねる(ぼかしのフィルターは使わない) -->
<path class="gauge__shadow" d="M246,209L58,212.4Q55,214 58,215.6L246,219Z" />
<path class="gauge__needle" d="M246,209L58,212.4Q55,214 58,215.6L246,219Z" />
<circle class="gauge__hub" cx="220" cy="214" r="17" />
<circle class="gauge__pin" cx="220" cy="214" r="6" />
</svg>
<p class="gauge__readout" aria-hidden="true"><span class="gauge__number">0</span><span class="gauge__max">/100</span></p>
<p class="gauge__badge" aria-hidden="true"></p>
</div>
<!-- 区分。範囲の上限(data-to)と色(--color)は、ここから読んでゲージを描く -->
<div class="gauge__ranges">
<p class="gauge__label">スコアの区分</p>
<ul class="gauge__list">
<li style="--color: #b3604e" data-to="40"><span>要改善</span><span>0〜40</span></li>
<li style="--color: #af7b4b" data-to="60"><span>普通</span><span>40〜60</span></li>
<li style="--color: #9a833d" data-to="80"><span>良好</span><span>60〜80</span></li>
<li style="--color: #568366" data-to="100"><span>非常に良好</span><span>80〜100</span></li>
</ul>
</div>
</div>
</section>
.gauge {
--bg: #111111; /* ページの地。バッジの文字にも使う */
--surface: #262626; /* カードの面 */
--ink: #eeece6;
--mute: #898989; /* 10 ごとの目盛りと「/100」 */
--mute-small: #a8a6a0; /* 16px 未満の文字 */
--line: rgba(238, 236, 230, 0.14); /* 2 ごとの目盛り */
padding: clamp(24px, 5vw, 48px);
border-radius: 4px;
background: var(--surface);
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.gauge__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.gauge__lead { margin: 4px 0 40px; color: var(--mute-small); font-size: 14px; letter-spacing: 0.04em; }
.gauge__body { display: flex; flex-wrap: wrap; align-items: center; gap: 48px 64px; }
.gauge__meter { flex: 0 1 440px; text-align: center; }
.gauge__dial { display: block; width: 100%; height: auto; }
/* 目盛りと帯(帯の色は、JS が区分の色を stroke に入れる) */
.gauge__track,
.gauge__fill { fill: none; stroke-width: 26; }
.gauge__track { stroke-opacity: 0.2; }
.gauge__tick { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
.gauge__tick.is-major { stroke: var(--mute); stroke-width: 2; }
.gauge__dial text { fill: var(--mute-small); font-size: 14px; font-weight: 600; text-anchor: middle; }
/* 針と影は、JS が入れる --turn(度)で回す。回す中心は、viewBox の中の軸の位置 */
.gauge__needle,
.gauge__shadow { transform-origin: 220px 214px; transform: rotate(calc(var(--turn, 0) * 1deg)); }
.gauge__needle,
.gauge__hub { fill: var(--ink); }
.gauge__shadow { translate: 0 4px; fill: rgba(0, 0, 0, 0.4); }
.gauge__pin { fill: var(--surface); }
/* 数字の箱を右寄せの固定幅にして、桁が変わっても「/100」が動かないようにする */
.gauge__readout { margin: 8px 0 0; white-space: nowrap; }
.gauge__number { display: inline-block; width: 3ch; font-size: 64px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; text-align: right; }
.gauge__max { margin-left: 8px; color: var(--mute); font-size: 20px; font-weight: 600; }
/* 状態のバッジ。区分の色を文字の色へ 25% 寄せた塗りに、地の色の文字(エラーの色のままでは 4.5:1 に届かない)。JS が clip-path で左から開く */
.gauge__badge {
display: inline-block;
min-width: 128px;
margin: 16px 0 0;
padding: 0 16px;
box-sizing: border-box;
border-radius: 999px;
background: color-mix(in srgb, var(--zone, transparent) 75%, var(--ink));
color: var(--bg);
font-size: 13px;
font-weight: 700;
line-height: 32px;
letter-spacing: 0.06em;
clip-path: inset(0 100% 0 0 round 99px);
}
.gauge__ranges { flex: 1 1 320px; }
.gauge__label { margin: 0 0 16px; color: var(--mute-small); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }
.gauge__list { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.gauge__list li {
display: flex;
align-items: center;
gap: 16px;
height: 52px;
padding: 0 16px;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.04em;
transition: background-color 0.4s;
}
.gauge__list li::before { content: ""; width: 12px; height: 12px; border-radius: 2px; background: var(--color); }
.gauge__list li span:last-child { margin-left: auto; color: var(--mute-small); font-size: 14px; font-weight: 400; }
/* 針が止まった区分の行。半透明にせず、面に区分の色を 20% 混ぜた不透明な色で塗る */
.gauge__list [aria-current] { background-color: color-mix(in srgb, var(--color) 20%, var(--surface)); }
@media (prefers-reduced-motion: reduce) {
.gauge__list li { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const C = { x: 220, y: 214, r: 196, inner: 183 }; // 中心・帯の中心の半径・帯の内側の縁の半径(viewBox の中の値)
const GAP = 0.6; // 区分の間のすき間(値の単位)
const SHOW = 1.1; // 振れ始めてから、状態のバッジが開くまで(秒)
// 減衰振動(減衰 0.5・速さ 12 のばね)。値を振れ幅の 16% 行き過ぎてから戻る。GSAP の ease にそのまま渡せる
function spring(p) {
const [zeta, omega] = [0.5, 12];
const wd = omega * Math.sqrt(1 - zeta * zeta);
return p >= 1 ? 1 : 1 - Math.exp(-zeta * omega * p) * (Math.cos(wd * p) + ((zeta * omega) / wd) * Math.sin(wd * p));
}
// 値 v(0〜100)の、半径 r の円周上の点。0 が左端、50 が真上、100 が右端
function point(v, r) {
const a = Math.PI * (1 + v / 100);
return [(C.x + r * Math.cos(a)).toFixed(2), (C.y + r * Math.sin(a)).toFixed(2)];
}
const arc = (a, b) => `M${point(a, C.r)}A${C.r},${C.r} 0 0 1 ${point(b, C.r)}`;
function add(parent, name, attrs) {
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);
return node;
}
function createGauge(root) {
const [meter, dial, scale, number, badge] = ['[role="meter"]', ".gauge__dial", ".gauge__scale", ".gauge__number", ".gauge__badge"].map((s) => root.querySelector(s));
// 区分は凡例の行から読む(行を直せば、帯も直る)。帯は薄く敷いておき、値までを濃く塗る
let from = 0;
const zones = [...root.querySelectorAll(".gauge__list li")].map((row) => {
const zone = { row, from, to: Number(row.dataset.to), name: row.firstElementChild.textContent };
zone.color = getComputedStyle(row).getPropertyValue("--color").trim();
add(scale, "path", { class: "gauge__track", d: arc(from + GAP, zone.to - GAP), stroke: zone.color });
zone.path = add(scale, "path", { class: "gauge__fill", stroke: zone.color });
from = zone.to;
return zone;
});
// 目盛り(2 ごと。10 ごとは長く濃く)と、20 ごとの数字。0 と 100 の数字は針の休む位置にかかるので、帯の端の下に置く
for (let v = 0; v <= 100; v += 2) {
const [x1, y1] = point(v, C.inner - 8);
const [x2, y2] = point(v, C.inner - (v % 10 ? 13 : 20));
add(scale, "line", { class: v % 10 ? "gauge__tick" : "gauge__tick is-major", x1, y1, x2, y2 });
const [x, y] = v % 100 ? point(v, C.inner - 38) : [point(v, C.r)[0], C.y + 29];
if (v % 20 === 0) add(scale, "text", { x, y: +y + 5 }).textContent = v;
}
const state = { needle: 0, badge: 0 };
let score = 0, start = 0, reached = false, tl = null;
function draw() {
// 塗りと数字は、針が値を初めて越えるまで針に合わせ、越えたら値で止める(行き過ぎと戻りで、数字が揺れない)
if ((state.needle - score) * Math.sign(score - start) >= 0) reached = true;
const value = reached ? score : state.needle;
dial.style.setProperty("--turn", (state.needle * 1.8).toFixed(2)); // 針は CSS で、値 × 1.8° 回す
for (const z of zones) {
const end = Math.min(z.to - GAP, value);
z.path.setAttribute("d", end > z.from + GAP ? arc(z.from + GAP, end) : "");
}
number.textContent = Math.round(value);
badge.style.clipPath = `inset(0 ${(100 - state.badge * 100).toFixed(1)}% 0 0 round 99px)`;
}
// 値を変える。読み上げには、針の動きを待たずに、新しい値と区分の名前を渡す
function set(next) {
[score, start, reached] = [Math.round(gsap.utils.clamp(0, 100, next)), state.needle, false];
const zone = zones.find((z) => score <= z.to) ?? zones.at(-1);
meter.setAttribute("aria-valuenow", score);
meter.setAttribute("aria-valuetext", `${score}点(${zone.name})`);
zones.forEach((z) => z.row.removeAttribute("aria-current"));
// 前のバッジを閉じておき、針が止まるころに新しい区分の文言と色で左から開く。凡例の行も、そこで色づける
tl?.kill();
tl = gsap
.timeline({ onUpdate: draw })
.to(state, { needle: score, duration: 1.8, ease: spring }, 0)
.to(state, { badge: 0, duration: 0.2, ease: "power2.in" }, 0)
.call(() => {
badge.textContent = zone.name;
badge.style.setProperty("--zone", zone.color);
zone.row.setAttribute("aria-current", "true");
}, null, SHOW)
.to(state, { badge: 1, duration: 0.45, ease: "power3.out" }, SHOW);
if (reduceMotion) tl.progress(1); // 動きを減らす設定では、止まった状態で見せる
}
draw();
// 画面に入ったら、書いてある値まで振る。動きを減らす設定では、見張りを始めた直後の 1 回目で止まった状態にする
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting && !reduceMotion) return;
io.disconnect();
if (!tl) set(Number(meter.getAttribute("aria-valuenow")));
}, { rootMargin: "0px 0px -20%" });
io.observe(root);
return { set };
}
export const gauges = [...document.querySelectorAll(".gauge")].map(createGauge); // 例: 値が変わったら gauges[0].set(88)