ReactとLeafletで、地図にマーカーを表示する方法

​​React​Leaflet,react-leaflet,TypeScript,Vite

react-leaflet を使うと、<MapContainer> の中に <TileLayer> と <Marker> を並べるだけで、React のコンポーネントとして地図にマーカーを立てられます。店舗の場所を示すアクセスマップや、店舗一覧のページに載せる地図のように、決まった地点を地図の上で見せたいときに使えます。

書き方は短いのですが、つまずく点が3つありました。CSS を読み込み忘れると地図が崩れること、地図の高さを決めないと何も見えないこと、そして Vite でビルドすると既定のマーカー画像が表示されなくなる ことです。どれも、検証環境で実際に起きた結果とともに示します。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。react-leaflet 5.0.0、leaflet 1.9.4、@types/leaflet 1.9.22、react / react-dom 19.3.1、typescript 7.0.2、vite 8.3.7 を使い、tsc で型チェックしてから Vite で動かしました。ブラウザは Playwright 1.63.0 で動かした Chromium 153(ヘッドレス)だけです。検証環境からは OpenStreetMap のタイルサーバーに接続できなかったため、地図の画像(タイル)は、タイルの番号だけを描いた検証用のダミーを手元のサーバーから返しました。記事の画像に写っている地図は、このダミーです。OpenStreetMap のタイルを実際に読み込むところ、Firefox・Safari・実機のスマートフォン、Next.js などでのサーバーサイドレンダリングは試していません。

react-leaflet と Leaflet を入れる

react-leaflet は、Leaflet を React から使うためのライブラリです。react-leaflet のインストールの説明では、React、React DOM、Leaflet は peerDependencies なので、自分のプロジェクトに入れておく必要があるとされています。TypeScript で使うときは、Leaflet の型定義(@types/leaflet)も入れます。

sh
npm install react-leaflet leaflet
npm install -D @types/leaflet

検証で使った react-leaflet 5.0.0 の peerDependencies は、react と react-dom が ^19.0.0、leaflet が ^1.9.0 でした。React 19 以降が必要です。

TypeScript では、もう1つ注意がありました。tsconfig.json に "skipLibCheck": true がないと、tsc が react-leaflet の型定義ファイルの中でエラーを出しました。

text
node_modules/react-leaflet/lib/LayersControl.d.ts(9,29): error TS2307: Cannot find module '@react-leaflet/core/lib/context' or its corresponding type declarations.

自分のコードではなく、ライブラリの型定義どうしの参照で起きているエラーです。"skipLibCheck": true を付けると、エラーは出なくなりました。

また、react-leaflet の考え方の説明によると、Leaflet は読み込まれたときに DOM を直接さわるため、react-leaflet はサーバーサイドレンダリングに対応していません。

地図に1つのマーカーを立てる

まず、1か所にマーカーを立て、クリックするとポップアップが開く地図を作ります。

tsx
// BasicMap.tsx
import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet';
import type { LatLngTuple } from 'leaflet';
import { markerIcon } from './markerIcon';

const center: LatLngTuple = [35.6812, 139.7671];

export function BasicMap({ tileUrl }: { tileUrl: string }) {
  return (
    <MapContainer center={center} zoom={15} style={{ height: 400 }}>
      <TileLayer url={tileUrl} attribution="検証用のダミータイル" />
      <Marker position={center} icon={markerIcon}>
        <Popup>本店はここです</Popup>
      </Marker>
    </MapContainer>
  );
}

それぞれの役割は次のとおりです。

  • MapContainer:地図そのもの。center に地図の中心の緯度・経度を、zoom に縮尺を渡す
  • TileLayer:地図の画像(タイル)を読み込む。url の {z}、{x}、{y} が、縮尺とタイルの位置に置き換わる
  • Marker:position の緯度・経度にマーカーを立てる
  • Popup:Marker の中に置くと、マーカーをクリックしたときに開く

icon={markerIcon} は、ビルド後もマーカー画像を表示するためのものです。中身は後の節で説明します。緯度・経度は、[緯度, 経度] の順の配列(Leaflet の型では LatLngTuple)で渡します。

検証では、tileUrl に手元のダミータイルの URL(/tiles/{z}/{x}/{y}.svg)を渡しました。地図の右下には attribution に渡した文字が、「Leaflet」の後ろに表示されました。マーカーをクリックするとポップアップに「本店はここです」が出て、地図の別の場所をクリックすると閉じました。

地図の中央に青いマーカーが立ち、その上に「本店はここです」のポップアップが出ている。背景は番号だけを描いた検証用のタイル
マーカーをクリックしてポップアップを開いたところ(背景は検証用のダミータイル)

実際のサイトでは、地図のタイルを配信しているサービスの URL と、そのサービスが求める著作権の表示を渡します。react-leaflet のセットアップの説明にある例では、OpenStreetMap のタイルを次のように指定しています。

tsx
<TileLayer
  attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
  url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>

CSS を読み込み、地図の高さを決める

セットアップの説明には、地図が正しく表示されないときに確かめることとして、Leaflet の CSS が読み込まれていること、地図を入れる要素に高さが決まっていることが挙げられています。CSS は、アプリの入り口のファイルで読み込みます。

tsx
// main.tsx
import 'leaflet/dist/leaflet.css';
import './app';

この1行を消して試したところ、地図の枠の高さは400pxのままでしたが、タイルの画像が地図の枠の位置からずれて、枠の下まではみ出して並び、ページの高さが600pxから1,429pxに伸びました。タイルの position が、CSS を読み込んだときの absolute ではなく static になっていました。

MapContainer の style={{ height: 400 }} を外したときは、地図の枠の高さが0pxになり、画面には何も見えませんでした。エラーは出ません。Leaflet のクイックスタートでは、#map { height: 180px; } のように CSS で高さを決める例が示されています。

ビルドするとマーカー画像が消える

Marker に icon を渡さないと、Leaflet の既定の青いマーカー画像が使われます。この書き方は、Vite の開発サーバー(vite)では問題なく表示されました。ところが、vite build でビルドしたものを vite preview で開くと、マーカー画像が表示されませんでした。

地図の中央に、マーカーの代わりに画像が読めなかったことを示す小さな枠と「Mark」の文字が出ている
icon を渡さずにビルドした地図。マーカー画像が読み込めていない

ビルド後の画面でマーカーの <img> を調べると、src は marker-icon.png という、ファイル名だけの相対 URL でした。ビルドした中にこのファイルはないので、画像は読み込めていませんでした。

原因は、Leaflet が既定のマーカー画像の場所を探す方法にあります。Leaflet の Icon.Default の説明のとおり、Icon.Default は青いマーカー画像の場所を自動で見つけようとします。leaflet 1.9.4 のソースを読むと、leaflet.css の中の url(images/marker-icon.png) を CSS から読み取り、その URL が marker-icon.png で終わっていれば、手前の部分を画像の置き場所として使っていました。

Vite の開発サーバーでは、この URL が …/node_modules/leaflet/dist/images/marker-icon.png になり、marker-icon.png で終わるので、手前の …/images/ を置き場所として見つけられます。一方、ビルドすると、CSS の中の URL は data:image/png;base64,... に置き換わっていました。Vite の build.assetsInlineLimit の説明のとおり、Vite は既定で4KiBより小さい画像を base64 の URL として埋め込みます。marker-icon.png は1,466バイトなので、埋め込まれます。こうなると URL が marker-icon.png で終わらないので、置き場所が見つからず、ファイル名だけが残ります。

注意

公開する前に、開発サーバーではなく、ビルドしたもので地図を確かめてください。

マーカー画像を import して、自分のアイコンとして渡す

対策として、Leaflet の画像を import し、その URL でアイコンを作って Marker の icon に渡します。

ts
// markerIcon.ts
import L from 'leaflet';
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';

// 大きさや位置合わせは Leaflet の既定のアイコンと同じにし、画像の URL だけを差し替える
export const markerIcon = L.icon({
  ...L.Icon.Default.prototype.options,
  iconUrl,
  iconRetinaUrl,
  shadowUrl,
});

L.Icon.Default.prototype.options には、既定のアイコンの大きさ(iconSize: [25, 41])や、マーカーの先端を地点に合わせる位置(iconAnchor: [12, 41])、ポップアップを出す位置などが入っています。これを広げてから画像の URL だけを差し替えると、見た目は既定のマーカーと同じになります。L.icon で作ったアイコンは、画像の場所を探さず、渡した URL をそのまま使います。

TypeScript で PNG を import するときは、型の宣言が要ります。宣言がないと、tsc は3つの import のそれぞれで TS2307(モジュールが見つからない)のエラーを出しました。検証では、次の宣言を置きました。

ts
// assets.d.ts
declare module '*.png' {
  const url: string;
  export default url;
}
declare module '*.css';

2つ目の declare module '*.css' は、main.tsx で leaflet.css を import するための宣言です。これがないと、tsc は CSS の import で TS2882 のエラーを出しました。

icon={markerIcon} を渡した地図は、開発サーバーでもビルド後でも、マーカー画像(25×41px)が表示されました。ビルド後の src は、base64 で埋め込まれた画像の URL でした。

複数のマーカーを、すべて見える範囲で表示する

店舗の一覧のように、地点が複数あるときは、配列から Marker を並べます。地点の名前と緯度・経度は、次の配列にしました(店舗は架空のものです)。

ts
// shops.ts
import type { LatLngTuple } from 'leaflet';

export type Shop = {
  id: string;
  name: string;
  position: LatLngTuple;
};

export const shops: Shop[] = [
  { id: 'main', name: '本店', position: [35.6812, 139.7671] },
  { id: 'east', name: '東口店', position: [35.6896, 139.7006] },
  { id: 'bay', name: '湾岸店', position: [35.6298, 139.7942] },
];

地点が複数あると、どこを中心に、どの縮尺で見せればよいかを決めるのが面倒です。MapContainer に center と zoom の代わりに bounds を渡すと、その範囲が収まるように表示されます。MapContainer の説明にあるとおり、bounds と boundsOptions は MapContainer が受け付ける props です。

tsx
const bounds = L.latLngBounds(shops.map((s) => s.position));

<MapContainer
  bounds={bounds}
  boundsOptions={{ padding: [40, 40] }}
  style={{ height: 400 }}
>
  <TileLayer url={tileUrl} attribution="検証用のダミータイル" />
  {shops.map((shop) => (
    <Marker key={shop.id} position={shop.position} icon={markerIcon}>
      <Popup>{shop.name}</Popup>
    </Marker>
  ))}
</MapContainer>

L.latLngBounds は、渡した地点をすべて含む範囲を作ります。boundsOptions の padding は、地図の端に空けておく余白(px)で、地点はこの余白の内側に収まるように表示されます(Leaflet の fitBounds のオプションの説明)。幅800pxの画面に置いた、幅768px・高さ400pxの地図で試したところ、縮尺は12になり、3つのマーカーがすべて地図の中に入りました。

3つの青いマーカーが、地図の中に収まって表示されている
bounds を渡して、3つのマーカーがすべて見える範囲で表示したところ

一覧のボタンで地図を動かす

一覧の店名のボタンを押したら、その店まで地図を動かしてポップアップを開く、という動きを付けます。ここで気を付けるのは、MapContainer の props を変えても地図は動かないことです。

MapContainer の説明には、children 以外の props は変えられず、最初に決めたあとで変えても地図には反映されない、と書かれています。試しに、center に state の値を渡し、ボタンで別の店の緯度・経度に変えました。center には新しい値が渡っていましたが、地図の中心は最初の位置のままでした。

地図を動かすには、Leaflet の地図のインスタンスを使います。MapContainer の中に置いたコンポーネントでは、useMap でインスタンスを受け取れます。

tsx
// ShopMap.tsx
import { useEffect, useRef, useState } from 'react';
import { MapContainer, Marker, Popup, TileLayer, useMap } from 'react-leaflet';
import L from 'leaflet';
import type { Marker as LeafletMarker } from 'leaflet';
import { shops, type Shop } from './shops';
import { markerIcon } from './markerIcon';

const bounds = L.latLngBounds(shops.map((s) => s.position));

function FlyToShop({ shop, markers }: {
  shop: Shop | null;
  markers: React.RefObject<Map<string, LeafletMarker>>;
}) {
  const map = useMap();
  useEffect(() => {
    if (!shop) return;
    map.setView(shop.position, 15);
    markers.current.get(shop.id)?.openPopup();
  }, [map, shop, markers]);
  return null;
}

export function ShopMap({ tileUrl }: { tileUrl: string }) {
  const [selected, setSelected] = useState<Shop | null>(null);
  const markers = useRef(new Map<string, LeafletMarker>());

  return (
    <div>
      <MapContainer
        bounds={bounds}
        boundsOptions={{ padding: [40, 40] }}
        style={{ height: 400 }}
      >
        <TileLayer url={tileUrl} attribution="検証用のダミータイル" />
        {shops.map((shop) => (
          <Marker
            key={shop.id}
            position={shop.position}
            icon={markerIcon}
            ref={(m) => {
              if (m) markers.current.set(shop.id, m);
              else markers.current.delete(shop.id);
            }}
          >
            <Popup>{shop.name}</Popup>
          </Marker>
        ))}
        <FlyToShop shop={selected} markers={markers} />
      </MapContainer>
      <ul>
        {shops.map((shop) => (
          <li key={shop.id}>
            <button type="button" onClick={() => setSelected(shop)}>
              {shop.name}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

ボタンは地図の外にあるので、押したときは selected の state を変えるだけにしています。地図の中に置いた FlyToShop が selected の変化を受け取り、map.setView で地図を動かします。

ポップアップを開くには、Leaflet のマーカーのインスタンスが要ります。react-leaflet の考え方の説明にあるとおり、react-leaflet のコンポーネントの ref からは、作られた Leaflet のインスタンスを受け取れます。ここでは、店の id をキーにして Map にためておき、openPopup() を呼んでいます。

「湾岸店」のボタンを押すと、地図の中心が湾岸店の緯度・経度になり、縮尺が15になって、ポップアップに「湾岸店」が出ました。続けて「東口店」を押すと、東口店に移って、ポップアップは「東口店」に替わりました。

地図の中央の東口店のマーカーに「東口店」のポップアップが出ていて、地図の下に本店・東口店・湾岸店のボタンが並んでいる
「東口店」のボタンを押したところ

Leaflet のリファレンスにある flyTo の説明のとおり、setView の代わりに flyTo を使うと、なめらかに移動するアニメーションになります。

​Posted by 86