GODRICH

ダッシュボード グラフ デザイン 操作9選 — 触ると数字が変わる

ダッシュボード グラフ デザインで本当に働くのは描かれた線ではなく、その上で手がすることです。9 つはすべてチャートライブラリなしで CSS の棒とインライン SVG の上に素の JS だけを載せ、触ると数字が変わるものだけを選びました。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は、人がグラフを読んでいく順です。まず値を 1 つ確かめ(01)、見る系列を絞り(02)、期間を手でなぞって決めます(03)。次は掘り下げる段階です。1 か月を押して日別に割り(04)、用意された期間に入れ替え(05)、同じ値を積み上げと横並びでひっくり返して見ます(06)。最後の 3 つは判断する段階です。目標線を自分で動かして達成本数を数え(07)、2 つの時点を留めて差を読み(08)、表の 1 行を広げてそのチャネルだけを覗きます(09)。9 つをひとつに束ねる約束があります。目に見える変化はすべて @property で登録したカスタムプロパティと aria-*data-* 属性だけに入り、JavaScript はその値を書き換えるだけで、描くのは CSS に任せます。そうして初めて、自動再生のプレビューと人が実際に触ったときが同じ値を共有します。

01マウスを追って一番近い点に吸い付く

線の上でポインタを動かすと、縦の点線が点と点の間に中途半端に立たず、一番近い日付の枠へ引き寄せられます。マウスの x を枠の比率に直して四捨五入した整数 1 つが吸い付きのすべてで、残りはその整数を読んだ CSS が描きます。24 フレームのレンダリングで動いたフレームは 23 枚、累積面積は 4.833% です。

@propertypointermovearia-live
var N = 12;
function snap(i) {
  cur = Math.min(Math.max(i, 0), N - 1);
  ch.classList.remove('is-demo');
  ch.style.setProperty('--i', String(cur));
  ch.style.setProperty('--y', ys[cur] + 'px');
  tip.textContent = nums[cur] + '명';
  val.textContent = (cur + 1) + '일 · ' + nums[cur] + '명';
}
hot.addEventListener('pointermove', function (e) {
  var r = hot.getBoundingClientRect();
  var t = Math.min(Math.max((e.clientX - r.left) / r.width, 0), 1);
  snap(Math.round(t * (N - 1)));
});

02凡例を消すと残りの棒が伸び直す

凡例のチップを押すとその系列が底まで畳まれ、残った系列の最大値で軸が取り直されて、ほかの棒がすべて高く伸びます。隠しているのではなく軸を測り直しているので、カードの見出しも合計ではなく軸の最大を 460 から 260 へ書き換えて見せます。

aria-pressed@propertytransform-origin
function apply() {
  lg.classList.remove('is-demo');
  var on = [], max = 0;
  for (var k = 0; k < 3; k++) if (chips[k].getAttribute('aria-pressed') === 'true') on.push(k);
  on.forEach(function (k) { V[k].forEach(function (v) { if (v > max) max = v; }); });
  for (var i = 0; i < bars.length; i++) {
    var b = bars[i], s = +b.getAttribute('data-s');
    if (on.indexOf(s) < 0) { b.setAttribute('data-fold', '1'); }
    else {
      b.removeAttribute('data-fold');
      b.style.setProperty('--h', (max ? V[s][+b.getAttribute('data-m')] / max * 100 : 0).toFixed(1) + '%');
    }
  }
  lg.style.setProperty('--mx', String(max));
  live.textContent = '축 최대 ' + max + '명';
}

03ドラッグで区間を選ぶと合計が変わる

14 本の棒の上を横になぞると、その区間だけが明るく残って外側は沈み、見出しの合計と日数が指を離す前から数え直されます。明るい帯は絶対配置ではなく、枠 1 つ分の幅を開始枠の分だけずらし、枠の数の分だけ引き伸ばしたものなので、枠が何 px でも式がそのまま合います。

pointerdownsetPointerCapturearia-live
.br__band {
  display: block;
  height: 100%;
  width: calc(100% / $cell);
  border-radius: $r-xs;
  background: rgba(47, 109, 246, .16);
  transform-origin: left center;
  transform: translateX(calc(var(--s) * 100%)) scaleX(calc(var(--e) - var(--s) + 1));
}

04月の棒を押すとその場で日別に割れる

月の棒を押しても画面は入れ替わらず、その棒が立っていた場所で日別の棒 5 本に割れます。5 本は最初から同じ枠に重なっていて、月の棒が退くとそれぞれの位置へ滑り出します。キーフレームの中の var(--i) はそのアニメーションを受ける要素ごとに解決されるので、規則 1 つで 5 本を別々の場所へ送れます。

aria-expandedtransform-originanimation-fill-mode
.dd.is-demo .dd__col[data-m="#{$demo-month}"] .dd__day {
  animation-name: dd-day;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: calc(var(--i) * 40ms);
  animation-fill-mode: backwards;
}
@keyframes dd-day {
  0%, 14% { transform: translateX(0) scaleX(1); }
  36%, 58% { transform: translateX(calc((var(--i) - 2) * 30%)) scaleX(.3); }
  78%, 100% { transform: translateX(0) scaleX(1); }
}

057日・30日・90日で棒が入れ替わる

期間を変えても棒は 8 本のままで、高さだけが新しい値へ流れます。JavaScript がするのは data-periodaria-selected を書き換えることだけで、8 本の高さも下線の位置も平均・最高の数字も、すべて data-period を読むセレクタから分かれます。

aria-selected@propertygrid-template-columns
.pg[data-period="7"]  { --seg: 0; --avg: 343; --max: 430; }
.pg[data-period="30"] { --seg: 1; --avg: 293; --max: 372; }
.pg[data-period="90"] { --seg: 2; --avg: 253; --max: 310; }

@each $p, $d in (7: $d7, 30: $d30, 90: $d90) {
  @for $i from 1 through 8 {
    .pg[data-period="#{$p}"] .pg__bar:nth-child(#{$i}) { --h: #{nth($d, $i)}; }
  }
}

06積み上げと横並びを行き来する

トグル 1 つで同じデータが積み上げの棒と横並びの棒に分かれます。先月の系列は先に横へ退いてから降り、戻るときは逆の順をたどります。縦と横を一度に補間すると、黒い棒が青い棒を斜めに突き抜けて 2 つの系列が重なったフレームが残るからです。

aria-pressedgrid-columnsteps(1, end)
@keyframes sk-b#{$i} {
  0%, 34%   { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
  44%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  52%, 76%  { transform: translateX(53%) translateY(0) scaleX(.44); }
  82%       { transform: translateX(53%) translateY(#{-1 * nth($ha, $i)}) scaleX(.44); }
  86%, 100% { transform: translateX(0) translateY(#{-1 * nth($ha, $i)}) scaleX(1); }
}

07目標線を動かすと超えた棒だけ色が変わる

横の目標線を上下に引くと、線を超えた棒だけ色が変わり、横の達成本数が指を離す前から追いかけます。ドラッグは setPointerCapture でポインタを捕まえるのでグラフの外へ出ても切れず、つまみは role="slider" なので ↑↓ キーでも同じ値が動きます。24 フレーム中 22 枚が動き、強度は 151.0 です。

setPointerCapture@propertyaria-valuenow
function setGoal(v) {
  v = Math.max(MIN, Math.min(MAX, Math.round(v)));
  var n = 0;
  bars.forEach(function (b, i) {
    var over = DATA[i] >= v ? '1' : '0';
    b.dataset.over = over;
    if (over === '1') { n += 1; }
  });
  root.style.setProperty('--goal', v / 4 + '%');
  root.style.setProperty('--gv', String(v));
  root.style.setProperty('--n', String(n));
  grab.setAttribute('aria-valuenow', String(v));
  grab.setAttribute('aria-valuetext', v + '명');
}

082点を留めると差が出る

1 点目が基準になり、2 点目を留めると 2 点を結ぶ点線と差のバッジが出て、3 点目を留めると基準がその点へ移ります。1 週の 128 人から 8 週の 262 人なら +104.7%、8 週から 5 週の 233 人へ戻れば −11.1% というように、バッジの数字は表の値からその都度計算します。

aria-pressedstroke-dasharrayaria-live
  put('--mx', (xs[a] + xs[b]) / 2 + 'px');
  put('--my', (ys[a] + ys[b]) / 2 + 'px');
  line.setAttribute('x2', xs[b] - xs[a]);
  line.setAttribute('y2', ys[b] - ys[a]);
  var d = (vs[b] - vs[a]) / vs[a] * 100;
  var pct = (d >= 0 ? '+' : '−') + Math.abs(d).toFixed(1) + '%';
  badge.textContent = pct;
  root.setAttribute('data-b', '1');

09表の中の小さな線がカードに広がる

行に触れるかキーボードで届くと、その行がカードに広がって線が太くなり、軸の目盛と最高点の印が現れます。高さの配分は grid-template-rows の宣言 1 つが独り占めしていて、開いた行が 68px になる分だけ残りの 2 行が 20px に縮むので、リスト全体は 108px のままでカードが舞台からはみ出しません。

vector-effectgrid-template-rows:focus-visible
.sp__rows {
  display: grid;
  grid-template-rows: $fold $fold $fold;
  transition-property: grid-template-rows;
}
.sp__rows:has(.sp__row[data-i="0"]:focus-visible) { grid-template-rows: $open $squash $squash; }
.sp__rows:has(.sp__row[data-i="1"]:focus-visible) { grid-template-rows: $squash $open $squash; }
.sp__rows:has(.sp__row[data-i="2"]:focus-visible) { grid-template-rows: $squash $squash $open; }
.sp__line { vector-effect: non-scaling-stroke; }

どこで壊れるか — 値が先に着くとそのフレームが代表画像になる

この回でいちばん直したのはコードではなく時刻でした。プレビューの代表画像は 2 秒を 24 分割したフレームのうち、前の 1 枚からもっとも大きく変わった 1 枚が選ばれます。つまり値が着く時刻と絵が着く時刻がずれると、その間のどれかが代表画像になります。03 がまさにそれでした。明るい帯は scaleX でなめらかに伸びるのに、合計の数字と外側の棒が明るくなるのは帯が出発する 15% にカットで置いていたため、帯は 5 枠なのに見出しは 165 万ウォン・14 日と書かれた 1 枚が選ばれました。3 つとも帯が着き終わる 40% へ移し、復帰を 88% で終わらせると、ずれるフレームが 1 枚もなくなりました。06 は逆向きでした。2 つの系列が場所を入れ替える間、縦と横を一度に補間したせいで黒い棒が青い棒を斜めに突き抜け、2 色が重なった 1 枚がループ中でもっとも大きく変わるフレームになり、そのまま代表画像になっていました。横移動と縦移動を 2 段に分け、先に退いてから降りるようにすると、重なる瞬間そのものが消えました。02 は時刻ではなく値が間違っていました。プレビューが検索の系列を畳んでいる間も見出しは合計 3,290 を抱えたままでしたが、実際にチップを押すと JavaScript は 1,590 へ数え直すので、プレビューと操作が違うことを言っていたわけです。見出しを軸の最大に変え、その数字まで @property で登録してカウンターで描くようにすると、460 と 260 がプレビューでもきちんと分かれました。3 つともコードを読んでも見えず、フレームを 1 枚ずつ開いて初めて見える種類なので、9 枚の代表画像はすべて目で確かめてから先へ進みました。同じ要領で、静的な状態も 1 枚ずつ撮って確かめました。自動再生の .is-demo を外しただけの画面を開き、数字・ラベル・選択の印が全部出ているかを見るのです。ループ用の演出にだけ値を置いていると、人が実際に触ったときに画面が空になります。測定もゲートもループの状態しか見ないので、どちらもこれを捕まえられません。フレームの外で見つかったものも 2 つあります。1 つは 01 の要約表です。見た目だけ隠すつもりで width: 1pxclip-path を表そのものに掛けたところ、表は中身の大きさまで育って 12 行が積み上がり、480×300 の舞台に縦 411px の文書ができていました。画面には何も見えずスクロールバーだけが出るので原因を探すのに手間取り、答えは表を包む div へその規則を移すことでした。もう 1 つは 07 のアイコンとスナップです。目標線のつまみに入れた Phosphor アイコンの弧の座標は A104.1 104.1 0 1 1 のはずが、回転の位置に 1 が入って A104.1 104.1 1 1 1 になっていました。2 つの円が重なった的の形なので目では無事に見え、取得しておいた元の SVG と 1 文字ずつ突き合わせて初めて分かりました。同じデモでは目標値を 10 単位に丸めるスナップも外しました。自動再生は値を連続で転がして目標 337 人のような場面を見せるのに、スナップが残っていると人がいくら引いてもその数字に届かず、プレビューが見せた画面が嘘になるからです。この回の決まりはそこで 1 つにまとまります。プレビューが見せる場面はすべて、人が実際に手で作れる場面でなければなりません。9 つを収めた zip の圧縮パスワードは 3b3n74ca で、素の版と React の移植版が同じ名前のフォルダで並んで入っています。

アクセシビリティ — reduced-motion で何が残るか

9 つとも prefers-reduced-motion: reduce で自動再生のループを止め、値と状態は残します。そのブロックの中の宣言には !important を付けました。.x.is-demo .y の形のセレクタが詳細度で勝ってしまうからです。状態を言葉で伝える側は、アニメーションを切ってもそのまま残ります。01 と 08 は aria-live="polite" の 1 か所が留めた点を、08 はその差も一文で読み、07 のつまみは role="slider"aria-valuetext で目標値を告げ、02 のチップは aria-pressed、04 の棒は aria-expanded、05 のセグメントは aria-selected が状態を残します。9 つともグラフに role="img" と実際の数字が入った aria-label を与え、同じ値を収めた要約表を視覚的にだけ隠してカードの中に一緒に置きました。隠すときに表そのものへ width: 1px を与えると文書が 411px まで伸びるので、その規則は包む div に置きます。キーボードも飾りではありません。01 と 08 は ←→ で点の間を、03 は ←→ で終点と Shift+←→ で始点を、05 は ←→ でセグメントを、07 は ↑↓ で目標線を、09 は ↑↓ で行を動かします。

文字と棒のコントラストは WCAG の相対輝度の式で直接計算しました。半透明の文字は重なる背景と合成した実効色で測っています。

使う場所 後ろ コントラスト
01・03・04 本文のインク / 白カード #17141a #ffffff 18.24:1
01・02・03 数値の青 / 白カード #2f6df6 #ffffff 4.53:1
08 案内のイエロー / 墨カード #ffd23f #17141a 12.63:1
補助文字・インク 62% / 白カード #17141a 62% #ffffff 5.12:1
02・07 系列の棒・下げたミント / 白カード #3d9e7f #ffffff 3.28:1

2 種類を直して通しました。1 つは軸のラベルや単位、消したチップのように意図してぼかした文字で、インクの不透明度 50〜58% で 3.44:1 から 4.45:1 まで散らばっていたため、55 か所を 62% に上げて 5.12:1 に揃えました。もう 1 つは棒の色です。パステルのミント #4cd4a6 は暗い舞台を前提にした色なので、白いカードの上では 1.86:1 しか出ず棒が背景に溶けます。同じミントをインクと 72:28 で混ぜて 3.28:1 まで上げると、非テキストの基準である 3:1 を超えました。07 では超えた棒の色が唯一の手がかりではなく、目標線の上という位置が同じことをもう一度伝えるので、色だけで判断する場面も残りません。隣の ダッシュボード コンポーネント9選 は同じ柱の部品を扱い、ダッシュボードの分類 にこの柱のほかの回が集まっています。登録の書式は MDN @property のドキュメントに従いました。

よくある質問

チャートライブラリを使わないと何が不便ですか

軸の目盛の自動計算、対数スケール、数千点の描画など、ライブラリが代わりにやってくれていたことを自分で書くことになります。この 9 つは反対側を選んだものです。点が十数個で操作が 1 つ 2 つのダッシュボードカードなら、バンドルとその中の DOM 構造を抱えるより、棒の高さをカスタムプロパティ 1 つに置いて CSS に補間させたほうがコードも短く、直す場所もはっきりします。

@property なしで棒の高さをアニメーションできますか

登録していないカスタムプロパティはブラウザがただの文字列として見るので、2 つの値の間に中間値がありません。棒は伸びる代わりにある瞬間にぱっと変わり、自動再生のプレビューでは動いたフレームが 2〜3 枚まで落ちます。syntax"<percentage>""<integer>" で宣言し、initial-value まで与えて初めてその間が埋まります。

なぜ十字線を一番近い点に吸い付かせるのですか

吸い付かせないと、人が読む値とカーソルが指す場所が少しずつずれます。点と点の間に縦線が立っていると、どの日の値なのかをもう一度目で確かめることになり、値を読み上げる支援技術の側には指す対象そのものがありません。枠の比率を四捨五入する 1 行で、カーソルの位置と読み上げられる値がいつも同じ日を指すようになります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。