【Android】MapConductor で地図 SDK を 1 つの Compose コードで扱ってみた
はじめに
Android で地図を扱うとき、Google Maps、MapLibre、Mapbox などの SDK はそれぞれ API が違います。あとからプロバイダを替えたくなると、地図まわりのコードをほぼ書き直すことになります。
MapConductor はこうした地図 SDK を 同じ Jetpack Compose の書き方 で扱えるようにするライブラリです。この記事では APIキーなしで使える MapLibre から始めて、次の順に試していきます。
1. 地図を表示する
2. マーカーをタップして吹き出しを出す
3. 円・線・多角形を描く
4. プロバイダを切り替える(地図に載せるコードはそのまま)
!完成イメージ(左: MapLibre、右: Open Mobile Maps に切り替えた後)
完成コード: saitojo1106/mapconductor-prc
リポジトリは Step ごとにコミットを分けているので、途中の状態も確認できます。
MapConductor とは
- Google Maps、Mapbox、MapLibre、HERE、ArcGIS、Open Mobile Maps などの地図 SDK を、共通の Compose API で扱えるライブラリです。
- マーカーや図形などの「地図に載せるもの」は共通の書き方で書き、プロバイダごとに替えるのは MapView と、その状態(State)だけです。
- ライセンスは Apache-2.0 で、Maven Central に com.mapconductor:* として公開されています。
- 日本語のドキュメント もあります。
動作環境
| 項目 | バージョン |
|---|---|
| Android Studio | 2026.1 |
| AGP / Gradle | 9.4.1 / 9.6.0 |
| Kotlin | 2.4.10 |
| compileSdk / minSdk | 37 / 26 |
| MapConductor BOM | 1.3.1 |
| MapLibre Android SDK | 13.4.1 |
| Open Mobile Maps(mapscore) | 4.0.0 |
| UiAutomator(テスト) | 2.4.0 |
| 確認した端末 | エミュレータ(Pixel 8 / Android 17 / API 37、M4 Mac)、実機(Pixel 8a / Android 16) |
Step1: MapLibre で地図を表示する
Android Studio の「Empty Activity」(Compose)でプロジェクトを作り、依存関係を追加します。
[versions]
mapconductorBom = "1.3.1"
maplibreSdk = "13.4.1"
[libraries]
mapconductor-bom = { group = "com.mapconductor", name = "mapconductor-bom", version.ref = "mapconductorBom" }
mapconductor-core = { group = "com.mapconductor", name = "core" }
mapconductor-maplibre = { group = "com.mapconductor", name = "for-maplibre" }
maplibre-android-sdk = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibreSdk" }
dependencies {
implementation(libs.maplibre.android.sdk)
implementation(platform(libs.mapconductor.bom))
implementation(libs.mapconductor.core)
implementation(libs.mapconductor.maplibre)
}
:::note warn
バージョンカタログの名前に for を入れるとビルドが失敗します
モジュール名に合わせてエイリアスを mapconductor-for-maplibre にすると、アクセサが libs.mapconductor.for.maplibre になります。for は Kotlin の予約語なので、Expecting '(' to open a loop range というエラーでビルドが失敗します。この記事では mapconductor-maplibre にしています。
:::
タイルを読み込むので、AndroidManifest.xml に INTERNET 権限を追加します。
<uses-permission android:name="android.permission.INTERNET" />
地図を表示する画面です。カメラの位置と地図のデザインを State にして、MapLibreMapView に渡します。
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.mapconductor.core.features.GeoPoint
import com.mapconductor.core.map.MapCameraPosition
import com.mapconductor.maplibre.MapLibreDesign
import com.mapconductor.maplibre.MapLibreMapView
import com.mapconductor.maplibre.rememberMapLibreMapViewState
private val TOKYO_STATION = GeoPoint(35.6812, 139.7671)
@Composable
fun HelloMapScreen(modifier: Modifier = Modifier) {
val mapState = rememberMapLibreMapViewState(
cameraPosition = MapCameraPosition(
position = TOKYO_STATION,
zoom = 14.0,
),
mapDesign = MapLibreDesign.OsmBrightJa,
)
MapLibreMapView(
modifier = modifier,
state = mapState,
)
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
HelloMapScreen(modifier = Modifier.fillMaxSize())
}
}
}
}
実行すると、東京駅を中心に、日本語ラベルの地図が表示されます。
!Step1: 東京駅周辺の地図
MapLibreDesign.OsmBrightJa は OpenStreetMap Japan のタイルサーバー(tile.openstreetmap.jp)にある osm-bright-ja スタイルを使います。左下の ⓘ をタップすると帰属表示が出ます。
!Step1: 帰属表示
:::note info
このタイルは手軽に試すのには便利ですが、本番のアプリで使う前に配信元の利用条件を確認してください。スクショを記事などに載せるときは、「© OpenStreetMap contributors」の表記を残しましょう。
:::
:::note info
AGP 9 からは Kotlin が AGP に組み込まれたため、app モジュールに org.jetbrains.kotlin.android プラグインを書かなくても Kotlin が使えます。古い記事の手順と違うので、戸惑うかもしれません。
:::
Step2: マーカーと吹き出し(InfoBubble)
東京駅にマーカーを置き、タップしたら「Hello, MapConductor」の吹き出しを出します。地図をタップすると閉じます。
@Composable
fun HelloMapScreen(modifier: Modifier = Modifier) {
+ var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
+
val mapState = rememberMapLibreMapViewState(
cameraPosition = MapCameraPosition(
position = TOKYO_STATION,
zoom = 14.0,
),
mapDesign = MapLibreDesign.OsmBrightJa,
)
+ val markerState = remember {
+ MarkerState(
+ position = TOKYO_STATION,
+ onClick = { selectedMarker = it },
+ )
+ }
+
MapLibreMapView(
modifier = modifier,
state = mapState,
- )
+ onMapClick = { selectedMarker = null },
+ ) {
+ Marker(markerState)
+
+ selectedMarker?.let {
+ InfoBubble(marker = it) {
+ Text("Hello, MapConductor")
+ }
+ }
+ }
}
- MapLibreMapView の最後のラムダに、地図に載せるものを書きます。ここが MapConductor の共通部分で、プロバイダを替えても同じコードがそのまま使えます(Step4 で試します)。
- MarkerState の onClick には、タップされたマーカーの State が渡ってきます。それを覚えておき、InfoBubble(marker = ...) に渡すと、そのマーカーの上に吹き出しが出ます。
- 吹き出しの中身は普通の Composable なので、Text 以外も自由に置けます。
!Step2: マーカーをタップすると吹き出しが出る
Step3: Circle / Polyline / Polygon を描く
東京駅を中心にした半径 1km の円、東京 → 神田 → 秋葉原を結ぶ線、皇居をおおまかに囲む多角形を描きます。3つが画面に収まるよう、ズームは 13.5 に下げました。
private val KANDA_STATION = GeoPoint(35.6918, 139.7709)
private val AKIHABARA_STATION = GeoPoint(35.6984, 139.7731)
private val IMPERIAL_PALACE = listOf(
GeoPoint(35.6895, 139.7505),
GeoPoint(35.6890, 139.7570),
GeoPoint(35.6838, 139.7585),
GeoPoint(35.6790, 139.7555),
GeoPoint(35.6795, 139.7490),
GeoPoint(35.6850, 139.7470),
)
val circleState = remember {
CircleState(
center = TOKYO_STATION,
radiusMeters = 1000.0,
fillColor = Color.Blue.copy(alpha = 0.4f),
strokeColor = Color.Blue,
strokeWidth = 2.dp,
)
}
val polylineState = remember {
PolylineState(
points = listOf(TOKYO_STATION, KANDA_STATION, AKIHABARA_STATION),
strokeColor = Color.Red,
strokeWidth = 6.dp,
)
}
val polygonState = remember {
PolygonState(
points = IMPERIAL_PALACE,
fillColor = Color.Magenta.copy(alpha = 0.4f),
strokeColor = Color.Magenta,
strokeWidth = 2.dp,
)
}
MapLibreMapView(
modifier = modifier,
state = mapState,
onMapClick = { selectedMarker = null },
) {
Circle(circleState)
Polyline(polylineState)
Polygon(polygonState)
Marker(markerState)
selectedMarker?.let {
InfoBubble(marker = it) {
Text("Hello, MapConductor")
}
}
}
<details><summary>Step3 時点の import 一覧</summary>
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.mapconductor.compose.circle.Circle
import com.mapconductor.compose.info.InfoBubble
import com.mapconductor.compose.marker.Marker
import com.mapconductor.compose.polygon.Polygon
import com.mapconductor.compose.polyline.Polyline
import com.mapconductor.core.circle.CircleState
import com.mapconductor.core.features.GeoPoint
import com.mapconductor.core.map.MapCameraPosition
import com.mapconductor.core.marker.MarkerState
import com.mapconductor.core.polygon.PolygonState
import com.mapconductor.core.polyline.PolylineState
import com.mapconductor.maplibre.MapLibreDesign
import com.mapconductor.maplibre.MapLibreMapView
import com.mapconductor.maplibre.rememberMapLibreMapViewState
</details>
!Step3: 円・線・多角形
書いてみて分かった点です。
- 円の半径はメートルで指定します。ズームを変えても、地図上の実際の距離どおりの大きさで描かれます。
- 多角形は始点を最後にもう一度書かなくて大丈夫です。ライブラリ側で自動的に閉じてくれます。
- ズーム値は Google Maps 基準にそろえられています。MapConductor のズームは 256px タイルを基準にした値で、MapLibre には内部で「指定値 − 1」に変換して渡しています。そのため、プロバイダを替えても同じズーム値で同じ縮尺になります。
Step4: プロバイダを Open Mobile Maps に切り替える
ここまでのコードを別のプロバイダでも動かしてみます。MapConductor の Android 版は 8 つのプロバイダに対応していて、そのうち Open Mobile Maps は MapLibre と同じく APIキーなしで使えます。画面上のボタンで、MapLibre と Open Mobile Maps を実行時に切り替えられるようにします。
依存関係を追加します。公式のセットアップ手順に合わせて、compose モジュールも明示しました(Step3 までは for-maplibre 経由で入っていました)。
[versions]
openMobileMaps = "4.0.0"
[libraries]
mapconductor-compose = { group = "com.mapconductor", name = "compose" }
mapconductor-openmobilemaps = { group = "com.mapconductor", name = "for-openmobilemaps" }
openmobilemaps-mapscore = { group = "io.openmobilemaps", name = "mapscore", version.ref = "openMobileMaps" }
dependencies {
implementation(libs.maplibre.android.sdk)
implementation(platform(libs.mapconductor.bom))
implementation(libs.mapconductor.core)
implementation(libs.mapconductor.compose)
implementation(libs.mapconductor.maplibre)
implementation(libs.openmobilemaps.mapscore)
implementation(libs.mapconductor.openmobilemaps)
}
:::note info
公式の手順には「minSdk 28 以上」とありますが、for-openmobilemaps 1.3.1 と mapscore 4.0.0 の AAR が宣言している minSdk は 26 で、minSdk 26 のままビルドできました。ただし、API 26〜27 の端末での動作は確かめていません。
:::
画面のコードです。Step3 との違いは次の3つです。
- 地図に載せるもの(Step2〜3 で MapLibreMapView のラムダに書いていた部分)を、mapContent という変数に取り出す。
- プロバイダごとに State を用意し、ボタンで表示する MapView を切り替える。
- 切り替えた先の地図に、直前まで見ていたカメラ位置を引き継ぐ。
private val INITIAL_CAMERA = MapCameraPosition(
position = TOKYO_STATION,
zoom = 13.5,
)
@Composable
fun HelloMapScreen(modifier: Modifier = Modifier) {
var useOpenMobileMaps by remember { mutableStateOf(false) }
var selectedMarker by remember { mutableStateOf<MarkerState?>(null) }
val mapLibreState = rememberMapLibreMapViewState(
cameraPosition = INITIAL_CAMERA,
mapDesign = MapLibreDesign.OsmBrightJa,
)
val openMobileMapsState = rememberOpenMobileMapsMapViewState(
cameraPosition = INITIAL_CAMERA,
mapDesign = OpenMobileMapsDesign.OpenStreetMapJapan,
)
// markerState / circleState / polylineState / polygonState は Step3 と同じなので省略
// 地図に載せるものはプロバイダに関係なく一度だけ書き、どちらの MapView にも渡す
val mapContent: @Composable MapViewScope.() -> Unit = {
Circle(circleState)
Polyline(polylineState)
Polygon(polygonState)
Marker(markerState)
selectedMarker?.let {
InfoBubble(marker = it) {
Text("Hello, MapConductor")
}
}
}
// 切り替え先に、いま見ていたカメラ位置を引き継ぐ
LaunchedEffect(useOpenMobileMaps) {
if (useOpenMobileMaps) {
openMobileMapsState.moveCameraTo(mapLibreState.cameraPosition)
} else {
mapLibreState.moveCameraTo(openMobileMapsState.cameraPosition)
}
}
Box(modifier = modifier) {
if (useOpenMobileMaps) {
OpenMobileMapsMapView(
modifier = Modifier.fillMaxSize(),
state = openMobileMapsState,
onMapClick = { selectedMarker = null },
content = mapContent,
)
} else {
MapLibreMapView(
modifier = Modifier.fillMaxSize(),
state = mapLibreState,
onMapClick = { selectedMarker = null },
content = mapContent,
)
}
Button(
onClick = { useOpenMobileMaps = !useOpenMobileMaps },
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 8.dp),
) {
Text(if (useOpenMobileMaps) "MapLibre に切り替え" else "Open Mobile Maps に切り替え")
}
}
}
<details><summary>Step4 で増えた import</summary>
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import com.mapconductor.compose.MapViewScope
import com.mapconductor.openmobilemaps.OpenMobileMapsDesign
import com.mapconductor.openmobilemaps.OpenMobileMapsMapView
import com.mapconductor.openmobilemaps.rememberOpenMobileMapsMapViewState
</details>
- MarkerState や CircleState などの「地図に載せるもの」の State は、プロバイダに依存しません。同じ State をどちらの MapView にも渡せます。
- プロバイダごとに替わるのは、MapView の型と、その State(rememberXxxMapViewState)だけです。
- カメラ位置は State ごとに別々に持っているので、切り替えたときに moveCameraTo() で合わせています。
実機(Pixel 8a)で切り替えた画面です。左が MapLibre、右が Open Mobile Maps で、円・線・多角形・マーカーが同じ位置に描かれています。Step3 で触れた「Google Maps 基準のズーム」のおかげで、縮尺もそろっています。
!Step4: MapLibre
!Step4: Open Mobile Maps
地図を動かしてから切り替えても、同じ範囲が表示されます(左: MapLibre で地図を動かした直後、右: Open Mobile Maps に切り替えた直後)。スクショから図形の中心位置を計算して比べると、切り替え前後のずれは数ピクセルでした。
!Step4: カメラ位置の引き継ぎ
ハマりどころ: Open Mobile Maps の OpenGL ES 3.2 要件
最初は Mac のエミュレータで試したのですが、Open Mobile Maps に切り替えると 地図も図形も真っ白 になりました。
Logcat には unsupported shader version というエラーが大量に出ます。一方、マーカーのタップと吹き出しは普通に動きます。当たり判定と吹き出しは、地図の描画とは別に動いているためです。「タップできるのに何も見えない」状態になるので、原因に気づきにくいです。
原因は、Open Mobile Maps の描画エンジンが OpenGL ES 3.2 を必須にしていることでした。シェーダーが #version 320 es で書かれていて、本家の README にも「Android 8.0+, OpenGl ES 3.2」と書かれています。MapConductor のセットアップページには、この要件は書かれていません(2026年11月時点)。「地図が真っ白」のトラブルシューティングも、ネットワーク側の原因だけを挙げています。
Mac(M4)のエミュレータで、GPU の設定を変えて OpenGL ES のバージョンを測った結果です。
| エミュレータの GPU 設定 | OpenGL ES |
|---|---|
| 自動(既定) | 3.1 |
| -gpu host | 3.0 |
| -gpu host -feature GuestAngle | 3.1 |
どの設定でも 3.2 にはなりません。Mac の GPU(Metal)には ES 3.2 が求める機能(ジオメトリシェーダーなど)の一部がないため、エミュレータの描画層(ANGLE)が「3.1 まで」と名乗るからです。Open Mobile Maps のバージョンを下げても解決しません。2.0.0 からずっと #version 320 es を使っていて、MapConductor 1.3.1 も 4.0.0 を前提にしているためです。
:::note warn
どうしてもエミュレータで動かしたい場合
エミュレータは起動時に、ANGLE の「規格を完全には満たせなくても新しいバージョンを名乗る」機能(exposeNonConformantExtensionsAndVersions)を無効にしています。これを有効に戻すと、Open Mobile Maps が描画されました。
emulator -avd Pixel_8 -gpu swiftshader_indirect -feature GuestAngle
起動したら、アプリを起動する前に次の2つを実行します(エミュレータを再起動すると元に戻ります)。
adb shell setprop debug.angle.feature_overrides_enabled exposeNonConformantExtensionsAndVersions
adb shell setprop debug.angle.feature_overrides_disabled 'supportsBlend*'
ただし、これは規格に準拠していない状態での描画なので、見た目が正しい保証はありません。しかもこの構成では、MapLibre の地図が崩れます。1回の起動で両方のプロバイダを正しく見ることはできないので、あくまで最終手段です。
:::
結局、OpenGL ES 3.2 に対応した実機(Pixel 8a、Mali-G715)で確認しました。実機では上書きなしで、両方のプロバイダが問題なく描画されます。
:::note info
実機はケーブルがなくても、Android 11 以降なら「ワイヤレスデバッグ」でつなげます。ただし、カフェなどのフリー Wi-Fi では端末どうしの通信が遮断されていることが多く、つながりません。自宅などのネットワークで試しましょう。
:::
動作確認: 地図の UI テストの書き方
Step2 からは、先に UI テストを書いて失敗を確認してから実装しました。地図 SDK のテストには普通の Android アプリと違う難しさがありました。
Compose のテストと Espresso の問題
最初は Compose の UI テストで書きましたが、2つの問題で動きませんでした。
1. Compose のテストが間接的に使う Espresso 3.5.0 が、Android 17 で削除された InputManager.getInstance を呼んで失敗します。Espresso を 3.7.0 に上げると直ります。
2. 地図ライブラリがメインスレッドで処理を回し続けるため、Espresso が待つ「メインスレッドが暇になる瞬間」が来ず、AppNotIdleException で失敗します。
2つ目はテストの書き方では避けられないので、操作も検証も UiAutomator で行うことにしました。
@RunWith(AndroidJUnit4::class)
class HelloMapScreenTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Test
fun tappingMarkerShowsInfoBubble() {
assertFalse(device.hasObject(By.text("Hello, MapConductor")))
tapMarkerUntilBubbleShown()
assertTrue(device.hasObject(By.text("Hello, MapConductor")))
}
}
- 吹き出しの中身は Compose の Text なので、UiAutomator の By.text() で見つけられます。
- マーカーの当たり判定はアイコンの矩形で行われます。 デフォルトのアイコンは下端の中央が座標に合うので、座標ちょうどではなく少し上(今回は 12dp 上)をタップしないと当たりません。
- 地図のスタイルを読み込み終わるまではマーカーが出ないので、吹き出しが出るまで間隔を空けてタップし直しています。
図形はスクショの色で確かめる
円や線は地図の描画レイヤーに直接描かれるので、UiAutomator からは見えません。そこで、次の手順で確かめました。
1. 図形の内側に来るはずの地点の緯度経度を、画面上の座標に変換する。
2. スクショを撮り、その点の周りの画素が図形の色(青・赤・マゼンタ)になっているかを多数決で判定する。
多数決にしたのは、地名ラベルや道路が一部の画素に重なっても誤判定しないようにするためです。緯度経度から画面座標への変換は、Step3 で触れた「Google Maps 基準のズーム」を前提にしています。
// 地図は全画面なので、カメラの中心が画面の中央に来る
private fun toScreen(point: GeoPoint, width: Int, height: Int): Pair<Int, Int> {
val worldPx = 256 * 2.0.pow(CAMERA_ZOOM) * density
val x = width / 2.0 + (point.longitude - CAMERA_CENTER.longitude) / 360.0 * worldPx
val y = height / 2.0 - (mercatorY(point.latitude) - mercatorY(CAMERA_CENTER.latitude)) * worldPx / (2 * PI)
return x.roundToInt() to y.roundToInt()
}
private fun mercatorY(latitude: Double): Double = ln(tan(PI / 4 + Math.toRadians(latitude) / 2))
実装前は3本とも「色の一致率 0%」で失敗し、実装後にパスすることを確認しています。
同じテストをプロバイダごとに回す
Step4 では JUnit の Parameterized を使って、同じテストを MapLibre と Open Mobile Maps の両方で実行しました。テストの最初に、画面上のボタンで対象のプロバイダに切り替えます。
enum class MapProvider(private val label: String) {
MapLibre("MapLibre"),
OpenMobileMaps("Open Mobile Maps"),
;
override fun toString(): String = label
}
// 起動直後は MapLibre。Open Mobile Maps は画面上の切り替えボタンで移る
fun UiDevice.showMapWith(provider: MapProvider) {
if (provider == MapProvider.MapLibre) return
val button = wait(Until.findObject(By.text("Open Mobile Maps に切り替え")), 10_000L)
?: throw AssertionError("切り替えボタンが見つかりません")
button.click()
if (!wait(Until.hasObject(By.text("MapLibre に切り替え")), 10_000L)) {
throw AssertionError("$provider に切り替わりませんでした")
}
}
@RunWith(Parameterized::class)
class HelloMapScreenTest(private val provider: MapProvider) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun providers(): List<MapProvider> = MapProvider.entries
}
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Before
fun setUp() {
device.showMapWith(provider)
}
// テスト本体は Step2 と同じ
}
「マーカー・吹き出し」の2本と「円・線・多角形」の3本をプロバイダごとに回すので、テストは全部で10本です。結果は次のとおりでした。
| | MapLibre | Open Mobile Maps |
|---|---|---|
| エミュレータ(Mac) | 5/5 パス | 2/5 パス(図形の3本が失敗) |
| 実機(Pixel 8a) | 5/5 パス | 5/5 パス |
エミュレータで Open Mobile Maps の図形テストが落ちたことで、「タップはできるのに描画されていない」ことに気づけました。図形をスクショの色で確かめるテストを書いておいてよかった場面です。
ハマりどころまとめ
- バージョンカタログのエイリアスに for を入れると、Kotlin の予約語とぶつかってビルドが失敗する。
- AGP 9 では app に Kotlin Android プラグインを書かなくてよい。
- MapLibre SDK のバージョンが資料ごとに違う(MapConductor のドキュメントは 13.4.1、for-maplibre 1.3.1 が依存しているのは 13.3.1)。使ったバージョンを明記しておくと安心。
- 地図のテストは、Compose のテストや Espresso ではアイドル待ちが終わらない。UiAutomator を使う。
- マーカーをタップするときは、座標の少し上を狙う。
- ズーム値は Google Maps 基準で、プロバイダをまたいで同じ縮尺になる。
- Android 17(API 37)のエミュレータは、Mac ではソフトウェア描画になることがある。
- Open Mobile Maps は OpenGL ES 3.2 必須で、Mac のエミュレータでは地図も図形も真っ白になる。タップと吹き出しは動くので気づきにくい。
- Open Mobile Maps の公式手順は minSdk 28 以上だが、AAR の宣言は 26 で、minSdk 26 のままビルドできた。
- ワイヤレスデバッグはフリー Wi-Fi だとつながらないことが多い。
まとめ
- MapConductor を使うと、MapLibre の地図に、マーカー・吹き出し・円・線・多角形を Compose らしい書き方で載せられました。APIキーも要りません。
- 地図に載せるものは MapView のラムダに書くだけで、プロバイダに依存しないコードになります。
- Open Mobile Maps に切り替えても、地図に載せるコードを1行も変えずに、マーカーや図形が同じ位置・同じ縮尺で描画されました。替えたのは MapView と、その State だけです。
- ただし、プロバイダごとに動作環境の要件は違います。Open Mobile Maps の OpenGL ES 3.2 のように、MapConductor のドキュメントに書かれていない落とし穴もあるので、プロバイダを替えたら実機でも確かめるのがおすすめです。
次はヒートマップやクラスタリング、GeoJSON も試してみたいと思います。
参考リンク
- MapConductor(GitHub)
- MapConductor 日本語ドキュメント
- MapConductor: プロバイダを切り替える
- MapConductor: Open Mobile Maps セットアップ
- MapLibre Native
- Open Mobile Maps
- UiAutomator(Android Developers)
6 likes
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
【Android】MapConductor で地図 SDK を 1 つの Compose コードで扱ってみた はじめに Android で地図を扱うとき、Google Maps、MapLibre、Mapbox などの SDK はそれぞれ API が違います。あとからプロバイダを替えたくなると、地図まわりのコードをほぼ書き直すことになります。 MapConductor はこうした地図 SDK を 同じ Jetpack Compose の書き方 で扱えるようにするライブラリです。この記事では APIキーなしで使える MapLibre から始めて、次の順に試していきます。 1. 地図を表示する 2. マーカーをタップして吹き出しを出す 3. 円・線・多角形を描く 4. プロバイダを切り替える(地図に載せるコードはそのまま) !完成イメージ(左: MapLibre、右: Open Mobile Maps に切り替えた後) 完成コード: saitojo1106/mapconductor-prc リポジトリは Step ごとにコミットを分けているので、途中の状態も確認できます。 MapConductor とは - Google Maps、Mapbox、MapLibre、HERE、ArcGIS、Open Mobile Maps などの地図 SDK を、共通の Compose API で扱えるライブラリです。 - マーカーや図形などの「地図に載せるもの」は共通の書き方で書き、プロバイダごとに替えるのは MapView と、その状態(State)だけです。 - ライセンスは Apache-2.0 で、Maven Central に com.mapconductor:* として公開されています。 - 日本語のドキュメント もあります。 動作環境 | 項目 | バージョン | |---|---| | Android Studio | 2026.1 | | AGP / Gradle | 9.4.1 / 9.6.0 | | Kotlin | 2.4.10 | | compileSdk / minSdk | 37 / 26 | | MapConductor BOM | 1.3.1 | | MapLibre Android SDK | 13.4.1 | | Open Mobile Maps(mapscore) | 4.0.0 | | UiAutomator(テスト) | 2.4.0 | | 確認した端末 | エミュレータ(Pixel 8 / Android 17 / API 37、M4 Mac)、実機(Pixel 8a / Android 16) | Step1: MapLibre で地図を表示する Android Studio の「Empty Activity」(Compose)でプロジェクトを作り、依存関係を追加します。 [versions] mapconductorBom = "1.3.1" maplibreSdk = "13.4.1" [libraries] mapconductor-bom = { group = "com.mapconductor", name = "mapconductor-bom", version.ref = "mapconductorBom" } mapconductor-core = { group = "com.mapconductor", name = "core" } mapconductor-maplibre = { group = "com.mapconductor", name = "for-maplibre" } maplibre-android-sdk = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibreSdk" } dependencies { implementation(libs.maplibre.android.sdk) implementation(platform(libs.mapconductor.bom)) implementation(libs.mapconductor.core) implementation(libs.mapconductor.maplibre) } :::note warn バージョンカタログの名前に for を入れるとビルドが失敗します モジュール名に合わせてエイリアスを mapconductor-for-maplibre にすると、アクセサが libs.mapconductor.for.maplibre になります。for は Kotlin の予約語なので、Expecting '(' to open a loop range というエラーでビルドが失敗します。この記事では mapconductor-maplibre にしています。 ::: タイルを読み込むので、AndroidManifest.xml に INTERNET 権限を追加します。 <uses-permission android:name="android.permission.INTERNET" /> 地図を表示する画面です。カメラの位置と地図のデザインを State にして、MapLibreMapView に渡します。 import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.maplibre.MapLibreDesign import com.mapconductor.maplibre.MapLibreMapView import com.mapconductor.maplibre.rememberMapLibreMapViewState private val TOKYO_STATION = GeoPoint(35.6812, 139.7671) @Composable fun HelloMapScreen(modifier: Modifier = Modifier) { val mapState = rememberMapLibreMapViewState( cameraPosition = MapCameraPosition( position = TOKYO_STATION, zoom = 14.0, ), mapDesign = MapLibreDesign.OsmBrightJa, ) MapLibreMapView( modifier = modifier, state = mapState, ) } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MaterialTheme { HelloMapScreen(modifier = Modifier.fillMaxSize()) } } } } 実行すると、東京駅を中心に、日本語ラベルの地図が表示されます。 !Step1: 東京駅周辺の地図 MapLibreDesign.OsmBrightJa は OpenStreetMap Japan のタイルサーバー(tile.openstreetmap.jp)にある osm-bright-ja スタイルを使います。左下の ⓘ をタップすると帰属表示が出ます。 !Step1: 帰属表示 :::note info このタイルは手軽に試すのには便利ですが、本番のアプリで使う前に配信元の利用条件を確認してください。スクショを記事などに載せるときは、「© OpenStreetMap contributors」の表記を残しましょう。 ::: :::note info AGP 9 からは Kotlin が AGP に組み込まれたため、app モジュールに org.jetbrains.kotlin.android プラグインを書かなくても Kotlin が使えます。古い記事の手順と違うので、戸惑うかもしれません。 ::: Step2: マーカーと吹き出し(InfoBubble) 東京駅にマーカーを置き、タップしたら「Hello, MapConductor」の吹き出しを出します。地図をタップすると閉じます。 @Composable fun HelloMapScreen(modifier: Modifier = Modifier) { + var selectedMarker by remember { mutableStateOf<MarkerState?>(null) } + val mapState = rememberMapLibreMapViewState( cameraPosition = MapCameraPosition( position = TOKYO_STATION, zoom = 14.0, ), mapDesign = MapLibreDesign.OsmBrightJa, ) + val markerState = remember { + MarkerState( + position = TOKYO_STATION, + onClick = { selectedMarker = it }, + ) + } + MapLibreMapView( modifier = modifier, state = mapState, - ) + onMapClick = { selectedMarker = null }, + ) { + Marker(markerState) + + selectedMarker?.let { + InfoBubble(marker = it) { + Text("Hello, MapConductor") + } + } + } } - MapLibreMapView の最後のラムダに、地図に載せるものを書きます。ここが MapConductor の共通部分で、プロバイダを替えても同じコードがそのまま使えます(Step4 で試します)。 - MarkerState の onClick には、タップされたマーカーの State が渡ってきます。それを覚えておき、InfoBubble(marker = ...) に渡すと、そのマーカーの上に吹き出しが出ます。 - 吹き出しの中身は普通の Composable なので、Text 以外も自由に置けます。 !Step2: マーカーをタップすると吹き出しが出る Step3: Circle / Polyline / Polygon を描く 東京駅を中心にした半径 1km の円、東京 → 神田 → 秋葉原を結ぶ線、皇居をおおまかに囲む多角形を描きます。3つが画面に収まるよう、ズームは 13.5 に下げました。 private val KANDA_STATION = GeoPoint(35.6918, 139.7709) private val AKIHABARA_STATION = GeoPoint(35.6984, 139.7731) private val IMPERIAL_PALACE = listOf( GeoPoint(35.6895, 139.7505), GeoPoint(35.6890, 139.7570), GeoPoint(35.6838, 139.7585), GeoPoint(35.6790, 139.7555), GeoPoint(35.6795, 139.7490), GeoPoint(35.6850, 139.7470), ) val circleState = remember { CircleState( center = TOKYO_STATION, radiusMeters = 1000.0, fillColor = Color.Blue.copy(alpha = 0.4f), strokeColor = Color.Blue, strokeWidth = 2.dp, ) } val polylineState = remember { PolylineState( points = listOf(TOKYO_STATION, KANDA_STATION, AKIHABARA_STATION), strokeColor = Color.Red, strokeWidth = 6.dp, ) } val polygonState = remember { PolygonState( points = IMPERIAL_PALACE, fillColor = Color.Magenta.copy(alpha = 0.4f), strokeColor = Color.Magenta, strokeWidth = 2.dp, ) } MapLibreMapView( modifier = modifier, state = mapState, onMapClick = { selectedMarker = null }, ) { Circle(circleState) Polyline(polylineState) Polygon(polygonState) Marker(markerState) selectedMarker?.let { InfoBubble(marker = it) { Text("Hello, MapConductor") } } } <details><summary>Step3 時点の import 一覧</summary> import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.mapconductor.compose.circle.Circle import com.mapconductor.compose.info.InfoBubble import com.mapconductor.compose.marker.Marker import com.mapconductor.compose.polygon.Polygon import com.mapconductor.compose.polyline.Polyline import com.mapconductor.core.circle.CircleState import com.mapconductor.core.features.GeoPoint import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.marker.MarkerState import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polyline.PolylineState import com.mapconductor.maplibre.MapLibreDesign import com.mapconductor.maplibre.MapLibreMapView import com.mapconductor.maplibre.rememberMapLibreMapViewState </details> !Step3: 円・線・多角形 書いてみて分かった点です。 - 円の半径はメートルで指定します。ズームを変えても、地図上の実際の距離どおりの大きさで描かれます。 - 多角形は始点を最後にもう一度書かなくて大丈夫です。ライブラリ側で自動的に閉じてくれます。 - ズーム値は Google Maps 基準にそろえられています。MapConductor のズームは 256px タイルを基準にした値で、MapLibre には内部で「指定値 − 1」に変換して渡しています。そのため、プロバイダを替えても同じズーム値で同じ縮尺になります。 Step4: プロバイダを Open Mobile Maps に切り替える ここまでのコードを別のプロバイダでも動かしてみます。MapConductor の Android 版は 8 つのプロバイダに対応していて、そのうち Open Mobile Maps は MapLibre と同じく APIキーなしで使えます。画面上のボタンで、MapLibre と Open Mobile Maps を実行時に切り替えられるようにします。 依存関係を追加します。公式のセットアップ手順に合わせて、compose モジュールも明示しました(Step3 までは for-maplibre 経由で入っていました)。 [versions] openMobileMaps = "4.0.0" [libraries] mapconductor-compose = { group = "com.mapconductor", name = "compose" } mapconductor-openmobilemaps = { group = "com.mapconductor", name = "for-openmobilemaps" } openmobilemaps-mapscore = { group = "io.openmobilemaps", name = "mapscore", version.ref = "openMobileMaps" } dependencies { implementation(libs.maplibre.android.sdk) implementation(platform(libs.mapconductor.bom)) implementation(libs.mapconductor.core) implementation(libs.mapconductor.compose) implementation(libs.mapconductor.maplibre) implementation(libs.openmobilemaps.mapscore) implementation(libs.mapconductor.openmobilemaps) } :::note info 公式の手順には「minSdk 28 以上」とありますが、for-openmobilemaps 1.3.1 と mapscore 4.0.0 の AAR が宣言している minSdk は 26 で、minSdk 26 のままビルドできました。ただし、API 26〜27 の端末での動作は確かめていません。 ::: 画面のコードです。Step3 との違いは次の3つです。 - 地図に載せるもの(Step2〜3 で MapLibreMapView のラムダに書いていた部分)を、mapContent という変数に取り出す。 - プロバイダごとに State を用意し、ボタンで表示する MapView を切り替える。 - 切り替えた先の地図に、直前まで見ていたカメラ位置を引き継ぐ。 private val INITIAL_CAMERA = MapCameraPosition( position = TOKYO_STATION, zoom = 13.5, ) @Composable fun HelloMapScreen(modifier: Modifier = Modifier) { var useOpenMobileMaps by remember { mutableStateOf(false) } var selectedMarker by remember { mutableStateOf<MarkerState?>(null) } val mapLibreState = rememberMapLibreMapViewState( cameraPosition = INITIAL_CAMERA, mapDesign = MapLibreDesign.OsmBrightJa, ) val openMobileMapsState = rememberOpenMobileMapsMapViewState( cameraPosition = INITIAL_CAMERA, mapDesign = OpenMobileMapsDesign.OpenStreetMapJapan, ) // markerState / circleState / polylineState / polygonState は Step3 と同じなので省略 // 地図に載せるものはプロバイダに関係なく一度だけ書き、どちらの MapView にも渡す val mapContent: @Composable MapViewScope.() -> Unit = { Circle(circleState) Polyline(polylineState) Polygon(polygonState) Marker(markerState) selectedMarker?.let { InfoBubble(marker = it) { Text("Hello, MapConductor") } } } // 切り替え先に、いま見ていたカメラ位置を引き継ぐ LaunchedEffect(useOpenMobileMaps) { if (useOpenMobileMaps) { openMobileMapsState.moveCameraTo(mapLibreState.cameraPosition) } else { mapLibreState.moveCameraTo(openMobileMapsState.cameraPosition) } } Box(modifier = modifier) { if (useOpenMobileMaps) { OpenMobileMapsMapView( modifier = Modifier.fillMaxSize(), state = openMobileMapsState, onMapClick = { selectedMarker = null }, content = mapContent, ) } else { MapLibreMapView( modifier = Modifier.fillMaxSize(), state = mapLibreState, onMapClick = { selectedMarker = null }, content = mapContent, ) } Button( onClick = { useOpenMobileMaps = !useOpenMobileMaps }, modifier = Modifier .align(Alignment.TopCenter) .statusBarsPadding() .padding(top = 8.dp), ) { Text(if (useOpenMobileMaps) "MapLibre に切り替え" else "Open Mobile Maps に切り替え") } } } <details><summary>Step4 で増えた import</summary> import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Button import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import com.mapconductor.compose.MapViewScope import com.mapconductor.openmobilemaps.OpenMobileMapsDesign import com.mapconductor.openmobilemaps.OpenMobileMapsMapView import com.mapconductor.openmobilemaps.rememberOpenMobileMapsMapViewState </details> - MarkerState や CircleState などの「地図に載せるもの」の State は、プロバイダに依存しません。同じ State をどちらの MapView にも渡せます。 - プロバイダごとに替わるのは、MapView の型と、その State(rememberXxxMapViewState)だけです。 - カメラ位置は State ごとに別々に持っているので、切り替えたときに moveCameraTo() で合わせています。 実機(Pixel 8a)で切り替えた画面です。左が MapLibre、右が Open Mobile Maps で、円・線・多角形・マーカーが同じ位置に描かれています。Step3 で触れた「Google Maps 基準のズーム」のおかげで、縮尺もそろっています。 !Step4: MapLibre !Step4: Open Mobile Maps 地図を動かしてから切り替えても、同じ範囲が表示されます(左: MapLibre で地図を動かした直後、右: Open Mobile Maps に切り替えた直後)。スクショから図形の中心位置を計算して比べると、切り替え前後のずれは数ピクセルでした。 !Step4: カメラ位置の引き継ぎ ハマりどころ: Open Mobile Maps の OpenGL ES 3.2 要件 最初は Mac のエミュレータで試したのですが、Open Mobile Maps に切り替えると 地図も図形も真っ白 になりました。 Logcat には unsupported shader version というエラーが大量に出ます。一方、マーカーのタップと吹き出しは普通に動きます。当たり判定と吹き出しは、地図の描画とは別に動いているためです。「タップできるのに何も見えない」状態になるので、原因に気づきにくいです。 原因は、Open Mobile Maps の描画エンジンが OpenGL ES 3.2 を必須にしていることでした。シェーダーが #version 320 es で書かれていて、本家の README にも「Android 8.0+, OpenGl ES 3.2」と書かれています。MapConductor のセットアップページには、この要件は書かれていません(2026年11月時点)。「地図が真っ白」のトラブルシューティングも、ネットワーク側の原因だけを挙げています。 Mac(M4)のエミュレータで、GPU の設定を変えて OpenGL ES のバージョンを測った結果です。 | エミュレータの GPU 設定 | OpenGL ES | |---|---| | 自動(既定) | 3.1 | | -gpu host | 3.0 | | -gpu host -feature GuestAngle | 3.1 | どの設定でも 3.2 にはなりません。Mac の GPU(Metal)には ES 3.2 が求める機能(ジオメトリシェーダーなど)の一部がないため、エミュレータの描画層(ANGLE)が「3.1 まで」と名乗るからです。Open Mobile Maps のバージョンを下げても解決しません。2.0.0 からずっと #version 320 es を使っていて、MapConductor 1.3.1 も 4.0.0 を前提にしているためです。 :::note warn どうしてもエミュレータで動かしたい場合 エミュレータは起動時に、ANGLE の「規格を完全には満たせなくても新しいバージョンを名乗る」機能(exposeNonConformantExtensionsAndVersions)を無効にしています。これを有効に戻すと、Open Mobile Maps が描画されました。 emulator -avd Pixel_8 -gpu swiftshader_indirect -feature GuestAngle 起動したら、アプリを起動する前に次の2つを実行します(エミュレータを再起動すると元に戻ります)。 adb shell setprop debug.angle.feature_overrides_enabled exposeNonConformantExtensionsAndVersions adb shell setprop debug.angle.feature_overrides_disabled 'supportsBlend*' ただし、これは規格に準拠していない状態での描画なので、見た目が正しい保証はありません。しかもこの構成では、MapLibre の地図が崩れます。1回の起動で両方のプロバイダを正しく見ることはできないので、あくまで最終手段です。 ::: 結局、OpenGL ES 3.2 に対応した実機(Pixel 8a、Mali-G715)で確認しました。実機では上書きなしで、両方のプロバイダが問題なく描画されます。 :::note info 実機はケーブルがなくても、Android 11 以降なら「ワイヤレスデバッグ」でつなげます。ただし、カフェなどのフリー Wi-Fi では端末どうしの通信が遮断されていることが多く、つながりません。自宅などのネットワークで試しましょう。 ::: 動作確認: 地図の UI テストの書き方 Step2 からは、先に UI テストを書いて失敗を確認してから実装しました。地図 SDK のテストには普通の Android アプリと違う難しさがありました。 Compose のテストと Espresso の問題 最初は Compose の UI テストで書きましたが、2つの問題で動きませんでした。 1. Compose のテストが間接的に使う Espresso 3.5.0 が、Android 17 で削除された InputManager.getInstance を呼んで失敗します。Espresso を 3.7.0 に上げると直ります。 2. 地図ライブラリがメインスレッドで処理を回し続けるため、Espresso が待つ「メインスレッドが暇になる瞬間」が来ず、AppNotIdleException で失敗します。 2つ目はテストの書き方では避けられないので、操作も検証も UiAutomator で行うことにしました。 @RunWith(AndroidJUnit4::class) class HelloMapScreenTest { @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @Test fun tappingMarkerShowsInfoBubble() { assertFalse(device.hasObject(By.text("Hello, MapConductor"))) tapMarkerUntilBubbleShown() assertTrue(device.hasObject(By.text("Hello, MapConductor"))) } } - 吹き出しの中身は Compose の Text なので、UiAutomator の By.text() で見つけられます。 - マーカーの当たり判定はアイコンの矩形で行われます。 デフォルトのアイコンは下端の中央が座標に合うので、座標ちょうどではなく少し上(今回は 12dp 上)をタップしないと当たりません。 - 地図のスタイルを読み込み終わるまではマーカーが出ないので、吹き出しが出るまで間隔を空けてタップし直しています。 図形はスクショの色で確かめる 円や線は地図の描画レイヤーに直接描かれるので、UiAutomator からは見えません。そこで、次の手順で確かめました。 1. 図形の内側に来るはずの地点の緯度経度を、画面上の座標に変換する。 2. スクショを撮り、その点の周りの画素が図形の色(青・赤・マゼンタ)になっているかを多数決で判定する。 多数決にしたのは、地名ラベルや道路が一部の画素に重なっても誤判定しないようにするためです。緯度経度から画面座標への変換は、Step3 で触れた「Google Maps 基準のズーム」を前提にしています。 // 地図は全画面なので、カメラの中心が画面の中央に来る private fun toScreen(point: GeoPoint, width: Int, height: Int): Pair<Int, Int> { val worldPx = 256 * 2.0.pow(CAMERA_ZOOM) * density val x = width / 2.0 + (point.longitude - CAMERA_CENTER.longitude) / 360.0 * worldPx val y = height / 2.0 - (mercatorY(point.latitude) - mercatorY(CAMERA_CENTER.latitude)) * worldPx / (2 * PI) return x.roundToInt() to y.roundToInt() } private fun mercatorY(latitude: Double): Double = ln(tan(PI / 4 + Math.toRadians(latitude) / 2)) 実装前は3本とも「色の一致率 0%」で失敗し、実装後にパスすることを確認しています。 同じテストをプロバイダごとに回す Step4 では JUnit の Parameterized を使って、同じテストを MapLibre と Open Mobile Maps の両方で実行しました。テストの最初に、画面上のボタンで対象のプロバイダに切り替えます。 enum class MapProvider(private val label: String) { MapLibre("MapLibre"), OpenMobileMaps("Open Mobile Maps"), ; override fun toString(): String = label } // 起動直後は MapLibre。Open Mobile Maps は画面上の切り替えボタンで移る fun UiDevice.showMapWith(provider: MapProvider) { if (provider == MapProvider.MapLibre) return val button = wait(Until.findObject(By.text("Open Mobile Maps に切り替え")), 10_000L) ?: throw AssertionError("切り替えボタンが見つかりません") button.click() if (!wait(Until.hasObject(By.text("MapLibre に切り替え")), 10_000L)) { throw AssertionError("$provider に切り替わりませんでした") } } @RunWith(Parameterized::class) class HelloMapScreenTest(private val provider: MapProvider) { companion object { @JvmStatic @Parameterized.Parameters(name = "{0}") fun providers(): List<MapProvider> = MapProvider.entries } @get:Rule val activityRule = ActivityScenarioRule(MainActivity::class.java) private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) @Before fun setUp() { device.showMapWith(provider) } // テスト本体は Step2 と同じ } 「マーカー・吹き出し」の2本と「円・線・多角形」の3本をプロバイダごとに回すので、テストは全部で10本です。結果は次のとおりでした。 | | MapLibre | Open Mobile Maps | |---|---|---| | エミュレータ(Mac) | 5/5 パス | 2/5 パス(図形の3本が失敗) | | 実機(Pixel 8a) | 5/5 パス | 5/5 パス | エミュレータで Open Mobile Maps の図形テストが落ちたことで、「タップはできるのに描画されていない」ことに気づけました。図形をスクショの色で確かめるテストを書いておいてよかった場面です。 ハマりどころまとめ - バージョンカタログのエイリアスに for を入れると、Kotlin の予約語とぶつかってビルドが失敗する。 - AGP 9 では app に Kotlin Android プラグインを書かなくてよい。 - MapLibre SDK のバージョンが資料ごとに違う(MapConductor のドキュメントは 13.4.1、for-maplibre 1.3.1 が依存しているのは 13.3.1)。使ったバージョンを明記しておくと安心。 - 地図のテストは、Compose のテストや Espresso ではアイドル待ちが終わらない。UiAutomator を使う。 - マーカーをタップするときは、座標の少し上を狙う。 - ズーム値は Google Maps 基準で、プロバイダをまたいで同じ縮尺になる。 - Android 17(API 37)のエミュレータは、Mac ではソフトウェア描画になることがある。 - Open Mobile Maps は OpenGL ES 3.2 必須で、Mac のエミュレータでは地図も図形も真っ白になる。タップと吹き出しは動くので気づきにくい。 - Open Mobile Maps の公式手順は minSdk 28 以上だが、AAR の宣言は 26 で、minSdk 26 のままビルドできた。 - ワイヤレスデバッグはフリー Wi-Fi だとつながらないことが多い。 まとめ - MapConductor を使うと、MapLibre の地図に、マーカー・吹き出し・円・線・多角形を Compose らしい書き方で載せられました。APIキーも要りません。 - 地図に載せるものは MapView のラムダに書くだけで、プロバイダに依存しないコードになります。 - Open Mobile Maps に切り替えても、地図に載せるコードを1行も変えずに、マーカーや図形が同じ位置・同じ縮尺で描画されました。替えたのは MapView と、その State だけです。 - ただし、プロバイダごとに動作環境の要件は違います。Open Mobile Maps の OpenGL ES 3.2 のように、MapConductor のドキュメントに書かれていない落とし穴もあるので、プロバイダを替えたら実機でも確かめるのがおすすめです。 次はヒートマップやクラスタリング、GeoJSON も試してみたいと思います。 参考リンク - MapConductor(GitHub) - MapConductor 日本語ドキュメント - MapConductor: プロバイダを切り替える - MapConductor: Open Mobile Maps セットアップ - MapLibre Native - Open Mobile Maps - UiAutomator(Android Developers)
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…