👻
PMTilesに大量の点データを格納して、プロパティをPopupで表示する
こんにちは
石橋です
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
コマンドについてはこちらの記事の事例を参考にしました
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;
こんな感じの実装をすると
いい感じになりました!
Discussion