MapLibre React Native v11 入門:Expo で iOS / Android / Web に同じ地図を出す
はじめに
最近、React Native / Expo でモバイルアプリ開発を勉強しています。地図の出し方を調べていたところ、MapLibre React Native v11 が 2026 年 4 月に正式リリースされていたことを知りました。
v11 は React Native の New Architecture 専用になった大きなリリースで、API が Web 用の MapLibre GL JS に合わせて作り直されています。実際に試してみると、ネイティブと Web の地図をほぼ同じ形のコードで書けました。調べた範囲では v11 を扱う日本語の情報が少なかったため、試してわかったことをまとめます。
この記事でわかること:
MapLibre React Native v11 で何が変わったか
背景地図のプロバイダーをどう選ぶか(課金設定なしで始める方法)
Expo で iOS / Android / Web に同じ地図を出す書き方
試していてハマったところ(型定義と実装のずれなど)
対象読者:React Native / Expo の基本がわかり、地図ライブラリを探している方、または v10 から v11 への移行を考えている方。地図ライブラリの経験は不要です。
用語
用語
この記事での意味
MapLibre
オープンソースの地図描画ライブラリ。Web 用の MapLibre GL JS と、ネイティブ用の MapLibre Native がある。MapLibre React Native は後者の React Native ラッパー
スタイル(style.json)
地図の見た目を定義する JSON。どのタイルを読み、道路や水域をどの色で描くかを書く。MapLibre Style Spec という仕様に従う
タイル
地図を細かく区切ったデータ。拡大・移動するたびに必要な分だけ読み込まれる
GeoJSON
地理データの JSON 形式。座標は [経度, 緯度] の順で書く
New Architecture
React Native の新しい内部構造(Fabric / TurboModules)
Development Build
ネイティブモジュールを含めて自分でビルドする開発用アプリ。Expo Go では動かないライブラリを試すときに使う
1. v11 で何が変わったか
v11 の移行ガイドによると、v11 は New Architecture 専用の最初のリリースで、API が MapLibre GL JS に合わせて作り直されています。要件は React Native 0.80 以上、Android API 23 以上です(Getting Started)。
主な変更点です。
v10
v11
MapView
Map
ShapeSource
GeoJSONSource
FillLayer / LineLayer / CircleLayer など
Layer(type で種類を指定)
PointAnnotation
ViewAnnotation
Camera の centerCoordinate / zoomLevel / heading
center / zoom / bearing
Camera の defaultSettings
initialViewState
moveTo() / setCamera()
jumpTo() / setStop()(easeTo() を追加)
Layer の style
スタイル仕様どおりの paint / layout
sourceID / belowLayerID / minZoomLevel
source / beforeId / minzoom
イベントの引数
NativeSyntheticEvent 形式(e.nativeEvent に値が入る)
Android では、描画に使う View が TextureView から GLSurfaceView に変わりました(androidView="texture" で戻せます)。
いちばん大きいのは、Layer の paint / layout がスタイル仕様そのままになったことです。MapLibre GL JS の map.addLayer({ type: 'circle', paint: {...} }) と同じオブジェクトを、ほぼそのまま <Layer type="circle" paint={...} /> に渡せます。Web の MapLibre の資料やスタイルエディターで作った定義を、そのままネイティブでも使えるということです。
2. 背景地図はどこから持ってくるか
MapLibre は「地図を描くライブラリ」なので、タイルや style.json は別のプロバイダーから持ってくる必要があります。私は Geoapify の Free プランを使いました。
Google Maps Platform
MapLibre + Geoapify
地図の描画
Maps SDK(モバイルの地図表示は無料)
MapLibre(オープンソース)
住所検索
Places / Geocoding API(無料枠は SKU ごとに異なる。Geocoding は月 10,000 件など)
Geocoding / Autocomplete API(1 リクエスト 1 クレジット)
無料枠
2025 年 3 月から、月 200 ドルのクレジットが SKU ごとの無料枠に変わった
Free プラン:1 日 3,000 クレジット、最大 5 リクエスト/秒
始めるのに必要なもの
請求先アカウント(課金設定)
API キーのみ(カード不要)。商用利用可、出典表示が必要
Web とネイティブ
別々の SDK
MapLibre GL JS と MapLibre Native が同じスタイル仕様を使う
MapLibre React Native は MIT ライセンス、MapLibre GL JS は BSD-3-Clause ライセンスで公開されています。料金と無料枠は 2026 年 10 月 15 日時点の情報なので、利用前に各サービスの最新の料金表を確認してください。
Google Maps のモバイル SDK は地図の表示自体は無料なので、「Google Maps は高い」というわけではありません。ただ、無料枠の範囲でも請求先アカウント(カード登録)が必要です。勉強や個人開発でカードを登録せずに始めたいなら、Geoapify の Free プランは選択肢になります(Geoapify Pricing)。
クレジットの消費は、タイル 1 枚が 0.25 クレジット、住所検索が 1 リクエスト 1 クレジットです(Map Tiles)。拡大・移動を繰り返すとタイルの消費が増える点は覚えておきましょう。
!
地図と住所検索を組み合わせるときは、両方のサービスの利用条件を確認してください。
Google の Places API の結果を地図上に表示する場合は、Google の地図を使う必要があります(Places API のポリシー)。そのため、「地図は MapLibre、検索結果は Google Places」という構成には注意が必要です。
OpenStreetMap 公式の Nominatim(公開サーバー)は、利用ポリシーでオートコンプリート用途が禁止されています(Nominatim Usage Policy)。「OSM だから無料で使える」と考えて検索候補に使わないよう注意してください。
3. Expo で 3 プラットフォームに同じ地図を出す
ここからは、次の 2 つの地図を作りながら v11 の書き方を見ていきます。
地点を選ぶ地図:タップ・ドラッグでピンを置き、指定した半径の円を描く
複数の場所を表示する地図:番号付きのピンを並べ、全部が入るようにカメラを合わせる
動作確認した環境(2026-10-07)
ソフトウェア
バージョン
Expo SDK
57.0.26
React Native
0.86.3(New Architecture)
React
19.2.3
@maplibre/maplibre-react-native
11.5.0
maplibre-gl(Web)
5.22.0
Android エミュレーター(API 36)、iOS シミュレーター(iOS 27)、Chromium(Expo Web)で確認しました。実機では確認していません。
セットアップ
npx expo install @maplibre/maplibre-react-native maplibre-gl expo-dev-client
app.json にプラグインを追加します。
app.json
{
"expo": {
"plugins": [
"expo-dev-client",
"@maplibre/maplibre-react-native"
]
}
}
MapLibre はネイティブコードを含むので Expo Go では動きません。npx expo run:android / npx expo run:ios で Development Build を作ってください(Expo のセットアップ)。
最後に、Geoapify でプロジェクトと API キーを作成し、プロジェクトのルートの .env.local に設定します。
.env.local
EXPO_PUBLIC_GEOAPIFY_MAPS_KEY=YOUR_GEOAPIFY_API_KEY
.env.local をリポジトリにコミットしないよう .gitignore を確認してください。ただし、Git に含めなくても EXPO_PUBLIC_* の値はアプリに埋め込まれます(5 章)。
ファイルの分け方
components/
├── map-model.ts # 共通:Props の型・スタイル URL・GeoJSON
├── LocationMap.tsx # TypeScript 用のフォールバック
├── LocationMap.native.tsx # iOS / Android:MapLibre React Native
└── LocationMap.web.tsx # Web:MapLibre GL JS
Metro はプラットフォーム別の拡張子を解決するので、import { LocationMap } from './LocationMap' と書けば、実行時に .native.tsx か .web.tsx が自動で選ばれます。
ただし、TypeScript(tsc)は既定の設定では Metro のように .native.tsx / .web.tsx を選びません。そこで、型チェック用にプラットフォーム接尾辞のない LocationMap.tsx を置いておきます(tsconfig.json の moduleSuffixes を使う方法もあります)。
components/LocationMap.tsx
// TypeScript 用のフォールバック。実行時は Metro が .native.tsx か .web.tsx を選ぶ
export { LocationMap } from './LocationMap.native';
共通部分:Props・スタイル・GeoJSON
components/map-model.ts
import type { FeatureCollection } from 'geojson';
export type Point = { latitude: number; longitude: number };
export type LocationMapProps = {
point: Point | null;
radius?: number; // メートル。指定すると円を描く
onChoose?: (point: Point) => void;
onError: () => void;
};
// EXPO_PUBLIC_* はアプリに埋め込まれ、利用者から参照できる。秘密情報は置かない(5 章)
export const mapStyle = () => {
const key = process.env.EXPO_PUBLIC_GEOAPIFY_MAPS_KEY?.trim();
return key
? https://maps.geoapify.com/v1/styles/osm-carto/style.json?apiKey=${encodeURIComponent(key)}
: null;
};
export const initialCenter: [number, number] = [105.852, 21.028]; // [経度, 緯度](ハノイ)
ピンと半径の円は、どちらも GeoJSON で作ります。
components/map-model.ts(続き)
export function mapFeatures(point: Point | null, radius?: number): FeatureCollection {
if (!point) return { type: 'FeatureCollection', features: [] };
const features: FeatureCollection['features'] = [{
type: 'Feature',
properties: { kind: 'pin' },
geometry: { type: 'Point', coordinates: [point.longitude, point.latitude] }, // 経度が先
}];
if (radius && Number.isFinite(radius) && radius > 0) {
// 半径の円は多角形で近似する(circleRing は下の details を参照)
features.unshift({ type: 'Feature', properties: { kind: 'area' },
geometry: { type: 'Polygon', coordinates: [circleRing(point, radius)] } });
}
return { type: 'FeatureCollection', features };
}
circleRing の実装
components/map-model.ts(続き)
// 中心から radius メートル離れた 64 点を球面上で求める
function circleRing(point: Point, radius: number): number[][] {
const lat = point.latitude * Math.PI / 180;
const lng = point.longitude * Math.PI / 180;
const angle = Math.min(radius, 20_000_000) / 6_371_008.8; // 地球の平均半径
const ring = Array.from({ length: 65 }, (_, i) => {
const bearing = 2 * Math.PI * i / 64;
const y = Math.asin(Math.sin(lat) * Math.cos(angle) + Math.cos(lat) * Math.sin(angle) * Math.cos(bearing));
const x = lng + Math.atan2(
Math.sin(bearing) * Math.sin(angle) * Math.cos(lat),
Math.cos(angle) - Math.sin(lat) * Math.sin(y),
);
return [x * 180 / Math.PI, y * 180 / Math.PI];
});
ring[64] = ring[0]; // 多角形は始点と終点を同じ座標で閉じる
return ring;
}
circle レイヤーの circle-radius はピクセル単位なので、「半径 1 km」のような地理的な円は描けません。そのため、円を多角形として計算しています。
!
アプリの状態は { latitude, longitude }、GeoJSON とライブラリは [経度, 緯度] です。変換は地図コンポーネントとの境界でだけ行い、それ以外の場所に配列の座標を持ち込まないようにすると、緯度と経度の取り違えを防ぎやすくなります。
ネイティブ版
components/LocationMap.native.tsx
import { useEffect, useRef } from 'react';
import { Map, Camera, GeoJSONSource, Layer, type CameraRef } from '@maplibre/maplibre-react-native';
import { initialCenter, mapFeatures, mapStyle, type LocationMapProps } from './map-model';
export function LocationMap({ point, radius, onChoose, onError }: LocationMapProps) {
const camera = useRef<CameraRef>(null);
const style = mapStyle();
useEffect(() => { if (!style) onError(); }, [style, onError]);
useEffect(() => {
if (point) camera.current?.jumpTo({ center: [point.longitude, point.latitude] });
}, [point]);
if (!style) return null;
return (
<Map
style={{ flex: 1 }}
mapStyle={style}
onDidFailLoadingMap={onError}
onPress={(e) => {
const [longitude, latitude] = e.nativeEvent.lngLat;
onChoose?.({ latitude, longitude });
}}
onRegionDidChange={(e) => {
// ユーザーが動かしたときだけ。jumpTo などのプログラムによる移動は無視する(4-3)
if (!e.nativeEvent.userInteraction) return;
const [longitude, latitude] = e.nativeEvent.center;
onChoose?.({ latitude, longitude });
}}
>
<Camera
ref={camera}
initialViewState={{
center: point ? [point.longitude, point.latitude] : initialCenter,
zoom: radius ? 11 : 14,
}}
/>
<GeoJSONSource id="location-choice" data={mapFeatures(point, radius)}>
<Layer id="location-area" type="fill"
filter={['==', ['get', 'kind'], 'area']}
paint={{ 'fill-color': '#147d73', 'fill-opacity': 0.18 }} />
<Layer id="location-pin" type="circle"
filter={['==', ['get', 'kind'], 'pin']}
paint={{ 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff' }} />
</GeoJSONSource>
</Map>
);
}
Web 版と並べてみる
Web 版は MapLibre GL JS のインスタンスを命令的に操作します。レイヤーを追加する部分だけ抜き出すと、ネイティブ版と同じ paint オブジェクトがそのまま使えているのがわかります。
LocationMap.web.tsx(抜粋)
instance.addSource('choice', { type: 'geojson', data: mapFeatures(point, radius) });
instance.addLayer({ id: 'area', type: 'fill', source: 'choice',
filter: ['==', ['get', 'kind'], 'area'],
paint: { 'fill-color': '#147d73', 'fill-opacity': 0.18 } });
instance.addLayer({ id: 'pin', type: 'circle', source: 'choice',
filter: ['==', ['get', 'kind'], 'pin'],
paint: { 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' } });
ネイティブは React コンポーネントで宣言し、Web はインスタンスを命令的に操作するので、イベントの形式やライフサイクルの扱いは違います。それでも paint と GeoJSON の定義は共通にでき、Map / GeoJSONSource / jumpTo / lngLat といった名前もそろっています。v10 では MapView / ShapeSource / CircleLayer style={...} だったので、Web と並べたときの読みやすさは大きく変わりました。
LocationMap.web.tsx の全体
components/LocationMap.web.tsx
import { useEffect, useRef } from 'react';
import { Map, type GeoJSONSource } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { initialCenter, mapFeatures, mapStyle, type LocationMapProps } from './map-model';
export function LocationMap(props: LocationMapProps) {
const host = useRef<HTMLDivElement>(null);
const map = useRef<Map | null>(null);
const latest = useRef(props); // イベントハンドラーから常に最新の Props を読むため
latest.current = props;
// 地図の生成は 1 回だけ
useEffect(() => {
const style = mapStyle();
if (!host.current || !style) { latest.current.onError(); return; }
const { point, radius } = latest.current;
const instance = new Map({
container: host.current,
style,
center: point ? [point.longitude, point.latitude] : initialCenter,
zoom: radius ? 11 : 14,
attributionControl: { compact: true },
});
map.current = instance;
instance.on('error', () => latest.current.onError());
instance.on('load', () => {
// スタイルの読み込み中に Props が変わることもあるので、ここでも最新の値を使う
const { point, radius } = latest.current;
instance.addSource('choice', { type: 'geojson', data: mapFeatures(point, radius) });
instance.addLayer({ id: 'area', type: 'fill', source: 'choice',
filter: ['==', ['get', 'kind'], 'area'],
paint: { 'fill-color': '#147d73', 'fill-opacity': 0.18 } });
instance.addLayer({ id: 'pin', type: 'circle', source: 'choice',
filter: ['==', ['get', 'kind'], 'pin'],
paint: { 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' } });
});
instance.on('click', (e) => latest.current.onChoose?.({ latitude: e.lngLat.lat, longitude: e.lngLat.lng }));
instance.on('moveend', (e) => {
if (!e.originalEvent) return; // ユーザー操作のときだけ(4-3)
const c = instance.getCenter();
latest.current.onChoose?.({ latitude: c.lat, longitude: c.lng });
});
// 親要素のサイズが変わっても地図が崩れないように(4-4)
const resize = new ResizeObserver(() => instance.resize());
resize.observe(host.current);
return () => { resize.disconnect(); instance.remove(); map.current = null; };
}, []);
// Props が変わったらデータだけ差し替える
useEffect(() => {
const instance = map.current;
(instance?.getSource('choice') as GeoJSONSource | undefined)?.setData(mapFeatures(props.point, props.radius));
if (props.point) instance?.jumpTo({ center: [props.point.longitude, props.point.latitude] });
}, [props.point, props.radius]);
return <div ref={host} style={{ width: '100%', height: '100%', minHeight: 240 }} />;
}
maplibre-gl/dist/maplibre-gl.css の import で TypeScript が「モジュールが見つからない」と言う場合は、宣言ファイルを 1 つ置けば解決します。
map-css.d.ts
declare module 'maplibre-gl/dist/maplibre-gl.css';
mapFeatures で作った半径の円と出典表示(Web 版)
地図:Powered by Geoapify · © OpenStreetMap contributors
React の View をそのままピンにする
複数の場所を表示する地図では、番号付きの丸いピンを React Native の View で描きました。v11 の Marker は子要素に任意の View を置けるので、アクセシビリティ情報もそのまま付けられます。
PlacesMap.native.tsx(抜粋)
<Map style={{ flex: 1 }} mapStyle={style} onDidFinishLoadingMap={fit}>
<Camera ref={camera} initialViewState={{ center: [center.longitude, center.latitude], zoom: 12 }} maxZoom={18} />
{places.map((place, index) => (
<Marker key={place.id} id={place.id} lngLat={[place.point.longitude, place.point.latitude]}
onPress={() => onSelect(place.id)}>
<View accessibilityRole="button" accessibilityLabel={place.name}
style={{ width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center',
borderWidth: 3, borderColor: '#fff', backgroundColor: selected === place.id ? '#102b4e' : '#2455dc' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{index + 1}</Text>
</View>
</Marker>
))}
</Map>
Web 版では、同じ見た目の <button> 要素を作って new maplibregl.Marker({ element }) に渡せば同じ表示になります。
同じ Props から描いた 3 プラットフォームの地図(Geoapify osm-carto スタイル)
地図:Powered by Geoapify · © OpenStreetMap contributors
4. ハマりどころ
4-1. fitBounds() の型は void だが、実際は Promise を返す
複数のピンを表示する地図で、データが変わるたびにすべてのピンが入るようカメラを合わせていました。最初は次のように書いていました。
// ❌ 修正前
const fit = () => camera.current?.fitBounds(bounds, { padding, duration: 0 });
useEffect(fit, [center, places]);
画面を切り替えると、React の警告が出ました(抜粋)。
useEffect must not return anything besides a function, which is used for clean-up. ...
v11.5.0 の型定義では fitBounds() の戻り値は void ですが、実装はネイティブモジュール(TurboModule)の setStop() の戻り値をそのまま返しています。そして setStop() の型は Promise<void> です(Camera.tsx、NativeCameraModule.ts)。
node_modules/@maplibre/maplibre-react-native/src/components/camera/NativeCameraModule.ts
setStop(reactTag: CodegenTypes.Int32, stop: NativeCameraStop): Promise<void>;
アロー関数の省略形は戻り値を返すので、useEffect に Promise がクリーンアップ関数として渡っていました。型が void なので TypeScript では検出できません。ブロックにして何も返さないようにすれば解決します。
// ✅ 修正後
const fit = () => { camera.current?.fitBounds(bounds, { padding, duration: 0 }); };
useEffect(fit, [center, places]);
v11.5.0 では jumpTo() / easeTo() / flyTo() も同じ setStop() を通るので、同じ注意が必要です。useEffect の中でカメラを動かすときは、ブロックで書いて何も返さない形にそろえておくと安全です。
再発防止のテスト(Jest)
Camera をモックして本物と同じく Promise を返すようにし、fitBounds が呼ばれても、更新・アンマウントで console.error が出ないことを確かめます(import と PlacesMap の実装は省略)。
PlacesMap.native.test.tsx(抜粋)
const mockFitBounds = jest.fn(() => Promise.resolve());
jest.mock('@maplibre/maplibre-react-native', () => {
const React = jest.requireActual('react');
const { View } = jest.requireActual('react-native');
return {
Map: View,
Marker: View,
// 本物と同じく Promise を返す Camera
Camera: ({ ref }: { ref: unknown }) => {
React.useImperativeHandle(ref, () => ({ fitBounds: mockFitBounds }));
return null;
},
};
});
test('camera promise is not returned as an effect cleanup', () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
try {
const places = [{ id: 'a', name: 'A', point: { latitude: 21.03, longitude: 105.85 } }];
const props = { center: { latitude: 21, longitude: 105 }, places, selected: null, onSelect: jest.fn() };
const view = render(<PlacesMap {...props} />);
view.rerender(<PlacesMap {...props} center={{ latitude: 22, longitude: 106 }} />);
view.unmount();
expect(mockFitBounds).toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
} finally {
error.mockRestore();
}
});
4-2. fitBounds() のデフォルトは 2 秒の fly アニメーション
v11.5.0 の実装では、fitBounds() の duration のデフォルトは 2000 ms、easing は "fly" です。データが変わるたびに 2 秒かけて飛ぶのは煩わしいので、duration: 0 を指定しています。
4-3. プログラムでカメラを動かしても「移動した」イベントは来る
「ユーザーが地図をドラッグして止めた位置」をピンにしたい場合、何も考えずに onRegionDidChange(Web では moveend)を拾うと、jumpTo() で中心を合わせたときにもイベントが来ます。すると「選択 → カメラ移動 → 選択 → …」と処理が繰り返されるおそれがあります。
ネイティブ:e.nativeEvent.userInteraction が true のときだけ処理する
Web:e.originalEvent があるとき(マウス・タッチ操作)だけ処理する
4-4. Web は親要素のサイズに追従させる
MapLibre GL JS は、生成時のコンテナのサイズでキャンバスを作ります。タブの切り替えやレイアウトの変化でサイズが変わると、地図の一部が描かれなくなります。ResizeObserver でコンテナを監視し、map.resize() を呼びます。
4-5. テストではタイルを読みに行かない
Jest:@maplibre/maplibre-react-native をモックします(4-1 のテスト)。ネイティブモジュールは Jest では動かないためです。
Playwright(Expo Web の E2E):Geoapify へのリクエストを横取りし、背景色だけのスタイルを返します。地図の操作やピンの表示は確認でき、クレジットも消費しません。
e2e/map.spec.mjs
await page.route('maps.geoapify.com', (route) => route.fulfill({
json: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#e8efe9' } }] },
}));
このモックで確認できるのは、地図の操作やピンの表示までです。実際のタイル取得、API キーの設定、出典表示は、実際のサービスを使って別に確認します。
4-6. 出典表示を忘れない
Geoapify の Free プランは出典表示が条件で、OpenStreetMap のデータにも出典表示が必要です。地図の近くに Geoapify へのリンクと、OpenStreetMap の著作権ページ(openstreetmap.org)へのリンクを表示しましょう。Web 版の MapLibre GL JS は attributionControl でスタイルに含まれる出典を表示しますが、実際に表示されているか確認してください。記事やスライドにスクリーンショットを載せるときも、画像の下に出典を添えます。
5. API キーの扱い
用途
キーの置き場所
理由
スタイル・タイル
アプリ(EXPO_PUBLIC_GEOAPIFY_MAPS_KEY)
この例ではアプリから Geoapify のスタイル・タイルを直接取得するため
住所検索(ジオコーディング)
自分の API サーバー
アプリに入れたキーは取り出せる。サーバーで回数制限をかけたい
EXPO_PUBLIC_* はビルド時にアプリに埋め込まれるため、秘密情報として扱うことはできません。キーを取り出されると、クォータを不正に消費される可能性があります。Web では Geoapify の設定で許可するオリジンやリファラーを制限できますが、ネイティブアプリを守る仕組みではないので、利用量も監視しておきましょう。
住所検索をサーバー経由にすると、キーをサーバー側で管理でき、利用者ごとの回数制限もかけられます。なお、同じ Geoapify プロジェクト内のキーはプランとクォータを共有するので、キーを分けても無料枠は別々にはなりません(Geoapify の API キー)。
まとめ
MapLibre React Native v11 は API が MapLibre GL JS に寄った:Map / GeoJSONSource / Layer と、スタイル仕様どおりの paint で、ネイティブと Web をほぼ同じ形で書ける。拡張子での切り替えと共通の map-model.ts で、1 つの Props から 3 プラットフォームに地図を出せる
地図と検索は利用条件を確認して組み合わせる:Geoapify の Free プランならカード登録なしで始められる。Google Places の結果を地図上に表示するときの条件や、Nominatim の公開サーバーではオートコンプリートが禁止されている点に注意する
型を信じすぎない:v11.5.0 の fitBounds() などのカメラ操作は、型が void でも実装では Promise を返す。useEffect の中ではブロックで書いて何も返さない
今回は、GeoJSON とスタイル定義を共通化しながら、Expo で iOS / Android / Web に地図を表示する方法を紹介しました。同じように React Native で地図を試している方の参考になればうれしいです。
参考
MapLibre React Native: Getting Started
MapLibre React Native: Migrating to v11
MapLibre React Native: Expo
MapLibre GL JS
Geoapify Pricing
Geoapify Map Tiles
Google Maps Platform の料金
Places API のポリシー
Nominatim Usage Policy
Geoapify: API キー
Expo: 環境変数
OpenStreetMap: 著作権とライセンス
Camera.tsx(v11.5.0)
Mentions
maplibre-react-native ⭐ 673 A React Native library for rendering vector tile maps on Android and iOS. 🔗 maplibre/maplibre-react-native #opensource t.co
Anybody has ghoppod understandings about this repo maplibre-gl-js in github proper good hands on on there codebases maplibre/maplibre-gl-js
MapLibre React Native v11 入門:Expo で iOS / Android / Web に同じ地図を出す はじめに 最近、React Native / Expo でモバイルアプリ開発を勉強しています。地図の出し方を調べていたところ、MapLibre React Native v11 が 2026 年 4 月に正式リリースされていたことを知りました。 v11 は React Native の New Architecture 専用になった大きなリリースで、API が Web 用の MapLibre GL JS に合わせて作り直されています。実際に試してみると、ネイティブと Web の地図をほぼ同じ形のコードで書けました。調べた範囲では v11 を扱う日本語の情報が少なかったため、試してわかったことをまとめます。 この記事でわかること: MapLibre React Native v11 で何が変わったか 背景地図のプロバイダーをどう選ぶか(課金設定なしで始める方法) Expo で iOS / Android / Web に同じ地図を出す書き方 試していてハマったところ(型定義と実装のずれなど) 対象読者:React Native / Expo の基本がわかり、地図ライブラリを探している方、または v10 から v11 への移行を考えている方。地図ライブラリの経験は不要です。 用語 用語 この記事での意味 MapLibre オープンソースの地図描画ライブラリ。Web 用の MapLibre GL JS と、ネイティブ用の MapLibre Native がある。MapLibre React Native は後者の React Native ラッパー スタイル(style.json) 地図の見た目を定義する JSON。どのタイルを読み、道路や水域をどの色で描くかを書く。MapLibre Style Spec という仕様に従う タイル 地図を細かく区切ったデータ。拡大・移動するたびに必要な分だけ読み込まれる GeoJSON 地理データの JSON 形式。座標は [経度, 緯度] の順で書く New Architecture React Native の新しい内部構造(Fabric / TurboModules) Development Build ネイティブモジュールを含めて自分でビルドする開発用アプリ。Expo Go では動かないライブラリを試すときに使う 1. v11 で何が変わったか v11 の移行ガイドによると、v11 は New Architecture 専用の最初のリリースで、API が MapLibre GL JS に合わせて作り直されています。要件は React Native 0.80 以上、Android API 23 以上です(Getting Started)。 主な変更点です。 v10 v11 MapView Map ShapeSource GeoJSONSource FillLayer / LineLayer / CircleLayer など Layer(type で種類を指定) PointAnnotation ViewAnnotation Camera の centerCoordinate / zoomLevel / heading center / zoom / bearing Camera の defaultSettings initialViewState moveTo() / setCamera() jumpTo() / setStop()(easeTo() を追加) Layer の style スタイル仕様どおりの paint / layout sourceID / belowLayerID / minZoomLevel source / beforeId / minzoom イベントの引数 NativeSyntheticEvent 形式(e.nativeEvent に値が入る) Android では、描画に使う View が TextureView から GLSurfaceView に変わりました(androidView="texture" で戻せます)。 いちばん大きいのは、Layer の paint / layout がスタイル仕様そのままになったことです。MapLibre GL JS の map.addLayer({ type: 'circle', paint: {...} }) と同じオブジェクトを、ほぼそのまま <Layer type="circle" paint={...} /> に渡せます。Web の MapLibre の資料やスタイルエディターで作った定義を、そのままネイティブでも使えるということです。 2. 背景地図はどこから持ってくるか MapLibre は「地図を描くライブラリ」なので、タイルや style.json は別のプロバイダーから持ってくる必要があります。私は Geoapify の Free プランを使いました。 Google Maps Platform MapLibre + Geoapify 地図の描画 Maps SDK(モバイルの地図表示は無料) MapLibre(オープンソース) 住所検索 Places / Geocoding API(無料枠は SKU ごとに異なる。Geocoding は月 10,000 件など) Geocoding / Autocomplete API(1 リクエスト 1 クレジット) 無料枠 2025 年 3 月から、月 200 ドルのクレジットが SKU ごとの無料枠に変わった Free プラン:1 日 3,000 クレジット、最大 5 リクエスト/秒 始めるのに必要なもの 請求先アカウント(課金設定) API キーのみ(カード不要)。商用利用可、出典表示が必要 Web とネイティブ 別々の SDK MapLibre GL JS と MapLibre Native が同じスタイル仕様を使う MapLibre React Native は MIT ライセンス、MapLibre GL JS は BSD-3-Clause ライセンスで公開されています。料金と無料枠は 2026 年 10 月 15 日時点の情報なので、利用前に各サービスの最新の料金表を確認してください。 Google Maps のモバイル SDK は地図の表示自体は無料なので、「Google Maps は高い」というわけではありません。ただ、無料枠の範囲でも請求先アカウント(カード登録)が必要です。勉強や個人開発でカードを登録せずに始めたいなら、Geoapify の Free プランは選択肢になります(Geoapify Pricing)。 クレジットの消費は、タイル 1 枚が 0.25 クレジット、住所検索が 1 リクエスト 1 クレジットです(Map Tiles)。拡大・移動を繰り返すとタイルの消費が増える点は覚えておきましょう。 ! 地図と住所検索を組み合わせるときは、両方のサービスの利用条件を確認してください。 Google の Places API の結果を地図上に表示する場合は、Google の地図を使う必要があります(Places API のポリシー)。そのため、「地図は MapLibre、検索結果は Google Places」という構成には注意が必要です。 OpenStreetMap 公式の Nominatim(公開サーバー)は、利用ポリシーでオートコンプリート用途が禁止されています(Nominatim Usage Policy)。「OSM だから無料で使える」と考えて検索候補に使わないよう注意してください。 3. Expo で 3 プラットフォームに同じ地図を出す ここからは、次の 2 つの地図を作りながら v11 の書き方を見ていきます。 地点を選ぶ地図:タップ・ドラッグでピンを置き、指定した半径の円を描く 複数の場所を表示する地図:番号付きのピンを並べ、全部が入るようにカメラを合わせる 動作確認した環境(2026-10-07) ソフトウェア バージョン Expo SDK 57.0.26 React Native 0.86.3(New Architecture) React 19.2.3 @maplibre/maplibre-react-native 11.5.0 maplibre-gl(Web) 5.22.0 Android エミュレーター(API 36)、iOS シミュレーター(iOS 27)、Chromium(Expo Web)で確認しました。実機では確認していません。 セットアップ npx expo install @maplibre/maplibre-react-native maplibre-gl expo-dev-client app.json にプラグインを追加します。 app.json { "expo": { "plugins": [ "expo-dev-client", "@maplibre/maplibre-react-native" ] } } MapLibre はネイティブコードを含むので Expo Go では動きません。npx expo run:android / npx expo run:ios で Development Build を作ってください(Expo のセットアップ)。 最後に、Geoapify でプロジェクトと API キーを作成し、プロジェクトのルートの .env.local に設定します。 .env.local EXPO_PUBLIC_GEOAPIFY_MAPS_KEY=YOUR_GEOAPIFY_API_KEY .env.local をリポジトリにコミットしないよう .gitignore を確認してください。ただし、Git に含めなくても EXPO_PUBLIC_* の値はアプリに埋め込まれます(5 章)。 ファイルの分け方 components/ ├── map-model.ts # 共通:Props の型・スタイル URL・GeoJSON ├── LocationMap.tsx # TypeScript 用のフォールバック ├── LocationMap.native.tsx # iOS / Android:MapLibre React Native └── LocationMap.web.tsx # Web:MapLibre GL JS Metro はプラットフォーム別の拡張子を解決するので、import { LocationMap } from './LocationMap' と書けば、実行時に .native.tsx か .web.tsx が自動で選ばれます。 ただし、TypeScript(tsc)は既定の設定では Metro のように .native.tsx / .web.tsx を選びません。そこで、型チェック用にプラットフォーム接尾辞のない LocationMap.tsx を置いておきます(tsconfig.json の moduleSuffixes を使う方法もあります)。 components/LocationMap.tsx // TypeScript 用のフォールバック。実行時は Metro が .native.tsx か .web.tsx を選ぶ export { LocationMap } from './LocationMap.native'; 共通部分:Props・スタイル・GeoJSON components/map-model.ts import type { FeatureCollection } from 'geojson'; export type Point = { latitude: number; longitude: number }; export type LocationMapProps = { point: Point | null; radius?: number; // メートル。指定すると円を描く onChoose?: (point: Point) => void; onError: () => void; }; // EXPO_PUBLIC_* はアプリに埋め込まれ、利用者から参照できる。秘密情報は置かない(5 章) export const mapStyle = () => { const key = process.env.EXPO_PUBLIC_GEOAPIFY_MAPS_KEY?.trim(); return key ? https://maps.geoapify.com/v1/styles/osm-carto/style.json?apiKey=${encodeURIComponent(key)} : null; }; export const initialCenter: [number, number] = [105.852, 21.028]; // [経度, 緯度](ハノイ) ピンと半径の円は、どちらも GeoJSON で作ります。 components/map-model.ts(続き) export function mapFeatures(point: Point | null, radius?: number): FeatureCollection { if (!point) return { type: 'FeatureCollection', features: [] }; const features: FeatureCollection['features'] = [{ type: 'Feature', properties: { kind: 'pin' }, geometry: { type: 'Point', coordinates: [point.longitude, point.latitude] }, // 経度が先 }]; if (radius && Number.isFinite(radius) && radius > 0) { // 半径の円は多角形で近似する(circleRing は下の details を参照) features.unshift({ type: 'Feature', properties: { kind: 'area' }, geometry: { type: 'Polygon', coordinates: [circleRing(point, radius)] } }); } return { type: 'FeatureCollection', features }; } circleRing の実装 components/map-model.ts(続き) // 中心から radius メートル離れた 64 点を球面上で求める function circleRing(point: Point, radius: number): number[][] { const lat = point.latitude * Math.PI / 180; const lng = point.longitude * Math.PI / 180; const angle = Math.min(radius, 20_000_000) / 6_371_008.8; // 地球の平均半径 const ring = Array.from({ length: 65 }, (_, i) => { const bearing = 2 * Math.PI * i / 64; const y = Math.asin(Math.sin(lat) * Math.cos(angle) + Math.cos(lat) * Math.sin(angle) * Math.cos(bearing)); const x = lng + Math.atan2( Math.sin(bearing) * Math.sin(angle) * Math.cos(lat), Math.cos(angle) - Math.sin(lat) * Math.sin(y), ); return [x * 180 / Math.PI, y * 180 / Math.PI]; }); ring[64] = ring[0]; // 多角形は始点と終点を同じ座標で閉じる return ring; } circle レイヤーの circle-radius はピクセル単位なので、「半径 1 km」のような地理的な円は描けません。そのため、円を多角形として計算しています。 ! アプリの状態は { latitude, longitude }、GeoJSON とライブラリは [経度, 緯度] です。変換は地図コンポーネントとの境界でだけ行い、それ以外の場所に配列の座標を持ち込まないようにすると、緯度と経度の取り違えを防ぎやすくなります。 ネイティブ版 components/LocationMap.native.tsx import { useEffect, useRef } from 'react'; import { Map, Camera, GeoJSONSource, Layer, type CameraRef } from '@maplibre/maplibre-react-native'; import { initialCenter, mapFeatures, mapStyle, type LocationMapProps } from './map-model'; export function LocationMap({ point, radius, onChoose, onError }: LocationMapProps) { const camera = useRef<CameraRef>(null); const style = mapStyle(); useEffect(() => { if (!style) onError(); }, [style, onError]); useEffect(() => { if (point) camera.current?.jumpTo({ center: [point.longitude, point.latitude] }); }, [point]); if (!style) return null; return ( <Map style={{ flex: 1 }} mapStyle={style} onDidFailLoadingMap={onError} onPress={(e) => { const [longitude, latitude] = e.nativeEvent.lngLat; onChoose?.({ latitude, longitude }); }} onRegionDidChange={(e) => { // ユーザーが動かしたときだけ。jumpTo などのプログラムによる移動は無視する(4-3) if (!e.nativeEvent.userInteraction) return; const [longitude, latitude] = e.nativeEvent.center; onChoose?.({ latitude, longitude }); }} > <Camera ref={camera} initialViewState={{ center: point ? [point.longitude, point.latitude] : initialCenter, zoom: radius ? 11 : 14, }} /> <GeoJSONSource id="location-choice" data={mapFeatures(point, radius)}> <Layer id="location-area" type="fill" filter={['==', ['get', 'kind'], 'area']} paint={{ 'fill-color': '#147d73', 'fill-opacity': 0.18 }} /> <Layer id="location-pin" type="circle" filter={['==', ['get', 'kind'], 'pin']} paint={{ 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#ffffff' }} /> </GeoJSONSource> </Map> ); } Web 版と並べてみる Web 版は MapLibre GL JS のインスタンスを命令的に操作します。レイヤーを追加する部分だけ抜き出すと、ネイティブ版と同じ paint オブジェクトがそのまま使えているのがわかります。 LocationMap.web.tsx(抜粋) instance.addSource('choice', { type: 'geojson', data: mapFeatures(point, radius) }); instance.addLayer({ id: 'area', type: 'fill', source: 'choice', filter: ['==', ['get', 'kind'], 'area'], paint: { 'fill-color': '#147d73', 'fill-opacity': 0.18 } }); instance.addLayer({ id: 'pin', type: 'circle', source: 'choice', filter: ['==', ['get', 'kind'], 'pin'], paint: { 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' } }); ネイティブは React コンポーネントで宣言し、Web はインスタンスを命令的に操作するので、イベントの形式やライフサイクルの扱いは違います。それでも paint と GeoJSON の定義は共通にでき、Map / GeoJSONSource / jumpTo / lngLat といった名前もそろっています。v10 では MapView / ShapeSource / CircleLayer style={...} だったので、Web と並べたときの読みやすさは大きく変わりました。 LocationMap.web.tsx の全体 components/LocationMap.web.tsx import { useEffect, useRef } from 'react'; import { Map, type GeoJSONSource } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { initialCenter, mapFeatures, mapStyle, type LocationMapProps } from './map-model'; export function LocationMap(props: LocationMapProps) { const host = useRef<HTMLDivElement>(null); const map = useRef<Map | null>(null); const latest = useRef(props); // イベントハンドラーから常に最新の Props を読むため latest.current = props; // 地図の生成は 1 回だけ useEffect(() => { const style = mapStyle(); if (!host.current || !style) { latest.current.onError(); return; } const { point, radius } = latest.current; const instance = new Map({ container: host.current, style, center: point ? [point.longitude, point.latitude] : initialCenter, zoom: radius ? 11 : 14, attributionControl: { compact: true }, }); map.current = instance; instance.on('error', () => latest.current.onError()); instance.on('load', () => { // スタイルの読み込み中に Props が変わることもあるので、ここでも最新の値を使う const { point, radius } = latest.current; instance.addSource('choice', { type: 'geojson', data: mapFeatures(point, radius) }); instance.addLayer({ id: 'area', type: 'fill', source: 'choice', filter: ['==', ['get', 'kind'], 'area'], paint: { 'fill-color': '#147d73', 'fill-opacity': 0.18 } }); instance.addLayer({ id: 'pin', type: 'circle', source: 'choice', filter: ['==', ['get', 'kind'], 'pin'], paint: { 'circle-radius': 9, 'circle-color': '#147d73', 'circle-stroke-width': 3, 'circle-stroke-color': '#fff' } }); }); instance.on('click', (e) => latest.current.onChoose?.({ latitude: e.lngLat.lat, longitude: e.lngLat.lng })); instance.on('moveend', (e) => { if (!e.originalEvent) return; // ユーザー操作のときだけ(4-3) const c = instance.getCenter(); latest.current.onChoose?.({ latitude: c.lat, longitude: c.lng }); }); // 親要素のサイズが変わっても地図が崩れないように(4-4) const resize = new ResizeObserver(() => instance.resize()); resize.observe(host.current); return () => { resize.disconnect(); instance.remove(); map.current = null; }; }, []); // Props が変わったらデータだけ差し替える useEffect(() => { const instance = map.current; (instance?.getSource('choice') as GeoJSONSource | undefined)?.setData(mapFeatures(props.point, props.radius)); if (props.point) instance?.jumpTo({ center: [props.point.longitude, props.point.latitude] }); }, [props.point, props.radius]); return <div ref={host} style={{ width: '100%', height: '100%', minHeight: 240 }} />; } maplibre-gl/dist/maplibre-gl.css の import で TypeScript が「モジュールが見つからない」と言う場合は、宣言ファイルを 1 つ置けば解決します。 map-css.d.ts declare module 'maplibre-gl/dist/maplibre-gl.css'; mapFeatures で作った半径の円と出典表示(Web 版) 地図:Powered by Geoapify · © OpenStreetMap contributors React の View をそのままピンにする 複数の場所を表示する地図では、番号付きの丸いピンを React Native の View で描きました。v11 の Marker は子要素に任意の View を置けるので、アクセシビリティ情報もそのまま付けられます。 PlacesMap.native.tsx(抜粋) <Map style={{ flex: 1 }} mapStyle={style} onDidFinishLoadingMap={fit}> <Camera ref={camera} initialViewState={{ center: [center.longitude, center.latitude], zoom: 12 }} maxZoom={18} /> {places.map((place, index) => ( <Marker key={place.id} id={place.id} lngLat={[place.point.longitude, place.point.latitude]} onPress={() => onSelect(place.id)}> <View accessibilityRole="button" accessibilityLabel={place.name} style={{ width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center', borderWidth: 3, borderColor: '#fff', backgroundColor: selected === place.id ? '#102b4e' : '#2455dc' }}> <Text style={{ color: '#fff', fontWeight: '700' }}>{index + 1}</Text> </View> </Marker> ))} </Map> Web 版では、同じ見た目の <button> 要素を作って new maplibregl.Marker({ element }) に渡せば同じ表示になります。 同じ Props から描いた 3 プラットフォームの地図(Geoapify osm-carto スタイル) 地図:Powered by Geoapify · © OpenStreetMap contributors 4. ハマりどころ 4-1. fitBounds() の型は void だが、実際は Promise を返す 複数のピンを表示する地図で、データが変わるたびにすべてのピンが入るようカメラを合わせていました。最初は次のように書いていました。 // ❌ 修正前 const fit = () => camera.current?.fitBounds(bounds, { padding, duration: 0 }); useEffect(fit, [center, places]); 画面を切り替えると、React の警告が出ました(抜粋)。 useEffect must not return anything besides a function, which is used for clean-up. ... v11.5.0 の型定義では fitBounds() の戻り値は void ですが、実装はネイティブモジュール(TurboModule)の setStop() の戻り値をそのまま返しています。そして setStop() の型は Promise<void> です(Camera.tsx、NativeCameraModule.ts)。 node_modules/@maplibre/maplibre-react-native/src/components/camera/NativeCameraModule.ts setStop(reactTag: CodegenTypes.Int32, stop: NativeCameraStop): Promise<void>; アロー関数の省略形は戻り値を返すので、useEffect に Promise がクリーンアップ関数として渡っていました。型が void なので TypeScript では検出できません。ブロックにして何も返さないようにすれば解決します。 // ✅ 修正後 const fit = () => { camera.current?.fitBounds(bounds, { padding, duration: 0 }); }; useEffect(fit, [center, places]); v11.5.0 では jumpTo() / easeTo() / flyTo() も同じ setStop() を通るので、同じ注意が必要です。useEffect の中でカメラを動かすときは、ブロックで書いて何も返さない形にそろえておくと安全です。 再発防止のテスト(Jest) Camera をモックして本物と同じく Promise を返すようにし、fitBounds が呼ばれても、更新・アンマウントで console.error が出ないことを確かめます(import と PlacesMap の実装は省略)。 PlacesMap.native.test.tsx(抜粋) const mockFitBounds = jest.fn(() => Promise.resolve()); jest.mock('@maplibre/maplibre-react-native', () => { const React = jest.requireActual('react'); const { View } = jest.requireActual('react-native'); return { Map: View, Marker: View, // 本物と同じく Promise を返す Camera Camera: ({ ref }: { ref: unknown }) => { React.useImperativeHandle(ref, () => ({ fitBounds: mockFitBounds })); return null; }, }; }); test('camera promise is not returned as an effect cleanup', () => { const error = jest.spyOn(console, 'error').mockImplementation(() => {}); try { const places = [{ id: 'a', name: 'A', point: { latitude: 21.03, longitude: 105.85 } }]; const props = { center: { latitude: 21, longitude: 105 }, places, selected: null, onSelect: jest.fn() }; const view = render(<PlacesMap {...props} />); view.rerender(<PlacesMap {...props} center={{ latitude: 22, longitude: 106 }} />); view.unmount(); expect(mockFitBounds).toHaveBeenCalled(); expect(error).not.toHaveBeenCalled(); } finally { error.mockRestore(); } }); 4-2. fitBounds() のデフォルトは 2 秒の fly アニメーション v11.5.0 の実装では、fitBounds() の duration のデフォルトは 2000 ms、easing は "fly" です。データが変わるたびに 2 秒かけて飛ぶのは煩わしいので、duration: 0 を指定しています。 4-3. プログラムでカメラを動かしても「移動した」イベントは来る 「ユーザーが地図をドラッグして止めた位置」をピンにしたい場合、何も考えずに onRegionDidChange(Web では moveend)を拾うと、jumpTo() で中心を合わせたときにもイベントが来ます。すると「選択 → カメラ移動 → 選択 → …」と処理が繰り返されるおそれがあります。 ネイティブ:e.nativeEvent.userInteraction が true のときだけ処理する Web:e.originalEvent があるとき(マウス・タッチ操作)だけ処理する 4-4. Web は親要素のサイズに追従させる MapLibre GL JS は、生成時のコンテナのサイズでキャンバスを作ります。タブの切り替えやレイアウトの変化でサイズが変わると、地図の一部が描かれなくなります。ResizeObserver でコンテナを監視し、map.resize() を呼びます。 4-5. テストではタイルを読みに行かない Jest:@maplibre/maplibre-react-native をモックします(4-1 のテスト)。ネイティブモジュールは Jest では動かないためです。 Playwright(Expo Web の E2E):Geoapify へのリクエストを横取りし、背景色だけのスタイルを返します。地図の操作やピンの表示は確認でき、クレジットも消費しません。 e2e/map.spec.mjs await page.route('maps.geoapify.com', (route) => route.fulfill({ json: { version: 8, sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#e8efe9' } }] }, })); このモックで確認できるのは、地図の操作やピンの表示までです。実際のタイル取得、API キーの設定、出典表示は、実際のサービスを使って別に確認します。 4-6. 出典表示を忘れない Geoapify の Free プランは出典表示が条件で、OpenStreetMap のデータにも出典表示が必要です。地図の近くに Geoapify へのリンクと、OpenStreetMap の著作権ページ(openstreetmap.org)へのリンクを表示しましょう。Web 版の MapLibre GL JS は attributionControl でスタイルに含まれる出典を表示しますが、実際に表示されているか確認してください。記事やスライドにスクリーンショットを載せるときも、画像の下に出典を添えます。 5. API キーの扱い 用途 キーの置き場所 理由 スタイル・タイル アプリ(EXPO_PUBLIC_GEOAPIFY_MAPS_KEY) この例ではアプリから Geoapify のスタイル・タイルを直接取得するため 住所検索(ジオコーディング) 自分の API サーバー アプリに入れたキーは取り出せる。サーバーで回数制限をかけたい EXPO_PUBLIC_* はビルド時にアプリに埋め込まれるため、秘密情報として扱うことはできません。キーを取り出されると、クォータを不正に消費される可能性があります。Web では Geoapify の設定で許可するオリジンやリファラーを制限できますが、ネイティブアプリを守る仕組みではないので、利用量も監視しておきましょう。 住所検索をサーバー経由にすると、キーをサーバー側で管理でき、利用者ごとの回数制限もかけられます。なお、同じ Geoapify プロジェクト内のキーはプランとクォータを共有するので、キーを分けても無料枠は別々にはなりません(Geoapify の API キー)。 まとめ MapLibre React Native v11 は API が MapLibre GL JS に寄った:Map / GeoJSONSource / Layer と、スタイル仕様どおりの paint で、ネイティブと Web をほぼ同じ形で書ける。拡張子での切り替えと共通の map-model.ts で、1 つの Props から 3 プラットフォームに地図を出せる 地図と検索は利用条件を確認して組み合わせる:Geoapify の Free プランならカード登録なしで始められる。Google Places の結果を地図上に表示するときの条件や、Nominatim の公開サーバーではオートコンプリートが禁止されている点に注意する 型を信じすぎない:v11.5.0 の fitBounds() などのカメラ操作は、型が void でも実装では Promise を返す。useEffect の中ではブロックで書いて何も返さない 今回は、GeoJSON とスタイル定義を共通化しながら、Expo で iOS / Android / Web に地図を表示する方法を紹介しました。同じように React Native で地図を試している方の参考になればうれしいです。 参考 MapLibre React Native: Getting Started MapLibre React Native: Migrating to v11 MapLibre React Native: Expo MapLibre GL JS Geoapify Pricing Geoapify Map Tiles Google Maps Platform の料金 Places API のポリシー Nominatim Usage Policy Geoapify: API キー Expo: 環境変数 OpenStreetMap: 著作権とライセンス Camera.tsx(v11.5.0)
Finalizing the release of the C plugin API for MapLibre Native. The screenshots in the newsletter give a good idea what it can do. maplibre.org Pretty happy with this plugin API, because it will allow people to vibe code extensions while it keeps the base library somewhat stable. Not everything can be done as a plugin, however. For example, we have an open PR that adds 'globe mode'. maplibre-native#4533 This will eventually need to land in main. But I am thinking of creating a 'next' branch where new (LLM driven) contributions can marinate a bit before they make it into the mainline. We have some pretty serious users so we cannot affort vibe coding our way to instability.
Step 5: make it pretty. Pulled the most-starred map repos on GitHub: d3 (113k ⭐), ECharts (67k), Leaflet (46k), Cesium, deck.gl, Mapbox, MapLibre… Picked MapLibre GL JS: free, no API key, built-in 3D globe. maplibre/maplibre-gl-js What it built: • 3D globe with great-circle flight arcs • glowing "planes" flying every route • pulsing home base (HK → SG) • year filters + live stats • ▶ Play mode that replays every trip in order One HTML file. No server. Want your own? Send this thread to your AI agent and say "do this with my WhatsApp" — it can get the whole thing done for you 🤖🌏
Step 5: make it pretty. Pulled the most-starred map repos on GitHub: d3 (113k ⭐), ECharts (67k), Leaflet (46k), Cesium, deck.gl, Mapbox, MapLibre… Picked MapLibre GL JS: free, no API key, built-in 3D globe. maplibre/maplibre-gl-js What it built: • 3D globe with great-circle flight arcs • glowing "planes" flying every route • pulsing home base (HK → SG) • year filters + live stats • ▶ Play mode that replays every trip in order Tips if you try it: • Keep your chat DB local, never upload it to "analyzer" sites • Regex to narrow, AI to judge Your chats already know where you've been 🌏
MapLibre GL JS で任意の座標系が使えるようになるっぽい github.com/maplibre/maplibre-gl-js/blob/main/docs…