Leaflet / React-Leaflet 地図実装ガイド
ブラウザで地図を組む実装ノート。React-Leaflet v5 の基本(タイル / マーカー)、GeoJSON / 円描画、ヒート / 密度マップまでを 1 ページに統合。
React Leaflet v5 で地図を表示する
React Leaflet v5(react-leaflet + leaflet)で OpenStreetMap タイルを表示し、Marker / Popup を最小コードで配置する方法、SSR / アイコン解決の落とし穴を触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
react-leaflet@5.0.0/leaflet@1.9.4対象: React で軽量な地図(店舗マップ・配送経路 viewer など)を組みたい人
React-Leaflet(Leaflet を React に橋渡しするラッパ)で地図を組む入門。<MapContainer> + <TileLayer> + <Marker> + <Popup> の最小構成、event hook、useMap の使い所を動く demo で確認します。
触って試す
ボタンで主要駅にフライト + Marker / Popup。
なぜ Leaflet を選ぶか
| 選択肢 | 特徴 |
|---|---|
| Leaflet | 軽量(40KB)、無料(OSM タイル可)、a11y 最低限 |
| Mapbox GL JS | ベクター、独自スタイル、API key 必要(無料枠あり) |
| Google Maps | UX 優れる、料金高め、API key 必要 |
| MapLibre | Mapbox の OSS fork、追加コストなし |
| deck.gl | 大規模可視化(数百万点)、学習コスト高 |
「無料・軽量・基本機能で十分」なら Leaflet が一番手堅い。
最小サンプル
<MapContainer> で地図領域を確保 → <TileLayer> で背景地図 → <Marker> + <Popup> でピン + 吹き出し:
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import "leaflet/dist/leaflet.css";
export function Map() {
return (
<MapContainer center={[35.681, 139.767]} zoom={11} style={{ height: 400 }}>
<TileLayer
attribution='© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[35.681, 139.767]}>
<Popup>東京駅</Popup>
</Marker>
</MapContainer>
);
}
CSS import 必須:leaflet/dist/leaflet.css。
OpenStreetMap タイル利用ルール
OSM の公式タイル(tile.openstreetmap.org)は 小規模利用に限定。商用 / 大量配信には:
- MapTiler(月 10 万 req まで無料)
- Stadia Maps(月 20 万 req まで無料)
- OSM の独自タイルサーバー構築(数十 GB のディスクが必要)
を検討。<TileLayer url="..." /> の URL を差し替えるだけ。
アイコン解決問題(必読)
Leaflet のデフォルトマーカー画像が webpack/vite で解決されず マーカーが表示されない問題は有名。明示的にアイコンを設定:
import L from "leaflet";
const icon = new L.Icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [0, -41],
});
<Marker position={[lat, lng]} icon={icon}>...</Marker>
CDN ロードか、public/ に画像を置いて自前ホストするのが安全。
center / zoom の動的更新
<MapContainer /> の center / zoom は 初期値のみ。動的に変えるには key を変える、または useMap() フックで命令的に呼ぶ:
function FlyTo({ pos }: { pos: [number, number] }) {
const map = useMap();
useEffect(() => { map.flyTo(pos, 11); }, [pos]);
return null;
}
<MapContainer center={[35, 139]} zoom={5}>
<TileLayer ... />
<FlyTo pos={selectedPos} />
</MapContainer>
つまずいたポイント
- 親要素に高さが必要:
<MapContainer style={{ height: ... }} />を必ず指定 - アイコンが出ない(上記)
- SSR で
window is not defined:Astro / Next.js ではclient:only="react"またはclient:load、SSR 出力には空コンテナだけ渡す - center を変えても更新されない:
keyを変える oruseMap()で命令的に leaflet.cssの読み込み忘れ:タイルが歯抜けになる、コントロールが崩れるMarkerのpositionを再設定:positionを変えるだけで再描画される(state 同期可)
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 学習コスト | ◎ | declarative + 公式 example が豊富 |
| バンドル | ◎ | leaflet 40KB + react-leaflet 10KB |
| カスタマイズ | ○ | アイコン / TileLayer / Polygon / GeoJSON 対応 |
| 大規模可視化 | △ | 数千点まで、それ以上は deck.gl |
| TypeScript | ◎ | 型同梱(react-leaflet)、leaflet は @types/leaflet |
向く / 向かないケース
- 向く: 店舗マップ、配送追跡、簡単な GeoJSON viewer、ヒートマップ、ハッカソン
- 向かない: 数万点のクラスタリング(Mapbox GL or deck.gl)
- 向かない: 3D / WebGL ベース可視化、ベクタータイルのスタイル動的変更
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
React Leaflet v5 で GeoJSON / Circle / Tooltip を重ねる
React Leaflet v5 の中級レイヤー — GeoJSON Polygon の style 関数 / onEachFeature による popup 紐付け、Circle で件数比例の可視化、Tooltip と Popup の使い分け、レイヤー表示切替パターンを触れる demo で確認する実装メモ。
検証日: 2026-05-10
使用バージョン:
react-leaflet@5.0.0/leaflet@1.9.4対象: 入門は通っている、領域可視化や件数バブル表示などダッシュボード級のマップを作りたい人
React-Leaflet v5 で GeoJSON(地理境界データ)による領域塗り と 件数バブル(<Circle>) を組合せるパターン。onEachFeature での event hook、fitBounds での自動 zoom、styling の動的切替を動く demo で確認します。
触って試す
3 つのレイヤー(GeoJSON / Circle / Marker)を切替えて重ねた状態を確認できる。
1. GeoJSON Polygon を style 関数で着色
GeoJSON FeatureCollection を渡し、style を関数で受ける と feature ごとに色を分けられる。
import { GeoJSON } from "react-leaflet";
const regions = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { name: "首都圏", color: "#3b82f6" },
geometry: { type: "Polygon", coordinates: [[/* lng,lat の配列 */]] },
},
// ...
],
};
<GeoJSON
data={regions}
style={(feature) => ({
color: feature?.properties?.color ?? "#888",
weight: 2,
fillOpacity: 0.15,
})}
onEachFeature={(feature, layer) => {
const name = feature.properties?.name;
if (name) layer.bindPopup(name);
}}
/>
ポイント:
- GeoJSON の座標は [lng, lat] の順(Leaflet の他 API は [lat, lng] が多いので混乱しがち)
styleは feature ごとに評価される関数:データ駆動で塗り分けが可能onEachFeature(feature, layer)で個別 feature に Popup / Tooltip / event を紐付け- fillOpacity を低めに(0.1〜0.25)、その上にマーカーや Circle を重ねても見える
GeoJSON データの取得は静的 import / fetch どちらでも:
import regions from "../data/regions.geojson?raw"; // 文字列で
import regions from "../data/regions.json"; // import attributes で
// fetch するなら
useEffect(() => {
fetch("/api/regions").then(r => r.json()).then(setRegions);
}, []);
2. Circle で件数比例可視化(バブルマップ)
各拠点の数値を 半径に対応させた Circle で重ねる。
import { Circle, Popup } from "react-leaflet";
const stations = [
{ id: 1, name: "東京駅", lat: 35.681, lng: 139.767, count: 234 },
// ...
];
{stations.map((s) => (
<Circle
key={s.id}
center={[s.lat, s.lng]}
radius={s.count * 100} // メートル単位
pathOptions={{ color: "#1d4ed8", fillOpacity: 0.18 }}
>
<Popup>
<strong>{s.name}</strong><br />
count = {s.count}
</Popup>
</Circle>
))}
ポイント:
radiusの単位はメートル(地図の zoom が変わると視覚的な大きさは変わるが、実距離は固定)CircleMarkerは ピクセル指定:zoom に関係なく一定の大きさ(用途で使い分け)fillOpacity: 0.15-0.25で重なっても見える
「人口」「売上」のような 値を半径に比例 させると一目で分布が分かる。値が大きく違う時は Math.sqrt(value) * scale で 面積比例 にすると視覚的に正確。
3. Tooltip vs Popup
| Tooltip | Popup | |
|---|---|---|
| 表示 | hover で出る軽量ラベル | クリックで開く詳細 |
| permanent | permanent={true} で常時表示可 | クリックでのみ |
| サイズ | 1 行が基本 | リッチなコンテンツ可 |
<Marker position={[lat, lng]}>
<Tooltip direction="top" offset={[0, -36]}>
{name} <span>({count})</span>
</Tooltip>
<Popup>
<strong>{name}</strong>
<p>詳細情報...</p>
</Popup>
</Marker>
両方つけると hover で Tooltip、click で Popup。Tooltip permanent でラベルを地図上に焼き付けた表示にもできる(ラベル付き地図を作る時)。
4. レイヤー表示切替
state でレイヤーごとに on/off を持ち、条件 render するだけ:
const [layers, setLayers] = useState({ geojson: true, circles: true, markers: true });
<MapContainer>
<TileLayer ... />
{layers.geojson && <GeoJSON ... />}
{layers.circles && stations.map(s => <Circle ... />)}
{layers.markers && stations.map(s => <Marker ... />)}
</MapContainer>
UI コントロール:
{(["geojson", "circles", "markers"] as const).map((k) => (
<label key={k}>
<input type="checkbox" checked={layers[k]} onChange={() => setLayers((p) => ({ ...p, [k]: !p[k] }))} />
{k}
</label>
))}
Leaflet 標準の <LayersControl> もあるが、UI を完全自前にするなら React state のほうが扱いやすい。
5. クラスタリング(大量マーカー)
数千以上のマーカーを表示する時は leaflet.markercluster か react-leaflet-cluster:
import MarkerClusterGroup from "react-leaflet-cluster";
<MarkerClusterGroup chunkedLoading>
{stations.map((s) => (
<Marker key={s.id} position={[s.lat, s.lng]} icon={icon} />
))}
</MarkerClusterGroup>
zoom out で近接マーカーが自動的に円グループ化、zoom in で展開される。chunkedLoading で大量マーカーを分割 mount してフリーズを避ける。
6. カスタムタイル(地形 / dark mode)
OSM の標準タイル以外に切替えると地図の印象が変わる:
// 地形(Stamen Terrain)
<TileLayer url="https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png"
attribution="..." />
// Dark mode 風(CartoDB Dark Matter)
<TileLayer url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution="..." />
// 衛星(Esri World Imagery)
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
attribution="..." />
各サービスの利用規約 + 商用ならば API キー必要なものもあるので、<TileLayer attribution> を必ず指定。
7. ジオコーディング / 経路検索
Leaflet 自身は地理データを表示するだけ。「住所から座標を取る」「2 点間の経路を引く」は別 API:
| 機能 | 推奨 |
|---|---|
| 住所 → 座標 | Nominatim(OSM、無料、低レート) / Google Geocoding API(商用) |
| 経路検索 | OSRM / Mapbox Directions |
| 表示 | <Polyline positions={[[lat, lng], ...]} /> で経路描画 |
import { Polyline } from "react-leaflet";
<Polyline
positions={[[35.681, 139.767], [34.985, 135.758], [34.702, 135.495]]}
pathOptions={{ color: "#dc2626", weight: 3, dashArray: "6 6" }}
/>
つまずいたポイント
- GeoJSON 座標の順序:
[lng, lat](GeoJSON 仕様)、Leaflet の他 API は[lat, lng]が多くて混乱する <GeoJSON data={...}>の data を変える:<GeoJSON key={JSON.stringify(data)} ... />のように key を変えないと再描画されないCircleのradius単位:Circleはメートル、CircleMarkerはピクセルTooltip permanentで重なる:近接マーカーで Tooltip が重なる、offsetで上下にずらすか cluster に切り替えonEachFeature内で React state を触ってもダメ:Leaflet 内部 callback なので、bindPopup 等で済ませるかuseEffectで同期
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
leaflet.heat で密度の可視化(ヒートマップレイヤー)を作る
Leaflet 公式の leaflet.heat プラグインで Canvas ベースのヒートマップを React-Leaflet 上に重ねる実装パターン — useMap + heatLayer の React 化、radius / blur / minOpacity / gradient のチューニング、大量点(数万)向けのチャンク投入、SSR/dynamic import の流儀を触れる demo 付きで整理する実装メモ。
検証日: 2026-05-10
使用バージョン:
leaflet.heat@0.2.0/leaflet@1.9.4/react-leaflet@5.0.0対象: 「マーカーが多すぎて見づらい」「分布の濃淡を一目で見せたい」場面で、軽量にヒートマップを足したい人
Leaflet 公式の leaflet.heat プラグインで Canvas ベースのヒートマップ を React-Leaflet 上に重ねるパターン。useMap + heatLayer の React 化、radius / blur / gradient のチューニング、大量点(数万)向けのチャンク投入を動く demo で確認します。
触って試す
点数 / radius / blur のスライダで分布の見え方が変わるのを確認できる。Canvas で描画しているので 1000 点ぐらいなら全く重くない。
1. なぜ leaflet.heat か
ヒートマップ系の選択肢:
leaflet.heat:Leaflet 公式の Vladimir Agafonkin 作。Canvas ベース、~3KB、依存ゼロheatmap.js:汎用ヒートマップだが、Leaflet 連携は別 plugin が必要deck.gl HexagonLayer/HeatmapLayer:WebGL で速いが、deck.gl 一式が重い(数百 KB)- 自前 Canvas + GIS ライブラリ:作り込めるが工数大
「Leaflet を既に使っている / 数千点 / カジュアルに分布だけ見せたい」なら leaflet.heat 一択。
2. 最小導入
useMap() で Map インスタンスを取って L.heatLayer(points).addTo(map) を 1 回呼ぶ専用 component を作る:
pnpm add leaflet leaflet.heat react-leaflet
import L from "leaflet";
import "leaflet.heat"; // ← side effect で L.heatLayer が生える
const map = L.map("map").setView([35.6, 139.7], 10);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(map);
const heat = L.heatLayer(
[
[35.681, 139.767, 0.6], // [lat, lng, intensity]
[35.682, 139.770, 0.4],
// ...
],
{ radius: 25 },
);
heat.addTo(map);
ポイント:
import "leaflet.heat"の副作用 でL.heatLayerが定義される(named export ではない)- データの形式は
[lat, lng, intensity?](intensity を省略すれば 1.0) - 後から
heat.setLatLngs([...])で全置換、heat.addLatLng([...])で追加
3. React-Leaflet と統合する
react-leaflet は heatLayer をラップしていないので、useMap + useEffect で自前ラッパを書く。
import { useMap } from "react-leaflet";
import { useEffect } from "react";
import L from "leaflet";
import "leaflet.heat";
export function HeatLayer({
points,
radius = 25,
blur = 15,
minOpacity = 0.05,
gradient,
}: {
points: [number, number, number?][];
radius?: number;
blur?: number;
minOpacity?: number;
gradient?: Record<number, string>;
}) {
const map = useMap();
useEffect(() => {
const layer = (L as any).heatLayer(points, { radius, blur, minOpacity, gradient });
layer.addTo(map);
return () => {
map.removeLayer(layer);
};
}, [map, points, radius, blur, minOpacity, gradient]);
return null;
}
使い方:
<MapContainer center={[35.6, 139.7]} zoom={10} style={{ height: 400 }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<HeatLayer points={points} radius={28} blur={20} />
</MapContainer>
useEffect の cleanup で map.removeLayer(layer) を必ず呼ぶ。これを忘れると hot reload や props 変化のたびに重ね描きされて発色がおかしくなる。
4. パラメータのチューニング指針
| パラメータ | デフォルト | 役割 | 観察される効果 |
|---|---|---|---|
radius | 25 | 点の影響半径(px) | 大きいほど「もや」が広がる |
blur | 15 | ぼかし量 | 小さいと点状、大きいと滑らか |
minOpacity | 0.05 | 最小透明度 | 薄い領域を消すか、うっすら見せるか |
maxZoom | 18 | このズーム以上で intensity 上限 | ズームインで濃さが固定される閾値 |
max | 1.0 | intensity の正規化最大 | 値の絶対上限。データの 95 パーセンタイル等 |
gradient | デフォルト青→赤 | 色の対応 | テーマや暖色/寒色の使い分け |
経験則:
radius >= blurにしないと粒子状になりがち- データが疎なら radius を大きく(40-50)、密なら小さく(15-20)
maxをデータの 90-95 パーセンタイルに固定すると外れ値で全体が薄くならない- モバイルは radius を 1.5x 程度大きく して指の解像度を補う
5. グラデーションの上書き
「青→赤」のデフォルトを暖色だけにしたり、ブランド色に揃える:
const sunset = {
0.0: "rgba(255, 240, 200, 0)", // 透明
0.3: "#fde047", // 黄
0.6: "#f97316", // オレンジ
1.0: "#dc2626", // 赤
};
<HeatLayer points={pts} radius={30} blur={22} gradient={sunset} />
0 は完全透明、1 が最大強度。0.0 を rgba(…, 0)` で透明 にしないと、薄い領域に色が残って地図が見づらくなる。
6. 大量点(数万〜)の扱い
leaflet.heat 自体は数万点でも動くが、最初の setLatLngs で UI が止まる。チャンク投入で frame を解放:
async function streamPoints(layer: L.Layer, all: [number, number, number][]) {
const CHUNK = 2000;
for (let i = 0; i < all.length; i += CHUNK) {
(layer as any).setLatLngs([...((layer as any)._latlngs ?? []), ...all.slice(i, i + CHUNK)]);
await new Promise((r) => setTimeout(r, 0)); // 1 frame 譲る
}
}
数十万点なら WebGL の deck.gl + HeatmapLayer に切り替えたほうが速い。閾値の目安は 30,000 点超。
7. SSR / dynamic import の流儀
Leaflet は window を即時参照するので SSR で死ぬ。React の lazy + Suspense:
import { lazy, Suspense } from "react";
const HeatMapInner = lazy(() => import("./HeatMapInner"));
export function HeatMapDemo() {
return (
<Suspense fallback={<div>地図を読み込み中…</div>}>
<HeatMapInner />
</Suspense>
);
}
Astro なら client:only="react"、Next.js なら dynamic(() => import("..."), { ssr: false })。
8. データソースから heat に変換するパターン
実データは多くが {lat, lng, count} のような形なので、count を intensity に正規化する:
const max = Math.max(...records.map((r) => r.count));
const points: [number, number, number][] = records.map((r) => [
r.lat,
r.lng,
r.count / max, // 0..1 に正規化
]);
「外れ値を切る」のが見栄えのコツ:
// 95 パーセンタイルでクリップ
const sorted = records.map((r) => r.count).sort((a, b) => a - b);
const p95 = sorted[Math.floor(sorted.length * 0.95)];
const points = records.map((r) => [
r.lat,
r.lng,
Math.min(r.count, p95) / p95,
]);
地震、ツイート、店舗売上のような ロングテールが極端なデータ で特に効く。
9. ヒートマップ以外で「分布」を見せる選択肢
| 方法 | 強み | 弱み |
|---|---|---|
| ヒートマップ | 一目で濃淡 | 個別の点情報が消える |
| マーカークラスタ | クリック可能、件数表示 | 「いくつあるか」しか分からない |
| コロプレス(行政区塗り分け) | 区域ごとの値が読める | 区域が地理単位に縛られる |
| 六角ビニング(deck.gl) | グリッドで定量比較 | 重い、UI コストあり |
| 3D エクストルージョン(deck.gl) | 高さで強度を表現 | 視認性は専門家向け |
「素早く分布の偏り を見せたい初手 = leaflet.heat」「個別操作 / クリック が要る = cluster」「統計的に正確 に区域を比較 = choropleth or hex」と覚える。
つまずいたポイント
L.heatLayerが undefined:import "leaflet.heat"をimport L from "leaflet"の 後ろ に書く。順序が逆だと L が未定義- TypeScript 型がない:
@types/leaflet.heatは無し。(L as any).heatLayer(...)か、自前 ambient 宣言でdeclare module "leaflet" { namespace L { function heatLayer(...): Layer } } - props を変えても色が変わらない:
useEffectの依存配列にpoints / radius / blurを全部入れる。layer.setOptions(...)の API は無いので 作り直し - 画面の半分しか色がつかない:Map のサイズが SSR で 0 になっている。
MapContainerの親にheight: <px>を指定 oruseMap().invalidateSize()を 1 度呼ぶ - scroll で重なる:
map.removeLayer(layer)を cleanup で必ず。漏らすと props 変更のたびに重なって不透明度がリニアに上がる - dark mode で見えない:
gradientの0.0をrgba(0,0,0,0)にし、低強度色を地図のダークタイルから浮く色(オレンジ系)にする
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
JavaScriptによるはじめてのアルゴリズム入門
Python と JavaScriptではじめるデータビジュアライゼーション
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…