🧑‍🎓

サーバレスで地図表示!PMTilesとS3で構築するOpenStreetMapのホスティング

2024/09/05に公開

https://openstreetmap.jp/terms_and_privacy

OpenStreetMap (OSM) は、自由に利用できるオープンな地理データであり、多くの開発者や企業がこれを利用して地図ベースのサービスやアプリケーションを構築しています。

しかし、OSMを使ったプロジェクトで「OSMの公式タイルサーバーをそのまま使用する」ことは推奨されません。その理由は、OSMのサーバーがボランティアによって運営されており、叩きまくることはお行儀のよい行為とはいえないと思います

そこでOSMを表示する際にリクエストが想定される場合には自前でタイルサーバを構築することがベストプラクティスになるかと思います

その際、tileserver-glをつかってmbtilesを使うなどが考えられますが最近はPMTilesなども選択肢にあるのでS3にPMTilesを置くだけでタイルサーバにできちゃいます

原理的にできることはわかっていたのですが、ドキュメントを見ながら進めてもなかなかすんなり作れなかったので、手っ取り早く動く状況を作るメモを残しておきます

https://docs.protomaps.com/basemaps/maplibre

OSMのPMTilesをダウンロード

https://maps.protomaps.com/builds/

ここからOSMのpmtilesをダウンロードしてきます

その後S3に設置します

S3のバケットポリシーはこちらを参考にすれば良いかと思います

https://docs.protomaps.com/pmtiles/cloud-storage

maplibre-gl-jsで表示

// MapComponent.tsx
import React, { useEffect, useRef, useState } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import layers from 'protomaps-themes-base';
import { Protocol } from 'pmtiles';
import LayerControlPanel from './LayerControlPanel';

const MapComponent: React.FC = () => {
  const mapContainer = useRef<HTMLDivElement | null>(null);
  const mapRef = useRef<maplibregl.Map | null>(null); // MapLibreインスタンスの参照を保持
  const [theme, setTheme] = useState<string>('light'); // 初期テーマは'light'
  const [visibleLayers, setVisibleLayers] = useState<Set<string>>(new Set(['light'])); // 表示中のレイヤー管理

  // レイヤー情報の配列
  const layerOptions = [
    { id: 'light', name: 'Light' },
    { id: 'dark', name: 'Dark' },
    { id: 'white', name: 'White' },
    { id: 'black', name: 'Black' },
    { id: 'grayscale', name: 'Grayscale' },
  ];

  useEffect(() => {
    if (!mapContainer.current) return;

    // PMTilesプロトコルハンドラーの初期化
    const protocol = new Protocol();
    maplibregl.addProtocol('pmtiles', protocol.tile);

    // MapLibreのマップ初期化
    const map = new maplibregl.Map({
      container: mapContainer.current,
      style: createStyle(theme), // 初期テーマに基づくスタイル
      center: [130.4102, 33.5676], // 初期位置 (例: 福岡)
      zoom: 12, // 初期ズームレベル
    });

    mapRef.current = map; // MapLibreインスタンスを参照に保持

    return () => {
      map.remove();
    };
  }, []);

  // テーマが変更されたときにスタイルを更新する
  useEffect(() => {
    if (mapRef.current) {
      mapRef.current.setStyle(createStyle(theme));
    }
  }, [theme]);

  // スタイルを生成する関数
  const createStyle = (theme: string): maplibregl.StyleSpecification => ({
    version: 8 as const, // '8' を指定
    glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
    sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v3/' + theme,
    sources: {
      protomaps: {
        type: 'vector',
        url: 'pmtiles://[PMTIlesへのS3URL]',
        attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
      },
    },
    layers: layers('protomaps', theme),
  });

  // テーマの変更ハンドラー
  const handleThemeChange = (theme: string) => {
    setTheme(theme);
    setVisibleLayers(new Set([theme])); // 表示中のレイヤーを更新
  };

  // レイヤーのトグルハンドラー
  const handleToggleLayer = (layerId: string) => {
    if (visibleLayers.has(layerId)) {
      setVisibleLayers((prev) => {
        const newSet = new Set(prev);
        newSet.delete(layerId);
        return newSet;
      });
    } else {
      setVisibleLayers((prev) => new Set(prev).add(layerId));
      handleThemeChange(layerId); // レイヤー選択時にテーマを変更
    }
  };

  return (
    <div style={{ display: 'flex' }}>
      {/* コントロールパネル */}
      <div style={{ position: 'absolute', top: '10px', left: '10px', zIndex: 1 }}>
        <LayerControlPanel
          selectedStyle={theme}
          onStyleChange={handleThemeChange}
        />
      </div>

      {/* マップ表示領域 */}
      <div ref={mapContainer} style={{ width: '100%', height: '100vh' }} />
    </div>
  );
};

export default MapComponent;

// src/components/LayerControlPanel.tsx
import React from 'react';

interface LayerControlPanelProps {
  selectedStyle: string;
  onStyleChange: (style: string) => void;
}

const LayerControlPanel: React.FC<LayerControlPanelProps> = ({
  selectedStyle,
  onStyleChange
}) => {
  return (
    <div style={{ padding: '10px', backgroundColor: 'white', borderRadius: '5px' }}>
      <h4>レイヤーコントロール</h4>
      <div style={{ marginBottom: '10px' }}>
        <label htmlFor="style-select">OSMスタイル:</label>
        <select
          id="style-select"
          value={selectedStyle}
          onChange={(e) => onStyleChange(e.target.value)}
          style={{ marginLeft: '10px' }}
        >
          <option value="light">Light</option>
          <option value="dark">Dark</option>
          <option value="white">White</option>
          <option value="black">Black</option>
          <option value="grayscale">Grayscale</option>
        </select>
      </div>
    </div>
  );
};

export default LayerControlPanel;

せっかくなのでいくつかのスタイルを試せるようにしておきました

Fusic 技術ブログ

Discussion