👻

PMTilesに大量の点データを格納して、プロパティをPopupで表示する

2024/08/05に公開

こんにちは

石橋です

PMTilesシリーズです

これまではベクタデータの中でもポリゴンの表示などをしてきましたが
今回は、ポイントデータを扱ってみます

PMTilesの作成

とりあえず点データを作ります

いい感じに生成するpythonコードを用意しました

import random
import json

# 範囲を指定
min_lat = 33.35571291973764
max_lat = 33.824739326505025
min_lon = 129.7135111827247
max_lon = 131.43287138802773

# ランダムな名前と説明を生成する関数
def generate_random_name():
    return "Location_" + str(random.randint(1000, 9999))

def generate_random_description():
    return "Description_" + str(random.randint(1000, 9999))

# 10000点のランダムなポイントを生成
points = []
for i in range(10000):
    lat = random.uniform(min_lat, max_lat)
    lon = random.uniform(min_lon, max_lon)
    point_class = i // 1000  # 1000件ごとに0〜9のクラスを割り当てる
    point = {
        "type": "Feature",
        "properties": {
            "name": generate_random_name(),
            "description": generate_random_description(),
            "class": point_class
        },
        "geometry": {
            "type": "Point",
            "coordinates": [lon, lat]
        }
    }
    points.append(point)

# GeoJSONデータの作成
geojson_data = {
    "type": "FeatureCollection",
    "features": points
}

# GeoJSONファイルとして保存
with open('../data/random_points_with_class.geojson', 'w') as f:
    json.dump(geojson_data, f, indent=4)

保存先の指定や、緯度経度の範囲は適当にカスタムしてください

geojsonができたら、これをPMTilesに変換します

以前までと違うポイントとして、点データをクラスタリングすることを指定することです

PMTilesは一部のデータをフロントに転送するため、事前にクラスタリング等の計算を行っておく必要があります。それらの機能はtippecanoeに用意されているので指定してあげます

tippecanoe -z17 -Z2 -o random_points.pmtiles --no-tile-compression -r1 --cluster-distance=50 --cluster-densest-as-needed random_points_with_class.geojson

コマンドについてはこちらの記事の事例を参考にしました

https://qiita.com/sanskruthiya/items/5f34e0d153e2b1337c11#ベクトルタイルの生成

PMTilesが出来上がったらS3に設置しましょう

jsでの実装

import React, { useRef, useEffect, useState } from 'react';
import maplibregl, { Map } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Protocol, PMTiles } from 'pmtiles';

const PMTILES_URL = "PMTilesへのURL";

const DisplayClusteredMarkers: React.FC<{}> = () => {
  const mapContainer = useRef(null);
  const map = useRef<Map | null>(null);
  const [mapState, setMapState] = useState({ lat: 33.5676, lon: 130.4102, zoom: 9 });

  useEffect(() => {
    if (map.current) return;

    // PMTiles Protocol設定
    const protocol = new Protocol();
    maplibregl.addProtocol("pmtiles", protocol.tile);
    const p = new PMTiles(PMTILES_URL);
    protocol.add(p);

    // 地図の初期設定
    map.current = new maplibregl.Map({
      container: mapContainer.current!,
      center: [mapState.lon, mapState.lat],
      zoom: mapState.zoom,
      style: {
        version: 8,
        sources: {
          "osm": {
            "type": "raster",
            "tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
            "tileSize": 256,
            "attribution": '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
          },
          "random_points": {
            "type": "vector",
            "url": "pmtiles://" + PMTILES_URL,
            "attribution": '© <a href="https://openstreetmap.org">OpenStreetMap</a>'
          }
        },
        glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
        layers: [
          {
            "id": "osm-tiles",
            "type": "raster",
            "source": "osm",
            "minzoom": 0,
            "maxzoom": 19,
          },
          {
            "id": "clusters",
            "type": "circle",
            "source": "random_points",
            "source-layer": "random_points_with_class",  // PMTilesのレイヤー名を確認
            "filter": ["has", "point_count"],
            "paint": {
              "circle-color": "#51bbd6",
              "circle-radius": ["step", ["get", "point_count"], 20, 100, 30, 750, 40],
            }
          },
          {
            "id": "cluster-count",
            "type": "symbol",
            "source": "random_points",
            "source-layer": "random_points_with_class",
            "filter": ["has", "point_count"],
            "layout": {
              "text-field": "{point_count_abbreviated}",
              "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"],
              "text-size": 12,
              "text-anchor": "center",
            }
          },
          {
            "id": "unclustered-point",
            "type": "circle",
            "source": "random_points",
            "source-layer": "random_points_with_class",
            "filter": ["!", ["has", "point_count"]],
            "paint": {
              "circle-color": "#11b4da",
              "circle-radius": 6,
            }
          }
        ]
      }
    });

    // ズーム、移動時に緯度経度とズームを更新
    map.current.on('move', () => {
      setMapState({
        lat: Number(map.current!.getCenter().lat.toFixed(4)),
        lon: Number(map.current!.getCenter().lng.toFixed(4)),
        zoom: Number(map.current!.getZoom().toFixed(2))
      });
    });

    // クラスターのクリック時にズーム
    map.current.on('click', 'clusters', async (e) => {
      const features = map.current!.queryRenderedFeatures(e.point, {
        layers: ['clusters']
      });
      const clusterId = features[0].properties?.cluster_id;
      const source = map.current!.getSource('random_points') as maplibregl.GeoJSONSource;

      if (clusterId) {
        const zoom = await source.getClusterExpansionZoom(clusterId);
        map.current!.easeTo({
          center: (features[0].geometry as GeoJSON.Point).coordinates as [number, number],
          zoom: zoom
        });
      }
    });

    // クラスターのホバー時にカーソルを変更
    map.current.on('mouseenter', 'clusters', () => {
      map.current!.getCanvas().style.cursor = 'pointer';
    });

    map.current.on('mouseleave', 'clusters', () => {
      map.current!.getCanvas().style.cursor = '';
    });

    // クラスターではないポイントのクリックイベントを追加
    map.current.on('click', 'unclustered-point', (e) => {
      const features = map.current!.queryRenderedFeatures(e.point, {
        layers: ['unclustered-point']
      });

      if (!features.length) return;

      const feature = features[0];
      const coordinates = feature.geometry.type === 'Point' ? feature.geometry.coordinates.slice() : null;

      if (!coordinates) return;

      // ポップアップの内容を作成
      const popupContent = `
        <h3>${feature.properties.name || '名前なし'}</h3>
        <p>説明: ${feature.properties.description || '説明なし'}</p>
        <p>クラス: ${feature.properties.class || 'N/A'}</p>
      `;

      // ポップアップを作成して表示
      if (map.current) {
        new maplibregl.Popup()
          .setLngLat(coordinates as [number, number])
          .setHTML(popupContent)
          .addTo(map.current);
      }
    });

    // クラスターではないポイントのホバー時にカーソルを変更
    map.current.on('mouseenter', 'unclustered-point', () => {
      map.current!.getCanvas().style.cursor = 'pointer';
    });

    map.current.on('mouseleave', 'unclustered-point', () => {
      map.current!.getCanvas().style.cursor = '';
    });
  }, []);

  return (
    <div>
      <div className="sidebar">
        Longitude: {mapState.lon} | Latitude: {mapState.lat} | Zoom: {mapState.zoom}
      </div>
      <div ref={mapContainer} className="map-container" style={{ height: '70vh', width: '100%' }} />
    </div>
  );
};

export default DisplayClusteredMarkers;

こんな感じの実装をすると
いい感じになりました!

Fusic 技術ブログ

Discussion