8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【コロプレスマップ】Leafletを使って統計データを地図で可視化してみた!

Posted at

はじめに

本記事はLIFULL Advent Calendar 2025の12日目の記事です。

こんにちは!1年目のエンジニアのつぶきと申します。
普段は不動産売却一括査定サイト LIFULL HOME'S 不動産査定 の開発を担当しています。

今回はインタラクティブな地図を埋め込めるJavascriptライブラリ「Leaflet」を用いて、
市区町村ごとの所得や人口などのオープンデータを色分けされた地図、コロプレスマップ(階級区分図)という形式で比較できるツールを作ってみました。

この記事で学べること

  • Leafletを用いたの地図サービスの基本実装
  • GeoJSONからTopoJSONへの変換方法、メリットとデメリット
  • chroma.jsを用いたカラースケールの実装と、関数の検討

以下のようなアプリを作成しました。
image.png

背景

営業の先輩と晩御飯を食べていた日...
不動産売買の成約件数や人口動態などのオープンデータがわかりやすく比較できると営業活動に役立ちそうという話になりました。その時は思いつきで、色分けすればわかりやすいし、マーケティングの方にも役立つんじゃないかと発想しました。その見せ方がベストかは検討していませんでしたが、少し調べた限り、技術的にもそれほど難しそうではなく、(真夏の)営業研修後ということでモチベーションもあったので、実装してみることにしました。

今回はあまり詳しく書きませんが、一番難しかったのは、どんなデータをどこから取ってくるかという部分で、営業ともかなり話し合いをしました...。政府統計e-Statなどを眺めてみると面白そうな統計がたくさんあるので、是非覗いてみてください!

実装

技術スタック

  • HTML5
  • JavaScript
    • Leaflet.js 1.9.4:地図の実装
    • CartoDB Dark Matter:ベースマップタイル
    • Chroma.js 2.4.2:カラースケール生成
    • TopoJSON Client 3.1.0:地図データの変換・表示
  • Amazon S3:ホスティング

社内向け静的コンテンツとして配信しました。

ディレクトリ構成

できるだけ、データの管理のしやすさに重きをおいて簡素な作りにしました。

~/
┣━━ data/           # 統計データ
┣━━ mapdata/        # 地図データ
┣━━ data_config.yml # データ管理用ファイル
┣━━ index.html
┣━━ style.css
┗━━ script.js

データの用意

統計データ

例として、e-Statに掲載されている「住民基本台帳に基づく人口、人口動態および世帯数調査(2025)」のデータを用います。データをダウンロード、以下のJson形式に整形して、data/に格納します。

population_data.json
[
  {
    "都道府県名": "東京都",
    "市区町村": "千代田区",
    "人口_計": 66680,
    "人口_男": 34419,
    "人口_女": 32261,
    "世帯数": 37067,
    "転入者数_計": 8234,
    "転出者数_計": 7156,
    "増減率": 0.52,
    "自然増減率": -0.18,
    "社会増減率": 0.70
  },
  {
    "都道府県名": "東京都",
    "市区町村": "中央区",
    "人口_計": 172253,
    "人口_男": 86891,
    "人口_女": 85362,
    "世帯数": 103424,
    "転入者数_計": 18234,
    "転出者数_計": 15678,
    "増減率": 1.23,
    "自然増減率": 0.15,
    "社会増減率": 1.08
  }
  ...
]

地図データ

国土数値情報の市区町村単位の地図データを用いました。以下がGeoJSONファイルの例です。

N03_20250101.geojeon
{
"type": "FeatureCollection",
"name": "N03-20250101",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::6668" } },
"xy_coordinate_resolution": 1e-09,
"features": [
{ "type": "Feature", "properties": { "N03_001": "北海道", "N03_002": "石狩振興局", "N03_003": null, "N03_004": "札幌市", "N03_005": "中央区", "N03_007": "01101" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 141.342326939, 43.066815838 ], [ 141.342847782, 43.066810559 ], [ 141.34325917, 43.066820838 ], [ 141.34380083, 43.066878333 ], [ 141.344091946, 43.066910559 ] ] ] } },
{ "type": "Feature", "properties": { "N03_001": "北海道", "N03_002": "石狩振興局", "N03_003": null, "N03_004": "札幌市", "N03_005": "北区", "N03_007": "01102" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 141.408387224, 43.183947505 ], [ 141.408333333, 43.183933 ], [ 141.408262218, 43.183913892 ], [ 141.408100558, 43.183908613 ], [ 141.407808885, 43.183942505 ] ] ] } },
...
]
}

問題1: データの容量と読み込み時間

GeoJSONは各領域を複数の座標で囲むようにして表現する地図データにおける標準的な規格です。しかし、実はこの形式、各領域の境界を重複して保存してしまうため、かなり効率が悪いのです。

離島を含む47都道府県の市区町村情報を格納したファイルの容量は驚異の 583MB もありました。この超ヘビー級のデータは、読み込み時間を引き伸ばして、ユーザー体験を著しく低下させる主要因になってしまいます。実際、開発当初、GeoJSON形式のデータをそのまま使用したところ、初期読み込み時間は 約2~3分 もかかってしまい、使い物になりませんでした。

そこで、TopoJSON という形式に変換して圧縮する手法を用いました。
TopoJSONは、各領域の境界線を重複せず保存する形式で、連続した領域を表現したい地図においては、GeoJSONと比べて遥かに効率の良い形式です。
また、TopoJSONに変換した上で、視認性に支障がない範囲で境界線の精度を下げることで更に圧縮することもできます。

GeoJSON→TopoJSONへの変換・圧縮

GeoJSONデータをTopoJSONへ変換したり、TopoJSONを操作したりするために必要なNode.jsパッケージをいくつかインストールします。

npm install topojson-server topojson-client topojson-simplify

TopoJSONへ変換します。

npx geo2topo japan=mapdata/N03-20250101.geojson > mapdata/N03-20250101.topojson

境界線の精度を落として圧縮します。-pは許容誤差の度合いを指定するパラメータで、大きくするほど、より簡素化されてファイルサイズが小さくなります。

npx toposimplify -p 0.000001 -f mapdata/N03-20250101.topojson > mapdata/N03-20250101_simplified.topojson

この圧縮によって、サイズはおよそ100分の1の 8MB 程度になり、キャッシュなどの実装と合わせると、初期読み込み時間は最大5秒程度まで減少しました。

データの登録・管理

新しいデータセットの追加やデータラベルの変更、非表示設定などをdata_config.ymlというファイル1つで設定を記述できるようにしました。(設定駆動設計)
yaml形式はjsonと比べて可読性・書きやすさに優れ、大量の統計・地図データの管理が容易なので採用しています。

data_config.yml
data_sets:
  population_stats:
    data_name: "人口統計"
    file_name: "population_data.json"
    map_type: city
    source_text: "e-stat 住民基本台帳"
    source_url: "https://www.e-stat.go.jp/..."
    source_date: "2025年"
    active: true
    columns:
      levels:
        都道府県名:
          level: 都道府県
        市区町村:
          level: 市区町村
      data:
        人口_計:
          label: 人口総数
          unit: 
          format: integer
          map_display: true
          tooltip_display: true
        増減率:
          label: 人口増減率
          unit: "%"
          format: float
          map_display: true
          tooltip_display: true
        世帯数:
          label: 世帯数
          unit: 世帯
          format: integer
          map_display: true
          tooltip_display: true

map_configs:
  city:
    files:
      - N03-20250101_simplified.topojson
    label: "市区町村(全国)"
    levels:
      N03_001: 都道府県
      N03_003,N03_004,N03_005: 市区町村
      

JavaScript側での読み込み

script.js
let config = null;

async function loadConfig() {
    try {
        const response = await fetch('data_config.yml');
        const yamlText = await response.text();
        config = jsyaml.load(yamlText);  // YAMLをJavaScriptオブジェクトに変換
        return config;
    } catch (error) {
        console.error('設定ファイルの読み込みに失敗:', error);
        throw error;
    }
}

地図の実装

Leaflet.jsを使用して地図を実装し、地図データと統計データを結合して表示します。

Leafletで地図を表示するためには、地図のスタイルやズームレベルを提供するベースマップタイルというものが必要です。

script.js
function initMap() {
    // [35.6762, 139.6503] (東京近辺) を中心に、ズームレベル10で地図を初期化
    map = L.map('map').setView([35.6762, 139.6503], 10);
    
    // ベースマップタイルの設定
    L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
        attribution: '© OpenStreetMap contributors © CARTO',   // 著作権表示
        maxZoom: 19
    }).addTo(map);
}

LeafletにはTopoJSONを直接扱う機能がないため、topojson-clientライブラリを使ってブラウザ上でGeoJSONに再び変換します。
軽量なTopoJSON形式に圧縮してブラウザ側へ高速で送信して、ブラウザ上ではLeafletで扱うためGeoJSONに解凍してあげるようなイメージです。

script.js
async function loadGeoJSON(file) {
    const response = await fetch(`mapdata/${file}`);
    // TopoJSONファイルの場合(地図データ)
    if (file.endsWith('.topojson')) {
        const topology = await response.json();
        
        // TopoJSONをGeoJSONに変換
        const objectName = Object.keys(topology.objects)[0];
        const geojson = topojson.feature(topology, topology.objects[objectName]);
        
        return geojson;
    }
    
    // GeoJSONファイルの場合(統計データ)
    return await response.json();
}

data_config.ymlで設定したエリアを連結して一意のキーを生成します。

script.js
function generateDataKey(feature, levels) {
    const keyValues = Object.keys(levels).map(key => {
        // 例:'N03_003,N03_004,N03_005: 市区町村'
        if (key.includes(',')) {
            const fields = key.split(',');
            return fields.map(field => {
                const value = feature.properties[field.trim()];
                return (value === null || value === undefined) ? '' : value;
            }).join('');
        }
        // 例:'N03_001: 都道府県'
        return feature.properties[key] || '';
    });
    return keyValues.join('');
}

統計データに関しても同様です。

script.js
function mapStatisticsDataKey(item, mapLevels, dataLevels) {
    // dataLevels (統計データ側の定義) を逆引き辞書 LevelName -> DataFieldName に変換
    // 例: { '都道府県': '都道府県名', '市区町村': '市区町村' }
    const levelToDataField = {};
    Object.entries(dataLevels).forEach(([dataField, config]) => {
        levelToDataField[config.level] = dataField;
    });
    
    // GeoJSON側のキー定義 (mapLevels) の順序に合わせて統計データの値を取得する
    const keyValues = Object.keys(mapLevels).map(mapKey => {
        // GeoJSON側が要求するレベル名 (例: '都道府県') を取得
        const levelName = mapLevels[mapKey]; 
        // そのレベル名に対応する統計データのフィールド名 (例: '都道府県名') を取得
        const dataFieldName = levelToDataField[levelName];
        
        // 統計データアイテムから値を取得 (例: item['都道府県名'])
        return item[dataFieldName] || '';
    });
    return keyValues.join('');
}

2つのデータを結合して表示します。

script.js
let config = null;

async function loadData() {
    const loadingOverlay = document.getElementById('loading');
    
    try {
        loadingOverlay.classList.add('show');
        
        if (!config) {
            await loadConfig(); 
        }

        const dataConfig = config.data_sets[currentDataType]; // 選択中のデータセットの設定
        const mapConfig = config.map_configs[dataConfig.map_type]; // 該当する地図の設定
        
        const loadGeoJSON = async (file) => {
            // 前述
        };
        
        const loadStatistics = async (file) => {
            const response = await fetch(`data/${file}`);
            return await response.json();
        };
        
        // 1. 地図ファイルとデータファイルを並行で読み込む
        const [mapDataArray, statisticsData] = await Promise.all([
            Promise.all(mapConfig.files.map(file => loadGeoJSON(file))),
            loadStatistics(dataConfig.file_name)
        ]);
        
        // 2. 統計データをキーでインデックス化
        const dataIndex = {};
        statisticsData.forEach(item => {
            const key = mapStatisticsDataKey(item, mapConfig.levels, dataConfig.columns.levels);
            dataIndex[key] = item;
        });
        
        // 3. 地図データ(Feature)を統計データに基づいてフィルタリング
        const allFeatures = mapDataArray.flatMap(data => data.features);
        const targetAreas = Object.keys(dataIndex); // データが存在する地域のキーリスト
        
        const filteredFeatures = allFeatures.filter(feature => {
            // GeoJSON Featureからキーを生成し、統計データが存在するかチェック
            const featureKey = generateDataKey(feature, mapConfig.levels);
            return targetAreas.includes(featureKey); 
        });
        
        // 4. データを結合
        filteredFeatures.forEach(feature => {
            const featureKey = generateDataKey(feature, mapConfig.levels);
            
            // 結合キーをFeature名としてプロパティに保存
            feature.properties.name = featureKey; 
            
            // 統計データを GeoJSON Feature の properties にマージ
            if (dataIndex[featureKey]) {
                Object.assign(feature.properties, dataIndex[featureKey]);
            }
        });
        
        // 5. 地図データの更新
        mapData = { type: "FeatureCollection", features: filteredFeatures };
        
        updateAvailablePrefectures();     // データが存在する都道府県の判定
        updatePrefectureCheckboxStates(); // 表示する都道府県の選択状況の判定
        updateMap();                      // 地図の表示

    } catch (error) {
        console.error('データの読み込みに失敗:', error);
    } finally {
        loadingOverlay.classList.remove('show');
    }
}

対象のエリアをクリックしたときに統計データを表示するツールチップを作成して、アタッチしましょう。

script.js
function generatePopupContent(feature) {
    const props = feature.properties;
    const columnsConfig = config.data_sets[currentDataType].columns.data;
    let popupContent = `<strong>${props.name}</strong><br><br>`;
    
    // data_cofing.ymlの設定に基づいて表示項目を生成
    Object.entries(columnsConfig).forEach(([key, columnConfig]) => {
        if (columnConfig.tooltip_display) {
            const value = props[key];
            let displayValue = 'N/A';
            
            if (value !== undefined && value !== null) {
                if (columnConfig.format === 'integer') {
                    displayValue = Math.round(value).toLocaleString();
                } else if (columnConfig.format === 'float') {
                    displayValue = Number(value).toFixed(2);
                }
            }
            
            popupContent += `${columnConfig.label}: ${displayValue}${columnConfig.unit}<br>`;
        }
    });
    return `<div>${popupContent}</div>`;
}

image.png

問題2: カラースケールの検討

値の大きいエリアを赤、小さいエリアを青として表示したいとします。
最大値と最小値の時の色を決めて、その間をグラデーションにすれば良さそうですね。
統計データには全国程よくなだらかに分布しているデータもあれば、特定の県や市区町村の値だけ外れ値を取るようなデータもあります。

例えば、課税対象所得です。全国的な平均は400万円〜500万円程度ですが、東京都港区の課税対象所得は2,600万円です。笑ってしまいますね。

これを単純に等間隔の(線形的な)スケールで色を割り振ってしまうと、港区だけが真っ赤に染まり、その他の市区町村は真っ青に表示されてしまいます。

最初の設計ではこの問題を解決するために、対数スケールを用いました。

\mathrm{Color} = \log_{10} \mathrm{(Data)}

この関数は、入力するデータの桁数が大きいほど、値がなだらかになるという特性があり、港区の所得のような外れ値がある場合でも、ある程度自然なグラデーションになります。

しかし、この対数スケールにはある問題がありました。それは、データがマイナスであるときは定義できないというものです。

負の値が出てくるデータは、例えば人口増加率など、昨年や他のデータとの差をとるような指標などです。色分け用のデータを下駄を履かせて別で作ることもできますが。比率などのデータに対してはその分布によって、都度適切に定義しなければなりません。また、数値に下駄を履かせてしまうと、比率の意味も少し変わってきてしまうので適切ではありません。

この問題を解決するために、asinh (逆双曲線) スケールを使うことにしました。 このasinhは対数のような外れた値を適度にならす性質を持ち、なおかつ負の値に対しても自然に拡張された関数です。

\mathrm{Color} = \mathrm{asinh} \mathrm{(Data)} = \log_e{\bigg\{\mathrm{(Data)+\sqrt{ \mathrm(Data)^2 + 1 }}\bigg\}}

このスケールを利用することで、すべてのデータに対して適切な色分けが表現することができました。

このスケールの取り方による変化の分かりやすい例として人口総数を見てみましょう。
等間隔の線形スケールを用いて表示した時のコロプレスマップが以下です。

image.png

真ん中の真っ赤なエリアは世田谷区です。あまりグラデーションが連続ではなく、特に、青色のエリアでは見分けがつきにくいです。

次に、asinhスケールを用いて表示した時のコロプレスマップが以下です。

image.png

グラデーションがなめらかになり、都心に向かって、人口が増加していることが一目でわかるようになったと思います。少し、表示範囲を広くしたものが以下になります。

image.png

色付け実装

それでは、実際にエリアに色をつけていきます。
色のグラデーションはChroma.jsというライブラリを使用します。mode('lab')(LABカラースペース)という設定をしておくと、明度や彩度をよしなに調節してくれます。

script.js
const colorScale = chroma.scale([
    '#000080',  // 濃い青(最小値)
    '#4080ff',  // 青
    '#80c0ff',  // 水色
    '#ffffcc',  // 薄黄色(中間値)
    '#ffcc80',  // オレンジ
    '#ff8040',  // 濃いオレンジ
    '#cc0000'   // 赤(最大値)
]).mode('lab');

定義したグラデーションに対してスケールを割り振っていきます。

script.js
function getColor(value, min, max) {
    // データがない場合はグレーを返す
    if (value === undefined || value === null || min >= max) {
        return '#e8e8e8';
    }
    
    // asinhスケールで変換
    const asinhValue = Math.asinh(value);
    const asinhMin = Math.asinh(min);
    const asinhMax = Math.asinh(max);
    
    // 0〜1の範囲に正規化
    const ratio = (asinhValue - asinhMin) / (asinhMax - asinhMin);
    
    // カラースケールから色を取得
    return colorScale(ratio).hex();
}

カラースケールの割り当てができたら、エリアに割り当てていきます。

script.js
function style(feature) {
    const value = feature.properties[currentData];
    
    return {
        fillColor: getColor(value, currentMin, currentMax), 
        weight: 1,
        color: '#ffffff', // 境界線:白
        fillOpacity: value !== undefined ? 0.8 : 0.3 // データがない場合は透過度を下げる
    };
}

凡例としてカラーバーも実装するとデータの比較がさらにしやすくなりますね。

おわりに

今回は、Leafletライブラリを使ってオープンデータをコロプレスマップとして可視化するツールを作ってみました。設計がとてもシンプルにまとめられて非常に良かったかなと思います。実際に使っていただいた営業の方から好評の声をいただいており、とても嬉しい限りです。もっとアウトプットが増やして、世の中に貢献できるように研鑽を積んで参ります...。

それでは!

8
2
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
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?