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?

Turf.jsでコンビニに対するボロノイ図を作成してみる

Posted at

概要

Turf.jsを用いてGeoJSONのポイントデータからボロノイ図を作成してみました。
地図ライブラリはMapLibre GL JSを利用、ポイントデータはコンビニエンスストア所在地データを元にしています。

ボロノイ図の応用として、人口メッシュデータとのオーバーレイも試してみました。

ボロノイ図とは

地図上の複数地点から「最も近い範囲」を分割して可視化する手法です。エリアマーケティングにおいては各店舗の勢力圏を示すので、未カバー地域の特定や新規出店候補地の選定など出店分析に役立ちます。

作図イメージ

ポイントデータ

初台駅周辺のコンビニエンスストア所在地データを利用しています。
元データはGeofabrikからシェープファイルをダウンロードし、GeoJSON形式に変換するにはQGISを用いました。

シェープファイル例
kanto-250908-free.shp.zip  ・・・・・・  関東エリアの各SHPファイルが圧縮
    └  gis_osm_pois_free_1.shp  ・・・  コンビニを含むPOIデータ
            ├  属性:name
            └  属性:fclass  ・・・  fclass='convenience'で絞り込みました

QGISには、地図に表示した地物レイヤをGeoJSON形式等のフォーマットに変換し、エクスポートする機能があります。

弊社でも月次コンビニポイントデータを提供しています。また住所データに緯度・経度を付与するWEBジオコーディングサービスも提供しています。ご興味をお持ちでしたら、各リンク先をご参照下さい。

ボロノイポリゴンの取得

Turf.jsのAPIを利用したボロノイポリゴン取得例が以下になります。比較的少ないコードで実現することができます。

Turf.jsの取り込み

html
<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>

ポイントデータからボロノイポリゴンの取得

javascript
// pointsの型は FeatureCollection<Point> です

const VORONOI_BUFFER_DEGREES = 0.002;   // ボロノイ図のバッファ値(度)

const bbox = turf.bbox(points); // バウンディングボックス(外接矩形)
// バッファ値を加えたバウンディングボックスを算出
const expandedBbox = [
  bbox[0] - VORONOI_BUFFER_DEGREES, // minX
  bbox[1] - VORONOI_BUFFER_DEGREES, // minY
  bbox[2] + VORONOI_BUFFER_DEGREES, // maxX
  bbox[3] + VORONOI_BUFFER_DEGREES, // maxY
];
const voronoiPolygons = turf.voronoi(points, { bbox: expandedBbox });

バッファの効果
コードでは外接矩形に対して0.002度のバッファを持たせていますが、その違いは以下のようになりました。

参考情報

人口メッシュデータとのオーバーレイ

出店分析の第一歩として、人口メッシュデータとボロノイ図のオーバーレイ表示も試してみました。人口メッシュデータについてはTerraMap APIから取得できるGeoJSONを利用しています。人口データは赤色の濃淡で表現し、結果は以下のようになりました。

考察

折角なので私自身の考察も加えておきます。参考程度に読んでいただければと思います。

地図からは北西部の人口が、比較的多いことが分かります。「本町」「本町六丁目」等の濃い赤色で埋まっている広いエリア(外縁部を除く)が新規の出店候補地として比較的良い条件になるのかなと思いました。しかしながら、出店分析がこれで済むとは思えません。より出店分析の精度を上げるには、以下のようなことも行うべきかと思いました。

  • コンビニ以外で競合になりえる店舗データも加える
  • ボロノイポリゴン毎の人口密度計算と可視化を行う
  • 人流データ、滞在人口等も分析対象に加える
  • 空きテナント等、その他の立地条件も考慮する

ソース

最後に今回のソース(GeoJSONは除いています)を載せておきます。

ファイル構成
root/
 ├── index.html
 ├── stores.geojson
 └── mesh_population_2020.geojson
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>コンビニのボロノイ図</title>
    <link href="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.css" rel="stylesheet" />
    <style>
      #map { height: 95vh; width: 95vw; }
    </style>
  </head>
  <body>
    <div id="map"></div>
    <script src="https://unpkg.com/maplibre-gl@3.6.1/dist/maplibre-gl.js"></script>
    <script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
    <script>
      const OPACITY_STEPS_COUNT = 10;         // 人口メッシュのopacityの階級数
      const VORONOI_BUFFER_DEGREES = 0.002;   // ボロノイ図のバッファ値(度)

      // 地図の初期化
      const map = new maplibregl.Map({
        container: "map",
        style: "https://tile.openstreetmap.jp/styles/osm-bright/style.json",
        center: [139.686144361, 35.6810950557],
        zoom: 13,
      });
      map.addControl(new maplibregl.NavigationControl());

      map.on("load", async () => {
        // 人口メッシュデータを描画
        await drawPopulationMesh();
        // 店舗位置のボロノイ図を描画
        await drawVoronoiPolygon();
      });

      // データの最小値と最大値から、階級別のopacityを算出
      function getOpacitySteps(minValue, maxValue, stepsCount) {
        const baseOpacity = minValue > 0 ? 0.1 : 0.0;
        const dataStep = (maxValue - minValue) / stepsCount;
        const opacityStep = (0.9 - baseOpacity) / stepsCount;
        let steps = [];

        for (let i = 0; i < stepsCount; i++) {
          steps.push(minValue + dataStep * i);
          steps.push(baseOpacity + opacityStep * i);
        }
        return steps;
      }

      // 人口メッシュデータのopacity設定を取得
      function getOpacitySetting(minValue, maxValue) {
        return [
          "interpolate",
          ["linear"],
          ["to-number", ["get", "value", ["at", 0, ["get", "data"]]]],    // TerraMap APIデータ特有のデータ構造に対応
          ...getOpacitySteps(minValue, maxValue, OPACITY_STEPS_COUNT),
        ];
      }

      // 人口メッシュを描画
      async function drawPopulationMesh() {
        const response = await fetch("mesh_population_2020.geojson");
        const geojson = await response.json();

        // valueの最大値,最小値を計算
        const maxValue = Math.max(
          ...geojson.features.map((f) =>
            Number(f.properties.data?.[0]?.value ?? 0)    // TerraMap APIデータ特有のデータ構造に対応
          )
        );
        const minValue = Math.min(
          ...geojson.features.map((f) =>
            Number(f.properties.data?.[0]?.value ?? 0)    // TerraMap APIデータ特有のデータ構造に対応
          )
        );

        // ソースとレイヤーを追加
        map.addSource("population_mesh", {
          type: "geojson",
          data: {
            type: "FeatureCollection",
            features: geojson.features,
          },
        });
        map.addLayer({
          id: "population_mesh_layer",
          type: "fill",
          source: "population_mesh",
          paint: {
            "fill-color": "#E6002E",
            "fill-opacity": getOpacitySetting(minValue, maxValue),
          },
        });
      }

      // ボロノイ図を描画
      async function drawVoronoiPolygon() {
        const response = await fetch("stores.geojson");
        const data = await response.json();
        // Turfでボロノイ計算
        const points = turf.featureCollection(
          data.features.map((f) => turf.point(f.geometry.coordinates))
        );
        const bbox = turf.bbox(points); // バウンディングボックス(外接矩形)
        // バッファ値を加えたバウンディングボックスを算出
        const expandedBbox = [
          bbox[0] - VORONOI_BUFFER_DEGREES, // minX
          bbox[1] - VORONOI_BUFFER_DEGREES, // minY
          bbox[2] + VORONOI_BUFFER_DEGREES, // maxX
          bbox[3] + VORONOI_BUFFER_DEGREES, // maxY
        ];
        const voronoiPolygons = turf.voronoi(points, { bbox: expandedBbox });

        // geometryがnullでないfeatureだけを抽出
        const filteredVoronoi = {
          type: "FeatureCollection",
          features: voronoiPolygons.features.filter((f) => f && f.geometry),
        };

        if (filteredVoronoi.features.length > 0) {
          map.addSource("voronoi", {
            type: "geojson",
            data: filteredVoronoi,
          });
          // ボロノイ図の塗りつぶしを描画
          // map.addLayer({
          //   id: 'voronoi-fill',
          //   type: 'fill',
          //   source: 'voronoi',
          //   paint: {
          //     'fill-color': '#3388ff',
          //     'fill-opacity': 0.2
          //   }
          // });

          // ボロノイ図のラインを描画
          map.addLayer({
            id: "voronoi-line",
            type: "line",
            source: "voronoi",
            paint: {
              "line-color": "#3388ff",
              "line-width": 3,
            },
          });
        }
        // 店舗位置もマーカーで表示
        map.addSource("stores", {
          type: "geojson",
          data: points,
        });
        map.addLayer({
          id: "stores-layer",
          type: "circle",
          source: "stores",
          paint: {
            "circle-radius": 3,
            "circle-color": "#0000ff",
            "circle-stroke-width": 1,
            "circle-stroke-color": "#fff",
          },
        });
      }
    </script>
  </body>
</html>
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?