地図クリックで2km範囲内の将来推計人口指数をインタラクティブに可視化

## 完成画面
ユーザーがクリックしたポイントを中心に、半径2km以内の町丁目ポリゴンを抽出し、2020年人口データおよび将来推計人口データを集計しました。
地図上には該当範囲のポリゴンを表示し、集計結果は表形式で示しています。
集計対象の人口データ指標は以下のとおりです。
- 総人口
- 19歳以下人口
- 労働者人口（15～64歳）
- 高齢者人口（65歳以上）
- ミドル女性人口（40～50代の女性）

レーダーチャートでは、各指標の「人口指数」を可視化しています。
人口指数は、2020年国勢調査の人口を基準値（100）とし、将来推計人口の増減率を示す値です。
```例：2050年総人口指数 = (2050年総人口 / 2020年総人口) * 100```

![未来人口指数0423.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2916958/ee26ba65-d3c6-401c-8d7f-bff12e9eaf15.gif)


*地図の移動操作は省略しています

## データについて
今回使用した町丁目ポリゴンおよび将来推計人口データは、TerraMap APIから取得したGeoJSONレスポンスをもとにしています。

https://www.mapmarketing.co.jp/terramap-api?grid=qiita&utm_source=qiita.com&utm_medium=referral&utm_campaign=qiita_7b1a190b20df34cd0f1d

## コードについて
- 地図：OSM
- 地図ライブラリ：Leaflet
- データ取得：TerraMap API（GeoJSON形式）
- レーダーチャート：Chart.js

Leafletで取得したGeoJSONを読み込み、マップ上にポリゴンを描画するとともに、集計データを表やレーダーチャートに反映しています。
*バックエンドシステムから行うTerraMap APIへのリクエストの詳細については、こちらの記事を参考してください

https://zenn.dev/mapmarketing/books/35340d1c9874de/viewer/388803

*本番の時はサーバープログラムにAPIキーをセットして下さい
```javascript
// —— 初期設定 ——
const API_URL = 'TerraMap APIにリクエストするバックエンドのシステムのエンドポイント';  // ← 実際のエンドポイントに置き換えてください

// Leaflet 地図を作成
const map = L.map('map').setView([35.681236, 139.767125], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  attribution: '© OpenStreetMap contributors'
}).addTo(map);

// レイヤーグループ（円・ポリゴンを管理）
const circleLayer = L.layerGroup().addTo(map);
const polygonLayer = L.geoJSON(null, {
  style: { color: 'blue', weight: 2, fillOpacity: 0.2 }
}).addTo(map);

// Chart.js 用変数
let radarChart = null;
const ctx = document.getElementById('radarChart').getContext('2d');
const labels = ['総人口', '19歳以下人口', '労働力人口', '高齢者人口', 'ミドル女性人口'];

// —— クリック時の処理 ——
map.on('click', async (e) => {
  // 1) 既存レイヤーをクリア
  circleLayer.clearLayers();
  polygonLayer.clearLayers();

  // 2) クリック地点に半径1000mの円を描画
  L.circle(e.latlng, { radius: 2000, color: 'red', weight: 1, fillOpacity: 0.1 })
    .addTo(circleLayer);

  // 3) API リクエスト
  const body = {
    layer_id: "00104",
    area_type: "circle",
    center_lat: e.latlng.lat,
    center_lng: e.latlng.lng,
    radius: [2000],
    stats: [
      { stat_id: "001012000", stat_item_id:[15776,15781,15782,15783,15784,15804,15805,15847,15848,15849,15850] },
      { stat_id: "029002300", stat_item_id:[22611,22615,22708,22709,22710,22711,23020,23021,23022,23023,22730,23042,22731,23043,22768,22769,22770,22771,23080,23081,23082,23083] }
    ],
    output: "polygon,data,point,area_ratio"
  };

  const res = await fetch(API_URL, {
    method: 'POST',
    headers: {
        'Content-Type':'application/json',
        'X-API-KEY': 'APIキー'
    },
    body: JSON.stringify(body)
  });
  const geojson = await res.json();

  // 4) ポリゴンを地図に追加
  polygonLayer.addData(geojson);

  // 5) 表とチャートを更新
  updateTableAndChart(geojson.features);
});

// —— 表とチャート更新関数 ——
function updateTableAndChart(features) {
    // 1) 全ポリゴンをまたいで stat_item_id ごとに合計を作る
    const mapVal = {};
    features.forEach(f => {
      f.properties.data.forEach(d => {
        const id = d.stat_item_id;
        const val = Number(d.value);
        mapVal[id] = (mapVal[id] || 0) + val;
      });
    });
  
    // 2) 各年・各指標の値を計算
    const sum = ids => ids.reduce((s,i)=>s + (mapVal[i]||0), 0);
    const v2020 = [
      mapVal[15776],
      sum([15781,15782,15783,15784]),
      mapVal[15804],
      mapVal[15805],
      sum([15847,15848,15849,15850])
    ];
    const v2030 = [
      mapVal[22611],
      sum([22708,22709,22710,22711]),
      mapVal[22730],
      mapVal[22731],
      sum([22768,22769,22770,22771])
    ];
    const v2050 = [
      mapVal[22615],
      sum([23020,23021,23022,23023]),
      mapVal[23042],
      mapVal[23043],
      sum([23080,23081,23082,23083])
    ];
  
    // 3) 指数を算出
    const idx2030 = v2030.map((v,i)=>(v2020[i] ? (v/v2020[i]).toFixed(2) : '—'));
    const idx2050 = v2050.map((v,i)=>(v2020[i] ? (v/v2020[i]).toFixed(2) : '—'));
  
    // 4) 表を再描画
    const tbody = document.getElementById('data-table-body');
    const names = ['総人口','19歳以下人口','労働力人口','高齢者人口','ミドル女性人口'];
    let html = '';
    names.forEach((nm, i) => {
      html += `
        <tr>
          <td>${nm}</td>
          <td>${v2020[i]}</td><td>1.00</td>
          <td>${v2030[i]}</td><td>${idx2030[i]}</td>
          <td>${v2050[i]}</td><td>${idx2050[i]}</td>
        </tr>`;
    });
    tbody.innerHTML = html;

  // 5) チャート更新
  if (radarChart) radarChart.destroy();
  radarChart = new Chart(ctx, {
    type: 'radar',
    data: {
      labels,
      datasets: [
        {
          label: '2050年 指数',
          data: idx2050,
          fill: true
        },
        {
          label: '2030年 指数',
          data: idx2030,
          fill: true
        }
      ]
    },
    options: {
      scales: {
        r: {
          // 目盛りの範囲を 0 〜 2 に固定
          min: 0,
          max: 2,
          // 目盛りの間隔を 0.5 に
          ticks: {
            stepSize: 0.5,
            // 表示ラベルを 2 桁小数に
            callback: value => value.toFixed(2)
          },
          beginAtZero: true
        }
      },
      plugins: {
        legend: {
          position: 'top'
        }
      }
    }
  });
}

// 初期表示のセットアップ
window.addEventListener('DOMContentLoaded', () => {
    // 1) 表に空行を入れる
    const tbody = document.getElementById('data-table-body');
    const rowNames = ['総人口','19歳以下人口','労働力人口','高齢者人口','ミドル女性人口'];
    let html = '';
    rowNames.forEach(name => {
      html += `
        <tr>
          <td>${name}</td>
          <td>—</td><td>—</td>
          <td>—</td><td>—</td>
          <td>—</td><td>—</td>
        </tr>`;
    });
    tbody.innerHTML = html;
  
    // 2) 空チャートを初期化
    radarChart = new Chart(ctx, {
      type: 'radar',
      data: {
        labels,
        datasets: []  // データなし
      },
      options: {
        scales: {
          r: {
            min: 0,
            max: 2,
            ticks: {
              stepSize: 0.5,
              callback: v => v.toFixed(2)
            }
          }
        },
        plugins: {
          legend: {
            position: 'top'
          }
        }
      }
    });
  });
  
```
htmlとcssは以下のファイルに記載しています。
<details><summary>index.html</summary>

```html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Leaflet + Chart.js Map App</title>
  <link
    rel="stylesheet"
    href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
  />
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div id="container">
    <!-- 上半分：地図 -->
    <div id="map"></div>

    <!-- 下左：表 -->
    <div id="table-wrap">
        <table>
            <thead>
            <tr>
                <th rowspan="2"></th>
                <th colspan="2">2020年</th>
                <th colspan="2">2030年</th>
                <th colspan="2">2050年</th>
            </tr>
            <tr>
                <th>人口</th><th>指数</th>
                <th>人口</th><th>指数</th>
                <th>人口</th><th>指数</th>
            </tr>
            </thead>
            <tbody id="data-table-body">
            </tbody>
        </table>
    </div>

    <!-- 下右：レーダーチャート -->
    <div id="chart-wrap">
      <canvas id="radarChart"></canvas>
    </div>
  </div>

  <script
    src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
  ></script>
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  <script src="script.js"></script>
</body>
</html>
```
</details>

<details><summary>style.css</summary>

```css
#container {
    display: grid;
    grid-template-rows: 50vh 50vh;
    grid-template-columns: 1fr 1fr;
    height: 100vh;
    margin: 0;
}

#map {
    grid-row: 1 / 2;
    grid-column: 1 / 3;
    width: 100%;
    height: 100%;
}

#table-wrap {
    grid-row: 2 / 3;
    grid-column: 1 / 2;
    padding: 10px;
    overflow: auto;
}

#chart-wrap {
    grid-row: 2 / 3;
    grid-column: 2 / 3;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 10px;
    height: 100%;
}
  
#chart-wrap canvas {
    max-width: 90%;
    max-height: 90%;
}

table {
    width: 100%;
    border-collapse: collapse;
    text-align: center;
}
th, td {
    border: 1px solid #ccc;
    padding: 4px;
}
```

</details>