Reactで地図を表示したい!React Leafletで位置情報アプリを作ろう

React Leafletを使って地図を表示し、位置情報サービスを実装する方法を初心者向けに解説。マーカー表示、ユーザー位置取得、カスタマイズ方法まで実際のコード例とともに詳しく説明します。

Learning Next 運営
65 分で読めます

「Reactアプリで地図を表示したいけど、どうすればいいの?」

こんな疑問を持ったことはありませんか?

「お店の位置を地図で見せたい」 「ユーザーの現在地を取得して地図に表示したい」 「地図にマーカーやポップアップを追加したい」

このような要望、多いですよね。

実は、React Leafletを使えば無料で簡単に地図アプリが作れます。 GoogleマップAPIのように有料制限もなく、初心者でも使いやすいライブラリなんです。

この記事では、React Leafletを使った地図表示から位置情報の取得まで、実際のコード例とともに分かりやすく解説します。 読み終わる頃には、あなたも地図を使ったWebアプリが作れるようになりますよ!

React Leafletって何?無料で使える地図ライブラリ

React Leafletは、人気のある地図ライブラリ「Leaflet」をReactで使いやすくしたものです。

React Leafletの魅力

こんなメリットがあります

  • 完全無料で商用利用も可能
  • 軽量で動作が速い
  • スマートフォンでも快適に動く
  • カスタマイズの自由度が高い
  • プラグインが豊富

どんなことができるの?

  • インタラクティブな地図の表示
  • マーカーやポップアップの追加
  • ユーザーの現在地を取得・表示
  • 地図のデザインを自由にカスタマイズ
  • 複数の地図スタイルから選択可能

他の地図サービスとの違い

地図サービスには色々な選択肢がありますが、React Leafletの特徴を見てみましょう。

// Google Maps API(有料制限あり)
import { GoogleMap, Marker } from '@react-google-maps/api';

// Mapbox(無料枠あり、カスタマイズ豊富)
import mapboxgl from 'mapbox-gl';

// React Leaflet(完全無料、オープンソース)
import { MapContainer, TileLayer, Marker } from 'react-leaflet';

Google Maps APIとの比較

Google Maps APIは有名ですが、無料枠を超えると料金が発生します。 React Leafletなら完全無料で使えるので、個人開発や小規模プロジェクトにぴったりです。

選ぶべき理由

予算を気にせず使えて、機能も十分充実しているからです。 まずはReact Leafletで始めてみることをおすすめします。

環境構築の準備をしよう

React Leafletを使うための準備から始めましょう。 意外と簡単なので、安心してくださいね。

必要なパッケージをインストール

まずは必要なパッケージをインストールします。

# React Leafletと関連パッケージのインストール
npm install react-leaflet leaflet

# TypeScriptを使用する場合
npm install @types/leaflet

何をインストールしているの?

  • react-leaflet: React用のLeafletライブラリ
  • leaflet: 地図機能の本体
  • @types/leaflet: TypeScript用の型定義(TypeScript使用時のみ)

CSSファイルを読み込もう

地図を正しく表示するために、LeafletのCSSファイルが必要です。

// App.jsまたはindex.jsで読み込み
import 'leaflet/dist/leaflet.css';

または、CSSファイルに直接書く方法もあります

/* App.css */
@import url('https://unpkg.com/leaflet@1.9.4/dist/leaflet.css');

どちらでも同じ効果が得られます。 お好みの方法を選んでくださいね。

アイコンの問題を解決しよう

React Leafletでは、マーカーアイコンで問題が起きることがあります。 でも大丈夫です、簡単に解決できます。

// utils/leafletSetup.js
import L from 'leaflet';

// デフォルトアイコンの設定を修正
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
  iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
  iconUrl: require('leaflet/dist/images/marker-icon.png'),
  shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});

このコードの意味は?

Leafletのデフォルトアイコンの設定を修正しています。 Reactの環境では、アイコンのパスが正しく設定されないことがあるんです。

// App.jsで読み込み
import './utils/leafletSetup';

最後にApp.jsでこのファイルを読み込めば準備完了です。

最初の地図を表示してみよう

準備ができたら、実際に地図を表示してみましょう。

import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';

function BasicMap() {
  const position = [35.6762, 139.6503]; // 東京の座標

  return (
    <MapContainer
      center={position}
      zoom={13}
      style={{ height: '400px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      <Marker position={position}>
        <Popup>
          東京駅の位置です
        </Popup>
      </Marker>
    </MapContainer>
  );
}

export default BasicMap;

コードの解説をしますね

MapContainerが地図の本体です。 centerで地図の中心位置、zoomで拡大レベルを指定します。

TileLayerは地図の画像データを提供します。 今回はOpenStreetMapという無料の地図データを使っています。

Markerで地図上にピンを表示できます。 Popupでマーカーをクリックした時の吹き出しを設定します。

実行してみよう

このコードを実行すると、東京駅を中心とした地図が表示されます。 マーカーをクリックすると、「東京駅の位置です」というポップアップが出ますよ。

マーカーとポップアップを活用しよう

地図上にマーカーを配置して、情報を表示する方法を学びましょう。 これができると、お店の場所案内などに使えますね。

複数のマーカーを表示する

一つの地図に複数のマーカーを表示してみましょう。

import React from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function MultipleMarkers() {
  const locations = [
    { id: 1, name: '東京駅', position: [35.6762, 139.6503], info: '日本の鉄道の中心駅' },
    { id: 2, name: '新宿駅', position: [35.6896, 139.7006], info: '世界一利用者数の多い駅' },
    { id: 3, name: '渋谷駅', position: [35.6580, 139.7016], info: '若者文化の発信地' },
    { id: 4, name: '池袋駅', position: [35.7295, 139.7109], info: '副都心の中心駅' }
  ];

  return (
    <MapContainer
      center={[35.6762, 139.6503]}
      zoom={12}
      style={{ height: '500px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      {locations.map(location => (
        <Marker key={location.id} position={location.position}>
          <Popup>
            <div>
              <h3>{location.name}</h3>
              <p>{location.info}</p>
            </div>
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}

データの準備から始めます

locations配列に、表示したい場所の情報をまとめています。 各場所には、ID、名前、座標、説明文を含んでいます。

map関数で繰り返し表示

locations.map()を使って、配列の各要素に対してマーカーを作成します。 これで一度に複数のマーカーが表示されますね。

ポップアップの中身をカスタマイズ

ポップアップの中にHTMLを書けるので、見出しや段落で情報を整理できます。 デザインも自由に変更可能です。

カスタムアイコンを作ってみよう

デフォルトのマーカーアイコンを、オリジナルのものに変更できます。

import L from 'leaflet';

function CustomIconMap() {
  // カスタムアイコンの定義
  const customIcon = new L.Icon({
    iconUrl: '/images/custom-marker.png',
    iconSize: [32, 32],
    iconAnchor: [16, 32],
    popupAnchor: [0, -32]
  });

  // 種類別のアイコン
  const icons = {
    restaurant: new L.Icon({
      iconUrl: '/images/restaurant-icon.png',
      iconSize: [30, 30],
      iconAnchor: [15, 30]
    }),
    hotel: new L.Icon({
      iconUrl: '/images/hotel-icon.png',
      iconSize: [30, 30],
      iconAnchor: [15, 30]
    }),
    station: new L.Icon({
      iconUrl: '/images/station-icon.png',
      iconSize: [30, 30],
      iconAnchor: [15, 30]
    })
  };

  const places = [
    { name: 'レストラン A', position: [35.6762, 139.6503], type: 'restaurant' },
    { name: 'ホテル B', position: [35.6896, 139.7006], type: 'hotel' },
    { name: '駅 C', position: [35.6580, 139.7016], type: 'station' }
  ];

  return (
    <MapContainer
      center={[35.6762, 139.6503]}
      zoom={12}
      style={{ height: '500px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      {places.map((place, index) => (
        <Marker
          key={index}
          position={place.position}
          icon={icons[place.type]}
        >
          <Popup>
            <div>
              <h3>{place.name}</h3>
              <p>タイプ: {place.type}</p>
            </div>
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}

アイコンの設定について

L.Iconでカスタムアイコンを作成します。 iconUrlで画像のパス、iconSizeでサイズを指定します。

種類別にアイコンを使い分け

iconsオブジェクトで、場所の種類ごとに異なるアイコンを準備しています。 レストラン、ホテル、駅など、一目で分かるアイコンにすると便利ですね。

アイコンの配置調整

iconAnchorでアイコンの基準点を設定します。 popupAnchorでポップアップの表示位置を調整できます。

豪華なポップアップを作ろう

ポップアップに画像やボタンを追加して、より魅力的にしてみましょう。

function RichPopupMap() {
  const restaurants = [
    {
      id: 1,
      name: '美味しいレストラン',
      position: [35.6762, 139.6503],
      rating: 4.5,
      price: '¥¥¥',
      image: '/images/restaurant1.jpg',
      description: '新鮮な食材を使った本格的なイタリアン'
    },
    {
      id: 2,
      name: 'カフェ サンプル',
      position: [35.6896, 139.7006],
      rating: 4.2,
      price: '¥¥',
      image: '/images/cafe1.jpg',
      description: '落ち着いた雰囲気のカフェ'
    }
  ];

  return (
    <MapContainer
      center={[35.6762, 139.6503]}
      zoom={12}
      style={{ height: '500px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      {restaurants.map(restaurant => (
        <Marker key={restaurant.id} position={restaurant.position}>
          <Popup maxWidth={300}>
            <div style={{ padding: '10px' }}>
              <img 
                src={restaurant.image} 
                alt={restaurant.name}
                style={{ width: '100%', height: '150px', objectFit: 'cover', borderRadius: '5px' }}
              />
              <h3 style={{ margin: '10px 0 5px 0' }}>{restaurant.name}</h3>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '5px' }}>
                <span>評価: ⭐ {restaurant.rating}</span>
                <span>価格: {restaurant.price}</span>
              </div>
              <p style={{ margin: '5px 0', fontSize: '14px' }}>{restaurant.description}</p>
              <button 
                style={{ 
                  width: '100%', 
                  padding: '8px', 
                  backgroundColor: '#007bff', 
                  color: 'white', 
                  border: 'none', 
                  borderRadius: '3px',
                  cursor: 'pointer'
                }}
                onClick={() => alert(`${restaurant.name}の詳細を表示`)}
              >
                詳細を見る
              </button>
            </div>
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}

ポップアップの構成要素

この例では、画像、タイトル、評価、価格、説明文、ボタンを含んでいます。 実際のお店紹介アプリのような見た目になりますね。

スタイリングの工夫

インラインスタイルで見た目を調整しています。 もちろん、CSSクラスを使って管理することも可能です。

インタラクティブな要素

ボタンをクリックすると、詳細ページに移動したり、予約画面を開いたりできます。 onClickイベントで好きな処理を追加してください。

ユーザーの位置情報を取得しよう

ユーザーの現在地を取得して、地図上に表示する方法を学びましょう。 位置情報を使えば、「現在地から一番近いお店」のような機能が作れますよ。

基本的な位置情報取得

まずは、シンプルな位置情報の取得から始めましょう。

import React, { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';

function LocationMap() {
  const [userPosition, setUserPosition] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 位置情報の取得
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          setUserPosition([
            position.coords.latitude,
            position.coords.longitude
          ]);
          setLoading(false);
        },
        (error) => {
          setError(error.message);
          setLoading(false);
        },
        {
          enableHighAccuracy: true,
          timeout: 10000,
          maximumAge: 60000
        }
      );
    } else {
      setError('位置情報がサポートされていません');
      setLoading(false);
    }
  }, []);

  if (loading) {
    return <div>位置情報を取得中...</div>;
  }

  if (error) {
    return <div>エラー: {error}</div>;
  }

  return (
    <MapContainer
      center={userPosition || [35.6762, 139.6503]}
      zoom={15}
      style={{ height: '500px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      {userPosition && (
        <Marker position={userPosition}>
          <Popup>
            <div>
              <h3>あなたの現在地</h3>
              <p>緯度: {userPosition[0].toFixed(6)}</p>
              <p>経度: {userPosition[1].toFixed(6)}</p>
            </div>
          </Popup>
        </Marker>
      )}
    </MapContainer>
  );
}

位置情報の取得処理

navigator.geolocation.getCurrentPosition()で現在地を取得します。 成功した場合は座標を、失敗した場合はエラーメッセージをstateに保存します。

オプションの説明

  • enableHighAccuracy: より精度の高い位置情報を取得
  • timeout: 取得処理のタイムアウト時間(10秒)
  • maximumAge: キャッシュされた位置情報の有効期間(5分)

ユーザビリティの配慮

位置情報の取得には時間がかかるので、ローディング表示を入れています。 エラーが発生した場合も、分かりやすいメッセージを表示します。

地図の中心を動的に移動

ボタンをクリックで地図の中心を移動できるようにしてみましょう。

import { useMap } from 'react-leaflet';

function MapController({ center, zoom }) {
  const map = useMap();

  useEffect(() => {
    if (center) {
      map.setView(center, zoom);
    }
  }, [center, zoom, map]);

  return null;
}

function DynamicCenterMap() {
  const [userPosition, setUserPosition] = useState(null);
  const [selectedLocation, setSelectedLocation] = useState(null);

  const locations = [
    { name: '東京駅', position: [35.6762, 139.6503] },
    { name: '新宿駅', position: [35.6896, 139.7006] },
    { name: '渋谷駅', position: [35.6580, 139.7016] }
  ];

  const handleLocationSelect = (location) => {
    setSelectedLocation(location);
  };

  const handleGetUserLocation = () => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          const userPos = [position.coords.latitude, position.coords.longitude];
          setUserPosition(userPos);
          setSelectedLocation({ name: '現在地', position: userPos });
        },
        (error) => {
          alert('位置情報の取得に失敗しました: ' + error.message);
        }
      );
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '10px' }}>
        <button onClick={handleGetUserLocation}>
          現在地を取得
        </button>
        {locations.map((location, index) => (
          <button
            key={index}
            onClick={() => handleLocationSelect(location)}
            style={{ marginLeft: '10px' }}
          >
            {location.name}
          </button>
        ))}
      </div>
      
      <MapContainer
        center={[35.6762, 139.6503]}
        zoom={13}
        style={{ height: '500px', width: '100%' }}
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        />
        
        {selectedLocation && (
          <MapController center={selectedLocation.position} zoom={15} />
        )}
        
        {locations.map((location, index) => (
          <Marker key={index} position={location.position}>
            <Popup>{location.name}</Popup>
          </Marker>
        ))}
        
        {userPosition && (
          <Marker position={userPosition}>
            <Popup>あなたの現在地</Popup>
          </Marker>
        )}
      </MapContainer>
    </div>
  );
}

MapControllerコンポーネントの役割

useMapフックで地図のインスタンスを取得し、setViewメソッドで中心位置を変更します。 Reactのstate変更をLeafletの地図に反映させる橋渡し役ですね。

ユーザーインターフェースの工夫

ボタンをクリックするだけで地図の中心が移動するので、使いやすいです。 現在地取得ボタンも追加して、ユーザーの利便性を向上させています。

リアルタイムで位置を追跡

位置情報をリアルタイムで更新する機能も作れます。

function RealtimeLocationMap() {
  const [userPosition, setUserPosition] = useState(null);
  const [isTracking, setIsTracking] = useState(false);
  const [watchId, setWatchId] = useState(null);

  const startTracking = () => {
    if (navigator.geolocation) {
      const id = navigator.geolocation.watchPosition(
        (position) => {
          setUserPosition([
            position.coords.latitude,
            position.coords.longitude
          ]);
        },
        (error) => {
          console.error('位置情報の取得エラー:', error);
        },
        {
          enableHighAccuracy: true,
          timeout: 5000,
          maximumAge: 0
        }
      );
      setWatchId(id);
      setIsTracking(true);
    }
  };

  const stopTracking = () => {
    if (watchId) {
      navigator.geolocation.clearWatch(watchId);
      setWatchId(null);
      setIsTracking(false);
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '10px' }}>
        <button
          onClick={isTracking ? stopTracking : startTracking}
          style={{
            padding: '10px 20px',
            backgroundColor: isTracking ? '#dc3545' : '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '5px',
            cursor: 'pointer'
          }}
        >
          {isTracking ? '追跡を停止' : '位置追跡を開始'}
        </button>
      </div>
      
      <MapContainer
        center={userPosition || [35.6762, 139.6503]}
        zoom={15}
        style={{ height: '500px', width: '100%' }}
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        />
        
        {userPosition && (
          <>
            <MapController center={userPosition} zoom={15} />
            <Marker position={userPosition}>
              <Popup>
                <div>
                  <h3>現在地</h3>
                  <p>緯度: {userPosition[0].toFixed(6)}</p>
                  <p>経度: {userPosition[1].toFixed(6)}</p>
                  <p>更新: {new Date().toLocaleTimeString()}</p>
                </div>
              </Popup>
            </Marker>
          </>
        )}
      </MapContainer>
    </div>
  );
}

位置追跡の仕組み

watchPositionを使うと、位置が変わるたびに自動的にコールバック関数が呼ばれます。 散歩やドライブの軌跡を記録するようなアプリに使えますね。

開始・停止の制御

追跡を停止するにはclearWatchを使います。 バッテリー消費を抑えるため、不要な時は停止できるようにしています。

更新時刻の表示

ポップアップに現在時刻を表示して、リアルタイム更新を視覚的に確認できます。

地図の見た目をカスタマイズしよう

地図のデザインを変更して、アプリに合った見た目にしてみましょう。 いろいろな地図スタイルから選べるんですよ。

異なる地図タイルを使ってみよう

地図の背景画像(タイル)を変更できます。

import React, { useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function CustomTileMap() {
  const [selectedTile, setSelectedTile] = useState('openstreetmap');
  
  const tileOptions = {
    openstreetmap: {
      url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
    },
    satellite: {
      url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
      attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
    },
    terrain: {
      url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
      attribution: 'Map data: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, <a href="http://viewfinderpanoramas.org">SRTM</a> | Map style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
    },
    dark: {
      url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '10px' }}>
        <label>地図スタイル: </label>
        <select
          value={selectedTile}
          onChange={(e) => setSelectedTile(e.target.value)}
          style={{ padding: '5px', marginLeft: '10px' }}
        >
          <option value="openstreetmap">標準地図</option>
          <option value="satellite">衛星写真</option>
          <option value="terrain">地形図</option>
          <option value="dark">ダークモード</option>
        </select>
      </div>
      
      <MapContainer
        center={[35.6762, 139.6503]}
        zoom={13}
        style={{ height: '500px', width: '100%' }}
      >
        <TileLayer
          url={tileOptions[selectedTile].url}
          attribution={tileOptions[selectedTile].attribution}
        />
        <Marker position={[35.6762, 139.6503]}>
          <Popup>東京駅</Popup>
        </Marker>
      </MapContainer>
    </div>
  );
}

地図スタイルの種類

  • 標準地図: 道路や建物が見やすい一般的なスタイル
  • 衛星写真: 上空から撮影した実際の写真
  • 地形図: 山や川などの地形が分かりやすい
  • ダークモード: 目に優しい暗いテーマ

選択機能の実装

セレクトボックスで地図スタイルを切り替えられます。 ユーザーが好みの見た目を選べるので便利ですね。

地図コントロールを調整しよう

地図の操作機能をカスタマイズできます。

import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function CustomControlsMap() {
  return (
    <MapContainer
      center={[35.6762, 139.6503]}
      zoom={13}
      style={{ height: '500px', width: '100%' }}
      zoomControl={false} // デフォルトのズームコントロールを非表示
      scrollWheelZoom={true} // スクロールでズーム
      doubleClickZoom={true} // ダブルクリックでズーム
      dragging={true} // ドラッグ可能
      attributionControl={false} // 著作権表示を非表示
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
      />
      <Marker position={[35.6762, 139.6503]}>
        <Popup>東京駅</Popup>
      </Marker>
    </MapContainer>
  );
}

コントロールオプションの説明

  • zoomControl: ズームボタンの表示・非表示
  • scrollWheelZoom: マウスホイールでのズーム
  • doubleClickZoom: ダブルクリックでのズーム
  • dragging: 地図のドラッグ移動
  • attributionControl: 著作権表示の制御

用途に応じた設定

モバイルアプリならscrollWheelZoomを無効にしたり、埋め込み地図ならdraggingを制限したりできます。

レスポンシブ対応の地図

画面サイズに応じて地図のサイズを調整しましょう。

import React, { useState, useEffect } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function ResponsiveMap() {
  const [windowSize, setWindowSize] = useState({
    width: typeof window !== 'undefined' ? window.innerWidth : 1200,
    height: typeof window !== 'undefined' ? window.innerHeight : 800
  });

  useEffect(() => {
    const handleResize = () => {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // レスポンシブな地図サイズの計算
  const getMapHeight = () => {
    if (windowSize.width < 768) {
      return '300px'; // モバイル
    } else if (windowSize.width < 1024) {
      return '400px'; // タブレット
    } else {
      return '500px'; // デスクトップ
    }
  };

  const getMapZoom = () => {
    if (windowSize.width < 768) {
      return 12; // モバイル
    } else {
      return 13; // デスクトップ
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h2>レスポンシブ地図</h2>
      <div style={{ 
        border: '1px solid #ddd', 
        borderRadius: '8px', 
        overflow: 'hidden',
        boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
      }}>
        <MapContainer
          center={[35.6762, 139.6503]}
          zoom={getMapZoom()}
          style={{ 
            height: getMapHeight(), 
            width: '100%',
            minHeight: '250px'
          }}
        >
          <TileLayer
            url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
            attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          />
          <Marker position={[35.6762, 139.6503]}>
            <Popup>
              <div>
                <h3>東京駅</h3>
                <p>画面サイズ: {windowSize.width}x{windowSize.height}</p>
                <p>地図の高さ: {getMapHeight()}</p>
              </div>
            </Popup>
          </Marker>
        </MapContainer>
      </div>
    </div>
  );
}

画面サイズの検出

window.innerWidthで画面の幅を取得し、リサイズイベントで更新します。 画面サイズに応じて地図の高さとズームレベルを調整します。

ブレークポイントの設定

  • 768px未満: モバイル
  • 1024px未満: タブレット
  • 1024px以上: デスクトップ

ユーザビリティの向上

どのデバイスでも快適に地図が使えるようになります。 モバイルでは地図を小さく、デスクトップでは大きく表示するのがポイントです。

実践的な店舗検索アプリを作ろう

今まで学んだ機能を組み合わせて、実際に使える店舗検索アプリを作ってみましょう。 検索機能、フィルタリング、距離計算まで含んだ本格的なアプリです。

完成版の店舗検索アプリ

import React, { useState, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function StoreFinderApp() {
  const [searchTerm, setSearchTerm] = useState('');
  const [selectedCategory, setSelectedCategory] = useState('all');
  const [userPosition, setUserPosition] = useState(null);

  const stores = [
    {
      id: 1,
      name: 'カフェ・ド・パリ',
      category: 'cafe',
      position: [35.6762, 139.6503],
      address: '東京都千代田区丸の内1-1-1',
      phone: '03-1234-5678',
      hours: '7:00-22:00',
      rating: 4.5,
      image: '/images/cafe1.jpg'
    },
    {
      id: 2,
      name: 'イタリアン・レストラン',
      category: 'restaurant',
      position: [35.6896, 139.7006],
      address: '東京都新宿区新宿3-1-1',
      phone: '03-2345-6789',
      hours: '11:00-23:00',
      rating: 4.2,
      image: '/images/restaurant1.jpg'
    },
    {
      id: 3,
      name: 'ビジネスホテル',
      category: 'hotel',
      position: [35.6580, 139.7016],
      address: '東京都渋谷区渋谷2-1-1',
      phone: '03-3456-7890',
      hours: '24時間',
      rating: 4.0,
      image: '/images/hotel1.jpg'
    }
  ];

  const categories = [
    { value: 'all', label: '全て' },
    { value: 'cafe', label: 'カフェ' },
    { value: 'restaurant', label: 'レストラン' },
    { value: 'hotel', label: 'ホテル' }
  ];

  // フィルタリングされた店舗リスト
  const filteredStores = useMemo(() => {
    return stores.filter(store => {
      const matchesSearch = store.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
                          store.address.toLowerCase().includes(searchTerm.toLowerCase());
      const matchesCategory = selectedCategory === 'all' || store.category === selectedCategory;
      return matchesSearch && matchesCategory;
    });
  }, [searchTerm, selectedCategory]);

  // 現在地取得
  const handleGetLocation = () => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          setUserPosition([position.coords.latitude, position.coords.longitude]);
        },
        (error) => {
          alert('位置情報の取得に失敗しました: ' + error.message);
        }
      );
    }
  };

  // 距離計算(簡易版)
  const calculateDistance = (pos1, pos2) => {
    const R = 6371; // 地球の半径(km)
    const dLat = (pos2[0] - pos1[0]) * Math.PI / 180;
    const dLon = (pos2[1] - pos1[1]) * Math.PI / 180;
    const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
              Math.cos(pos1[0] * Math.PI / 180) * Math.cos(pos2[0] * Math.PI / 180) *
              Math.sin(dLon/2) * Math.sin(dLon/2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
    return R * c;
  };

  return (
    <div style={{ padding: '20px' }}>
      <h1>店舗検索アプリ</h1>
      
      {/* 検索フォーム */}
      <div style={{ marginBottom: '20px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
        <input
          type="text"
          placeholder="店舗名または住所で検索"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          style={{ padding: '8px', minWidth: '200px' }}
        />
        <select
          value={selectedCategory}
          onChange={(e) => setSelectedCategory(e.target.value)}
          style={{ padding: '8px' }}
        >
          {categories.map(cat => (
            <option key={cat.value} value={cat.value}>
              {cat.label}
            </option>
          ))}
        </select>
        <button
          onClick={handleGetLocation}
          style={{
            padding: '8px 16px',
            backgroundColor: '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer'
          }}
        >
          現在地を取得
        </button>
      </div>

      {/* 地図 */}
      <MapContainer
        center={[35.6762, 139.6503]}
        zoom={12}
        style={{ height: '400px', width: '100%', marginBottom: '20px' }}
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        />
        
        {/* 現在地マーカー */}
        {userPosition && (
          <Marker position={userPosition}>
            <Popup>
              <div>
                <h3>現在地</h3>
                <p>あなたの位置</p>
              </div>
            </Popup>
          </Marker>
        )}
        
        {/* 店舗マーカー */}
        {filteredStores.map(store => (
          <Marker key={store.id} position={store.position}>
            <Popup maxWidth={300}>
              <div style={{ padding: '10px' }}>
                <h3>{store.name}</h3>
                <p><strong>カテゴリ:</strong> {store.category}</p>
                <p><strong>住所:</strong> {store.address}</p>
                <p><strong>電話:</strong> {store.phone}</p>
                <p><strong>営業時間:</strong> {store.hours}</p>
                <p><strong>評価:</strong> ⭐ {store.rating}</p>
                {userPosition && (
                  <p><strong>距離:</strong> {calculateDistance(userPosition, store.position).toFixed(2)} km</p>
                )}
                <button
                  style={{
                    width: '100%',
                    padding: '8px',
                    backgroundColor: '#28a745',
                    color: 'white',
                    border: 'none',
                    borderRadius: '4px',
                    cursor: 'pointer'
                  }}
                  onClick={() => {
                    const url = `https://www.google.com/maps/dir/?api=1&destination=${store.position[0]},${store.position[1]}`;
                    window.open(url, '_blank');
                  }}
                >
                  ルート案内
                </button>
              </div>
            </Popup>
          </Marker>
        ))}
      </MapContainer>

      {/* 店舗一覧 */}
      <div>
        <h2>検索結果 ({filteredStores.length}件)</h2>
        <div style={{ display: 'grid', gap: '15px' }}>
          {filteredStores.map(store => (
            <div key={store.id} style={{
              border: '1px solid #ddd',
              borderRadius: '8px',
              padding: '15px',
              backgroundColor: '#f9f9f9'
            }}>
              <h3>{store.name}</h3>
              <p>{store.address}</p>
              <p>営業時間: {store.hours}</p>
              <p>評価: ⭐ {store.rating}</p>
              {userPosition && (
                <p>距離: {calculateDistance(userPosition, store.position).toFixed(2)} km</p>
              )}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

アプリの主要機能

このアプリには以下の機能が含まれています。

  • 検索機能: 店舗名や住所での検索
  • カテゴリフィルタ: 種類による絞り込み
  • 現在地取得: ユーザーの位置情報を表示
  • 距離計算: 現在地からの距離を計算
  • ルート案内: GoogleマップでのルートASE検索

検索とフィルタリング

useMemoを使って、検索条件が変わった時だけフィルタリングを実行します。 パフォーマンスを向上させる重要な最適化ですね。

距離計算の実装

ハヴァシン公式を使って、2点間の距離を計算しています。 精密な計算ではありませんが、店舗検索アプリには十分な精度です。

外部連携機能

「ルート案内」ボタンでGoogleマップを開き、目的地までのルートを表示します。 実用性の高い機能で、ユーザーの利便性が向上します。

エラーハンドリングで安定したアプリに

位置情報や地図の読み込みで問題が起きた時の対処法を学びましょう。 エラーハンドリングがしっかりしていると、ユーザーに安心感を与えられます。

位置情報取得のエラー対策

位置情報の取得で起きるエラーに対応しましょう。

import React, { useState, useEffect } from 'react';

function RobustLocationMap() {
  const [userPosition, setUserPosition] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  const handleGetLocation = async () => {
    setLoading(true);
    setError(null);

    try {
      // 位置情報APIの利用可能性をチェック
      if (!navigator.geolocation) {
        throw new Error('位置情報サービスがサポートされていません');
      }

      // 権限のチェック
      const permission = await navigator.permissions.query({ name: 'geolocation' });
      if (permission.state === 'denied') {
        throw new Error('位置情報の利用が拒否されています。ブラウザの設定を確認してください。');
      }

      // 位置情報の取得
      const position = await new Promise((resolve, reject) => {
        navigator.geolocation.getCurrentPosition(
          resolve,
          reject,
          {
            enableHighAccuracy: true,
            timeout: 10000,
            maximumAge: 300000 // 5分間はキャッシュを利用
          }
        );
      });

      setUserPosition([position.coords.latitude, position.coords.longitude]);
    } catch (err) {
      let errorMessage = '位置情報の取得に失敗しました';
      
      switch (err.code) {
        case 1:
          errorMessage = '位置情報の利用が拒否されました';
          break;
        case 2:
          errorMessage = '位置情報が利用できません';
          break;
        case 3:
          errorMessage = '位置情報の取得がタイムアウトしました';
          break;
        default:
          errorMessage = err.message || errorMessage;
      }
      
      setError(errorMessage);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        <button
          onClick={handleGetLocation}
          disabled={loading}
          style={{
            padding: '10px 20px',
            backgroundColor: loading ? '#ccc' : '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '5px',
            cursor: loading ? 'not-allowed' : 'pointer'
          }}
        >
          {loading ? '位置情報を取得中...' : '現在地を取得'}
        </button>
      </div>

      {error && (
        <div style={{
          padding: '10px',
          backgroundColor: '#f8d7da',
          color: '#721c24',
          border: '1px solid #f5c6cb',
          borderRadius: '5px',
          marginBottom: '10px'
        }}>
          エラー: {error}
        </div>
      )}

      {userPosition && (
        <div style={{
          padding: '10px',
          backgroundColor: '#d4edda',
          color: '#155724',
          border: '1px solid #c3e6cb',
          borderRadius: '5px',
          marginBottom: '10px'
        }}>
          位置情報を取得しました: {userPosition[0].toFixed(6)}, {userPosition[1].toFixed(6)}
        </div>
      )}
    </div>
  );
}

エラーコードの対応

位置情報APIのエラーコードに応じて、適切なメッセージを表示します。 ユーザーが原因を理解しやすくなりますね。

権限チェックの実装

事前に位置情報の権限をチェックして、拒否されている場合は分かりやすいメッセージを表示します。

ユーザビリティの向上

ローディング中はボタンを無効化し、現在の状態が分かるようにしています。 エラーや成功メッセージも色分けして見やすくしました。

地図読み込みのエラー対策

地図タイルの読み込みエラーにも対応しましょう。

import React, { useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function ErrorHandlingMap() {
  const [mapError, setMapError] = useState(null);
  const [retryCount, setRetryCount] = useState(0);

  const handleTileError = (error) => {
    console.error('地図の読み込みエラー:', error);
    setMapError('地図の読み込みに失敗しました');
  };

  const handleRetry = () => {
    setMapError(null);
    setRetryCount(prev => prev + 1);
  };

  return (
    <div>
      {mapError && (
        <div style={{
          padding: '15px',
          backgroundColor: '#f8d7da',
          color: '#721c24',
          border: '1px solid #f5c6cb',
          borderRadius: '5px',
          marginBottom: '10px',
          textAlign: 'center'
        }}>
          <p>{mapError}</p>
          <button
            onClick={handleRetry}
            style={{
              padding: '8px 16px',
              backgroundColor: '#007bff',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer'
            }}
          >
            再試行
          </button>
        </div>
      )}

      <MapContainer
        center={[35.6762, 139.6503]}
        zoom={13}
        style={{ height: '500px', width: '100%' }}
        key={retryCount} // 再試行時にコンポーネントを再マウント
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          onError={handleTileError}
        />
        <Marker position={[35.6762, 139.6503]}>
          <Popup>東京駅</Popup>
        </Marker>
      </MapContainer>
    </div>
  );
}

再試行機能の実装

地図の読み込みに失敗した場合、「再試行」ボタンで再度読み込みを試せます。 一時的なネットワークエラーでも対応できますね。

keyプロパティの活用

keyプロパティを変更することで、Reactコンポーネントを強制的に再マウントできます。 地図コンポーネントを完全にリセットしたい時に有効です。

エラーの視覚化

エラーメッセージを目立つように表示して、ユーザーが問題を認識しやすくしています。

まとめ:地図アプリ開発をマスターしよう

React Leafletを使った地図アプリケーションの実装方法について、詳しく解説してきました。

今回学んだ内容

  • React Leafletの基本的な使い方
  • マーカーとポップアップの活用法
  • ユーザー位置情報の取得と表示
  • 地図のカスタマイズとスタイリング
  • 実践的な店舗検索アプリの作成
  • エラーハンドリングの実装

React Leafletの魅力を再確認

無料で使える強力なライブラリで、位置情報を活用したWebアプリケーションが簡単に作れます。 GoogleマップAPIのような有料制限もないので、安心して開発に集中できますね。

次にやってみたいこと

地図機能を追加することで、あなたのアプリはユーザーにとってより価値のあるものになります。 今回学んだ技術を使って、ぜひオリジナルの地図アプリを作ってみてください。

お店の位置案内、イベント会場の案内、旅行記録アプリなど、アイデア次第で色々なサービスが作れますよ。 まずは簡単なものから始めて、徐々に機能を追加していきましょう!

関連記事