数百件と数千万件が並ぶグラフを、1画面の対数タイムラインに収めるまで

一覧ページを廃して1画面の散布図タイムラインに振り切り、スクロールでの時間移動・ピンチでの時間単位ズーム・表示範囲に応じた対数縦軸の滑らかな伸縮を実装するまで。

#データ可視化#UI#Astro

企業の情報漏洩事件を時系列と被害規模で記録するアーカイブサイト「漏洩百貨店(loo100)」を作っていたとき、最初は一般的なニュースサイトと同じように、カードが縦に並ぶ一覧ページ(/incidents/)を入り口にしていました。

しかし、実際に2026年秋に公表された国内の漏洩事案を並べてみると、文字の一覧ではどうしても伝わらないことがあると気づきました。それは**「被害規模の桁の違い」と「事件が集中する時期の密度」**です。数百件の漏洩と、1,000万件・2,000万件を超える大規模な漏洩が同じ高さのカードとして1行ずつ並んでいると、どの事件がどれほど突出しているのかが直感的に分かりません。

そこで思い切って従来の /incidents/ 一覧ページを廃止し、トップページをスクロールしない1画面(height: 100svh; overflow: hidden)に固定して、画面全体を「横軸=公表日時、縦軸=被害件数(対数目盛)」のインタラクティブな歴史グラフにする設計(D-021 / D-023 / D-024)へ振り切りました。

線形では底に潰れる数百件と数千万件を、動的な対数軸に載せる

情報漏洩の被害件数は、事案によって4桁から5桁以上の開きがあります。たとえば2026年9月下旬から10月中旬にかけて公表された事案だけでも、数十万件のものから、焼肉きんぐ(約1,078万件)、skyticket(約1,464万件)、ソースネクスト(約2,251万件)のように1,000万件を超えるものまでが同じ期間にひしめき合っています。

これを通常の線形スケール(リニア軸)で描くと、2,000万件の線が画面の上端に届く一方で、10万件以下の事件はすべて底辺の数ピクセルに張り付いてしまい、点の区別すらつきません。そこで縦軸には常用対数($\log_10$)を採用し、さらに件数が調査中・非公表の事案もグラフから消えてしまわないよう、対数軸のすぐ下に高さ 72px の「非公表」の段を設けて破線と白抜きの点で並べることにしました。

ただ、対数軸にしても、全期間の最小値から最大値(たとえば $10^2$〜$10^8$)に縦軸を固定したままでは、時間軸を拡大して「数十万件〜数百万件の事件だけが並んでいる期間」を見たときに、グラフの上下がスカスカになってしまいます。

そこで、現在画面に見えている期間(描画域の外側の余白も含む画面全体)にある事件の最小件数と最大件数を毎フレーム計算し、縦軸の下限と上限を動的に伸縮させる仕組み(D-026)を入れました。

縦軸の制御パラメータ計算式・設定値役割と理由
下限の目標値(lo)max(0, floor(log10(min) - 0.15))一番小さい事件の縦線が下限ぴったりで高さ 0 にならないよう、対数で 0.15 だけ下に余裕を持たせる
上限の目標値(hi)max(lo + 1, ceil(log10(max)))見えている最大の事件が収まる最小の $10^n$ を上限にする
フレーム間の減衰係数現在値 + (目標値 - 現在値) * 0.18〜0.20スクロール中に新しい桁の事件が画面端から入った瞬間、目盛りと線がカクッと跳ねるのを防ぎ、指数減衰で滑らかに追従させる

スクロールして画面の端に1,000万件クラスの事件が顔を出した瞬間、もし縦軸の上限が即座に $10^6$ から $10^8$ へ切り替わると、見ている最中のすべての線がガクッと縮んで視線を見失います。そのため、目標の対数範囲(targetLogMax / yGoal)に対して現在の表示範囲を毎フレーム約2割ずつ近づける指数減衰(ダンピング)を入れ、差が 0.005 未満になるまで requestAnimationFrame を回し続けることで、目盛り線と事件の縦線が呼吸するように滑らかに伸び縮みするようにしました。

ホイールで時間を流し、ピンチで 1 時間単位まで拡大する

全画面をグラフにした以上、ブラウザの標準の縦スクロールは使えません。代わりに、トラックパッドやマウスのホイール操作をすべて「時間軸の前後移動(パン)」に割り当て、2本指のピンチ操作(Chrome 系では ctrlKey 付きの wheel イベント、Safari では gesturestart / gesturechange、スマホでは2本指のポインター追跡)を「表示期間の拡大・縮小(ズーム)」に割り当てました(D-025)。

拡大・縮小を作るときにこだわったのは、**「ポインターがある位置の日時(アンカー)を画面上で動かさないまま、前後の期間だけを伸縮させる」**ことです。単純に画面の中心を基準にズームすると、端のほうにある事件にカーソルを合わせて拡大したときに、その事件が画面の外へ逃げていってしまいます。カーソル位置が描画域の左端から何割の位置(ratio)にあるかを求め、その時点の時刻 anchor を固定したまま新しい表示幅 span を左右に配分することで、狙った事件の塊へ自然にズームインできるようにしました。

また、初期の実装(D-028)では、直近1年(365日)の俯瞰から最大で MIN_SPAN = 1 / 24(1時間単位)まで無段階に拡大できるようにし、表示期間が3日以下になった時点で横軸の目盛りラベルを日付から M/D HH:00(日本時間)の1時間刻みに自動で切り替える仕組みを作りました。

ここで一度踏んだ罠がタイムゾーンのずれです。最初、記事の公表日(2026-09-25 など)を JavaScript の Date.parse でそのまま扱っていたため、内部的に UTC の 0時(=日本時間の朝9時)にプロットされていました。月単位や日単位の目盛りでは気になりませんでしたが、時間単位の目盛り(9/25 0:00, 1:00...)まで拡大できるようにした途端、すべての事件が朝9時の位置にずれていることが露呈しました。そこで、公表日時の解釈も横軸の目盛り打ちもすべて日本時間(UTC + 9時間)を基準に計算し直すように統一しました。

さらにその後、実際の記事データ(公表時刻が発表されていない事案が大半)を入れてPCとスマホの両方で触り比べると、「時間単位まで拡大できても、0時以外のデータがないと空白ばかりが広がる」「PCの広い画面で3日分まで拡大すると余白が大きすぎる」という運用上の課題が見えてきました。そのため最終的には、「1日あたりの画面幅の上限を 100px とし、スマホ(幅約300px)では最小3日、PCの1280px幅では約11日、1920px幅では約17日で拡大を止める」という画面幅に応じた動的リミット(D-039 / D-041)へ洗練させました。

同じ日に起きた事件の重なりと、端の事件まで開けないバグ

実データとして2026年9月〜10月の事件を21件入れて動かしてみると、架空のサンプルデータでは気づかなかった視覚的な不具合が次々と見つかりました。

1. 同じ公表日の事件が完全に1本の線に重なる

たとえば10月8日にはローソンと第一興商、10月9日にはブックオフ・skyticket・ディップ(バイトル)、10月19日にはニフティ・阪急阪神(H2O)・クリプトン・ユミルリンク・リンクステーションの5件が同じ日に公表されています。公表時刻まで分単位で発表している企業はまれなため、そのままプロットすると同じ日の事件が完全に同じ X 座標に重なり、下の線にホバーすることも選ぶこともできなくなります。

これを防ぐため、公表日時が同一のグループを検出し、最初は時間軸上で前後に数時間(±6h)散らす方法を試しました。しかし、時間でずらしてしまうと「縮小したときには結局重なり、拡大したときには別の時刻に公表されたように見えてしまう」という問題がありました。そこで、時刻ではなく画面上のピクセル座標で、グループの中心を基準に 6px ずつ(5件なら -12, -6, 0, +6, +12px)ずらす方式(D-029)に変えました。これならどのズーム倍率でも一定の隙間(6px)が保たれ、正確に同じ日の事件だと分かります。

2. 右端までスクロールしても最新の 2 件の概要が開けない

ところが、この「ピクセル単位のずらし(overlapShifts)」を入れたことで、別の思わぬバグ(D-040)が生まれました。トップ画面では「画面の左右中央に一番近い事件」を自動で赤く強調して概要カードを開く仕組みにしています。そのためスクロールの可動範囲は「最初の事件の日時〜最後の事件の日時が、画面の中央に来るまで」に制限していました。

しかし最新日である10月19日には5件の事件があり、右側のニフティとユミルリンクは 10月19日 0時の位置からさらに +6px、+12px 右へずれています。可動範囲の計算が「ずらす前の日時(真ん中のリンクステーションの位置)」で止まっていたため、右端まで目一杯スクロールしてもニフティとユミルリンクが画面中央に届かず、概要カードを永遠に開けなくなっていたのです。

原因が分かってからは、最初と最後の日時におけるピクセルのずらし量(padLeft / padRight)を境界データに持たせ、clampView の中で「その時点のズーム倍率における1ピクセルあたりの時間(perPx)」を掛けて可動範囲と実線の横軸を延ばすことで、1px ずつスクロールすると5件すべてが順番に中央へ来て開けるように直しました。

3. 点が画面から出た瞬間に、まだ見えている企業名ラベルがパッと消える

もう1つ不自然だったのが、被害規模上位の事件に付けている箱型の企業名ラベル(boxLabel)の消え方でした(D-044)。

最初は「画面内に線(点)が入っている事件」だけを対象にラベルを描き、さらに画面端からはみ出すラベルは反対側に置き直すか省略していました。すると、スクロールして事件の縦線が画面の左端を1ピクセルでも越えた瞬間に、その右側に伸びてまだ画面内にしっかり見えている「株式会社物語コーポレーション」などの長い企業名ラベルまで、パッと瞬時に消えてしまっていたのです。

そこで、ラベルを配置する候補を「線が画面から 600px(LABEL_REACH)以内にある事件」まで広げました。そのうえで、他のラベルと重ならない限りは画面端からはみ出したまま描画を続け、ラベル自身の矩形(getBoundingClientRect)が画面から完全に外へ出たときに初めて描画をやめるように変更しました。ヘッドレス Chrome で 8px ずつ左へスクロールさせて計測したところ、9件すべてのラベルが画面の左端から残り 2〜8px になる最後の瞬間まで滑らかに流れてから消えるようになりました。

さらに、事件のない未来や過去の範囲には補助線を「点線」で伸ばして実線のデータ範囲と区別し(D-031)、縦軸の目盛り文字には背景色の下地(tick-bg)を敷いて流れていく線が文字の下をくぐるようにしたことで、1画面のなかに数十件〜数百件のインシデントが気持ちよく収まるタイムラインになりました。

このノートに出てくるプロダクト

ほかのノート

ノートの一覧に戻る