1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

deck.gl HexagonLayer で東京都の交通事故データを時系列 3D/2D 可視化する

Posted at

はじめに

本記事では、MapLibre と deck.gl を組み合わせて、交通事故データを「時間帯ごと」に 3D/2D 表示で切り替えられる可視化デモを構築します。

deck.gl は WebGL ベースの高性能な可視化ライブラリで、HexagonLayer を用いることで、緯度・経度データを滑らかに集計し、色や高さで密度を表現できます。

今回は以下のような構成で、最小限のコードだけで「動く時系列地図」を実装します:

  • 3D 表示:高さのある Hexagon(柱)で密度を表現
  • 2D 表示:高さを 0 にした平面 Hexagon(色のみ)で密度を表現
  • 時間帯スライダー:24 時間のどの時間帯に事故が集中しているかを即時切り替え
  • 表示モード切替:3D/2D をボタンで切り替え

完成イメージは次のようになります。

35da9f31ff4cbd84a45e-1.png

使用データについて

本記事では、警察庁が公開している「交通事故統計情報(本票データ)」を利用します。
データは以下から入手できます:

本票データには、日本全国で発生した交通事故の発生日時・場所・当事者情報などが含まれています。
ただしそのままでは次の課題があります:

  • 都道府県ごとに番号が振られている(東京=30 など)
  • 緯度・経度は DMS(度分秒)形式の整数値で提供される
  • 日付は複数の列に分かれている
  • ファイルサイズが大きい

交通事故統計情報(本票データ)のデータ仕様については過去記事で紹介しています

なお、厳密には「一般道」「高速道路」など道路種別ごとの集計を分けるほうが望ましいですが、本記事ではあくまで “時系列 × 地図 × 3D/2D 可視化の最小構成” にフォーカスするため、道路種別による分類は行いません

本記事で扱う形式(前処理済み JSON)

可視化をシンプルにするため、前処理後のデータは次のような 軽量 JSON に整形しています。

[
  { "lon": 139.7501, "lat": 35.6902, "hour": 14 },
  { "lon": 139.7205, "lat": 35.6801, "hour": 8  }
]

ファイル構成

本記事では、MapLibre × deck.gl を最小構成で動かすために、以下の 4 ファイルだけを使用します。

project-root/
├── index.html
├── css/
│ └── style.css
├── js/
│ └── app.js
└── data/
  └── accidents_tokyo_timeline.json

各ファイルの役割

  • index.html
    MapLibre と deck.gl の CDN を読み込み、地図と UI(ボタン・スライダー)を配置

  • css/style.css
    ボタン・スライダー・地図のレイアウト用の最小スタイル

  • js/app.js
    地図初期化、deck.gl のレイヤ適用、時間帯スライダーと表示モード(3D/2D)切り替えなど、主要ロジックがすべてここに含まれる

  • data/accidents_tokyo_timeline.json
    前処理済みの事故データ(緯度・経度・時間帯)

使用する CDN(必要なライブラリ)

index.html では次のライブラリを CDN 経由で読み込みます。

  • MapLibre GL JS
  • deck.gl(MapboxOverlay 含む)

CDN を利用することで、追加インストールなしですぐに動作確認できます。

このファイル構成をベースに、次章から順に実装内容を解説していきます。

地図と deck.gl

本記事では、MapLibre 上に deck.gl の可視化レイヤ(HexagonLayer)を重ねて表示します。
その際にポイントとなるのが deck.gl の MapboxOverlay を利用する構成です。

MapboxOverlay を使うことで、次のことが実現できます。

  • MapLibre と deck.gl が 同じ WebGL コンテキストを共有できる
  • 地図のパン/ズームに合わせて deck.gl レイヤが自動で再描画される
  • overlay.setProps({ layers: [...] }) で地図上に可視化レイヤを重ねられる

これにより、地図の操作性と描画パフォーマンスを保ちながら、
HexagonLayer の 3D/2D 表示や、時間帯別のデータ切替をスムーズに行えます。

app.js の全体像

このスクリプトでは、次のような役割をまとめて実装します。

  • 事故データ(accidents_tokyo_timeline.json)の読み込み
  • 表示モード(3D / 2D)と時間帯(0〜23 時)の管理
  • MapLibre の初期化と視点(pitch / bearing)の制御
  • deck.gl MapboxOverlay の初期化
  • HexagonLayer の生成
    • 3D 表示:elevationScale > 0
    • 2D 表示:elevationScale = 0(高さだけ 0 にする)
  • スライダーとボタンからのイベントを受け取り、updateLayer() で再描画

サンプルコード

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>交通事故データ 可視化デモ(MapLibre × deck.gl)</title>

    <!-- MapLibre GL JS -->
    <link
      href="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.css"
      rel="stylesheet"
    />
    <script src="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.js"></script>

    <!-- deck.gl -->
    <script src="https://unpkg.com/deck.gl@8.9.36/dist.min.js"></script>

    <!-- Styles -->
    <link rel="stylesheet" href="css/style.css" />
  </head>

  <body>
    <!-- 地図本体 -->
    <div id="map"></div>

    <!-- 地図の上に重ねるコントロール -->
    <div class="controls">
      <div class="mode-buttons">
        <button id="btn-3d" class="active">3D</button>
        <button id="btn-2d">2D</button>
      </div>

      <div class="timeline">
        <input id="slider" type="range" min="0" max="23" value="0" />
        <span id="step-label">00時</span>
      </div>
    </div>

    <!-- スクリプト -->
    <script src="js/app.js"></script>
  </body>
</html>

css/style.css

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
}

/* 地図を全画面に表示 */
#map {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
}

/* 3D / 2D ボタン:右上 */
.mode-buttons {
  position: fixed;
  top: 10px;
  right: 10px;
  display: flex;
  gap: 8px;
  z-index: 1000;
  background: rgba(255, 255, 255, 0.9);
  padding: 6px 10px;
  border-radius: 6px;
  border: 1px solid #ccc;
}

.mode-buttons button {
  padding: 6px 14px;
  border: 1px solid #444;
  background: #fff;
  cursor: pointer;
  border-radius: 4px;
  font-size: 14px;
}

.mode-buttons button.active {
  background: #eee;
}

/* タイムライン:下部中央(背景付きのオーバレイ) */
.timeline {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 6px;
  align-items: center;
  z-index: 1000;
  background: rgba(255, 255, 255, 0.9);
  padding: 8px 12px;
  border-radius: 6px;
  border: 1px solid #ccc;
  font-size: 14px;
}

#slider {
  width: 240px;
}

js/app.js

// js/app.js

const DATA_URL = "data/accidents_tokyo_timeline.json";

const CENTER_LON = 139.76;
const CENTER_LAT = 35.68;
const HOUR_MIN = 0;
const HOUR_MAX = 23;

let rawData = [];
let currentMode = "3d";
let currentHour = 0;

const sliderEl = document.getElementById("slider");
const stepLabelEl = document.getElementById("step-label");
const btn3d = document.getElementById("btn-3d");
const btn2d = document.getElementById("btn-2d");

// -------------------------------
// MapLibre 初期化
// -------------------------------
const map = new maplibregl.Map({
  container: "map",
  style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
  center: [CENTER_LON, CENTER_LAT],
  zoom: 10,
  pitch: 35,
  bearing: -15,
});

map.addControl(new maplibregl.NavigationControl(), "top-left");

// deck.gl overlay
const overlay = new deck.MapboxOverlay({
  interleaved: true,
  layers: [],
});

map.addControl(overlay);

// -------------------------------
// hour → "00時" の表示形式
// -------------------------------
function formatHourLabel(hour) {
  const h = String(hour).padStart(2, "0");
  return `${h}時`;
}

// -------------------------------
// データ読み込み
// -------------------------------
async function loadData() {
  try {
    const res = await fetch(DATA_URL);
    const records = await res.json();

    if (!Array.isArray(records) || !records.length) return;

    rawData = records
      .filter(
        (r) =>
          r.hour != null &&
          r.lon != null &&
          r.lat != null &&
          !Number.isNaN(Number(r.hour))
      )
      .map((r) => ({
        ...r,
        timeStep: Number(r.hour),
      }));

    sliderEl.min = String(HOUR_MIN);
    sliderEl.max = String(HOUR_MAX);
    sliderEl.value = String(HOUR_MIN);

    currentHour = HOUR_MIN;
    stepLabelEl.textContent = formatHourLabel(currentHour);

    updateLayer();
  } catch (e) {
    console.error("データ読み込みに失敗しました:", e);
  }
}

// -------------------------------
// HexagonLayer 生成
// 3D/2D は elevationScale で制御
// -------------------------------
function getLayer(mode, hour) {
  if (!rawData.length) return null;

  const data = rawData.filter((d) => d.timeStep === hour);

  const commonProps = {
    data,
    getPosition: (d) => [d.lon, d.lat],
    getElevationWeight: () => 1,
    elevationAggregation: "SUM",
    getColorWeight: () => 1,
    colorAggregation: "SUM",
    radius: 250,
    opacity: 0.9,
    coverage: 0.8,
    extruded: true,
    colorRange: [
      [255, 255, 204],
      [161, 218, 180],
      [65, 182, 196],
      [44, 127, 184],
      [37, 52, 148],
      [8, 29, 88],
    ],
  };

  if (mode === "3d") {
    return new deck.HexagonLayer({
      id: "hex-3d",
      ...commonProps,
      elevationScale: 15, // ← 高さあり
    });
  }

  return new deck.HexagonLayer({
    id: "hex-2d",
    ...commonProps,
    elevationScale: 0, // ← 高さゼロで平面表示
  });
}

// -------------------------------
// レイヤ更新
// -------------------------------
function updateLayer() {
  if (!rawData.length) return;
  const layer = getLayer(currentMode, currentHour);
  overlay.setProps({ layers: [layer] });
  stepLabelEl.textContent = formatHourLabel(currentHour);
}

// -------------------------------
// UI イベント
// -------------------------------
sliderEl.addEventListener("input", (e) => {
  currentHour = Number(e.target.value);
  updateLayer();
});

btn3d.addEventListener("click", () => {
  currentMode = "3d";
  btn3d.classList.add("active");
  btn2d.classList.remove("active");

  map.easeTo({
    pitch: 35,
    bearing: -15,
    duration: 500,
  });

  updateLayer();
});

btn2d.addEventListener("click", () => {
  currentMode = "2d";
  btn2d.classList.add("active");
  btn3d.classList.remove("active");

  map.easeTo({
    pitch: 0,
    bearing: 0,
    duration: 500,
  });

  updateLayer();
});

// -------------------------------
map.on("load", () => {
  loadData();
});

可視化パラメータの調整ポイント

HexagonLayer の見た目は、いくつかのパラメータを調整することで大きく変わります。
この記事のサンプルでは最小構成にまとめていますが、目的に応じて以下の項目を調整すると可視化を最適化できます。

radius(六角形の大きさ)

六角形 1 セルの広がりを決めるパラメータです。
細かい局所パターンを見たい場合は小さく、広域的な傾向を示したい場合は大きく設定します。

radius: 250,

elevationScale(高さの強さ)

3D 表示時の高さの強調具合です。
2D 表示では高さを 0 にして平面として描画します。

// 3D
elevationScale: 15,

// 2D
elevationScale: 0,

colorRange(色のスケール)

密度に応じて色を変化させるグラデーションです。
事故データのように偏りが大きいデータでは、寒色系〜中間色の階調が見やすい傾向があります。

colorRange: [
  [255, 255, 204],
  [161, 218, 180],
  [65, 182, 196],
  [44, 127, 184],
  [37, 52, 148],
  [8, 29, 88],
],

pitch / bearing(視点の角度)

MapLibre 側の視点制御です。
3D 表示では傾きを付け、2D 表示では真上からの視点に切り替えています。

// 3D 表示時の視点
map.easeTo({
  pitch: 35,
  bearing: -15,
  duration: 500
});

// 2D 表示時の視点
map.easeTo({
  pitch: 0,
  bearing: 0,
  duration: 500
});

opacity / coverage(透明度と塗りの広がり)

視覚的な重なり具合や広がりを調整するパラメータです。
密度の強弱を強調したい場合に役立ちます。

opacity: 0.9,
coverage: 0.8,

表示目的に合わせて自由に調整可能です。

まとめ

本記事では、MapLibre と deck.gl を組み合わせ、東京都の交通事故データをもとに「時間帯で変化する 3D/2D ヒートマップ」を構築しました。
deck.gl の HexagonLayer を利用することで、密度集計やグリッド分割を自動で行えるため、少ない記述で時系列の変化を表現できます。

3D/2D ボタンによる視点切り替えや、スライダーによる時間帯切り替えを組み合わせることで、事故発生の傾向を直感的に把握できます。

今後は、オープンデータに含まれる属性(道路種別、年代、天候、事故類型など)を組み合わせることで、特定条件下での傾向分析に発展させることができます。
地域や年度を変えて適用することも容易で、さまざまな事故分析に応用できます。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?