From 6de1de0c44c67b64917497938c5f4e6c955e19ed Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:53 -0700 Subject: [PATCH 1/2] feat: integrate vector style support and pending map fixes --- api/android-for-openmobilemaps.api | 2 +- build.gradle.kts | 350 +++---- settings.gradle.kts | 89 +- .../openmobilemaps/OpenMobileMapsMapView.kt | 858 +++++++++--------- .../OpenMobileMapsTiltEmulation.kt | 186 ++-- 5 files changed, 751 insertions(+), 734 deletions(-) diff --git a/api/android-for-openmobilemaps.api b/api/android-for-openmobilemaps.api index 730f300..963de70 100644 --- a/api/android-for-openmobilemaps.api +++ b/api/android-for-openmobilemaps.api @@ -205,7 +205,7 @@ public final class com.mapconductor.openmobilemaps.OpenMobileMapsMapViewHolder i } public final class com.mapconductor.openmobilemaps.OpenMobileMapsMapViewKt { public static final com.mapconductor.openmobilemaps.OpenMobileMapsViewState rememberOpenMobileMapsMapViewState(com.mapconductor.openmobilemaps.OpenMobileMapsMapDesignTypeInterface, com.mapconductor.core.map.MapCameraPositionInterface, androidx.compose.runtime.Composer, int, int); - public static final void OpenMobileMapsMapView(com.mapconductor.openmobilemaps.OpenMobileMapsViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void OpenMobileMapsMapView(com.mapconductor.openmobilemaps.OpenMobileMapsViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, kotlin.jvm.functions.Function2, ? extends java.lang.Object>, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); public static final com.mapconductor.openmobilemaps.OpenMobileMapsMapViewController createOpenMobileMapsViewController(com.mapconductor.openmobilemaps.OpenMobileMapsMapViewHolder, java.util.ArrayList, float, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.MutableMapServiceRegistry); } public final class com.mapconductor.openmobilemaps.OpenMobileMapsMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { diff --git a/build.gradle.kts b/build.gradle.kts index 1c55aa2..154a52b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,175 +1,175 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.compose) - id("org.jlleitschuh.gradle.ktlint") version "14.2.0" - id("maven-publish") - id("signing") - id("com.gradleup.nmcp") version "1.5.0" -} - -ktlint { - android.set(true) - reporters { - reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) - reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) - } -} - -// ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 -// android-for-* は地図SDKドライバーなので、モジュール単位で許可する。 -kotlin { - compilerOptions { - optIn.add("com.mapconductor.core.InternalMapConductorApi") - } -} - -android { - namespace = "com.mapconductor.openmobilemaps" - compileSdk = project.property("compileSdk").toString().toInt() - - defaultConfig { - minSdk = project.property("minSdk").toString().toInt() - consumerProguardFiles("consumer-rules.pro") - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) - targetCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) - } - - publishing { - singleVariant("release") { - withSourcesJar() - } - } -} - -val libraryGroupId = project.findProperty("libraryGroupId") as String? ?: "com.mapconductor" -val libraryArtifactId = "for-openmobilemaps" -val libraryVersion = project.findProperty("libraryVersion") as String? ?: "1.0.0" - -dependencies { - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.foundation) - - // Lifecycle(MapView が Lifecycle を要求する) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.common.java8) - - // Open Mobile Maps SDK - api(libs.openmobilemaps.mapscore) - // ローカルタイルサーバの 404 を 204 へ書き換えるインターセプタで使う - // (mapscore の transitive 依存と同じ版。DataLoader が okhttp3.Interceptor を受け取る) - implementation("com.squareup.okhttp3:okhttp:5.1.0") - - // 集約ビルド(android-sdk)ではプロジェクト参照、単体ビルド(CI のリリース)では - // Maven 座標で解決する。他プロバイダと同じ形。 - if (findProject(":android-sdk-compose") != null) { - api(project(":android-sdk-compose")) - } else { - api("com.mapconductor:compose:$libraryVersion") - } - - testImplementation(libs.junit) -} - - -// Set project version for NMCP plugin -version = libraryVersion -val libraryName = "MapConductor for Open Mobile Maps" -val libraryDescription = "Open Mobile Maps implementation for MapConductor unified mapping library" - -// Gradle 9.6 で `by tasks.registering` は非推奨(スクリプトコンパイルエラーになる)。 -// googlemaps 等の既存モジュールは古い形のままなので、再コンパイル時に同じ問題が出る。 -val javadocJar = tasks.register("javadocJar") { - archiveClassifier.set("javadoc") -} - -afterEvaluate { - publishing { - publications { - create("release") { - from(components["release"]) - - groupId = libraryGroupId - artifactId = libraryArtifactId - version = libraryVersion - - artifact(javadocJar.get()) - - pom { - name.set(libraryName) - description.set(libraryDescription) - url.set( - project.findProperty("libraryUrl") as String? - ?: "https://github.com/MapConductor/android-for-openmobilemaps", - ) - - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - - developers { - developer { - id.set(project.findProperty("developerId") as String? ?: "mapconductor") - name.set(project.findProperty("developerName") as String? ?: "MapConductor Team") - email.set(project.findProperty("developerEmail") as String? ?: "info@mkgeeklab.com") - } - } - - scm { - connection.set("scm:git:git://github.com/MapConductor/android-for-openmobilemaps.git") - developerConnection - .set("scm:git:ssh://github.com:MapConductor/android-for-openmobilemaps.git") - url.set( - project.findProperty("scmUrl") as String? - ?: "https://github.com/MapConductor/android-for-openmobilemaps.git", - ) - } - } - } - } - - repositories { - maven { - name = "GitHubPackages" - setUrl("https://maven.pkg.github.com/MapConductor/android-for-openmobilemaps") - credentials { - username = - project.findProperty("gpr.user") as String? ?: System.getenv("GPR_USER") - ?: System.getenv("GITHUB_ACTOR") - password = - project.findProperty("gpr.key") as String? ?: System.getenv("GPR_TOKEN") - ?: System.getenv("GITHUB_TOKEN") - } - } - } - } - - signing { - val signingKey = findProperty("signingKey") as String? - val signingPassword = findProperty("signingPassword") as String? - if (!signingKey.isNullOrEmpty() && !signingPassword.isNullOrEmpty()) { - useInMemoryPgpKeys(signingKey, signingPassword) - sign(publishing.publications["release"]) - } - } - - if (project == rootProject) { - // standalone build only — in multi-project (android-sdk), parent configures nmcp - nmcp { - publishAllPublicationsToCentralPortal { - username.set(findProperty("ossrh_username") as String? ?: System.getenv("OSSRH_USERNAME") ?: "") - password.set(findProperty("ossrh_password") as String? ?: System.getenv("OSSRH_PASSWORD") ?: "") - } - } - } -} +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" + id("maven-publish") + id("signing") + id("com.gradleup.nmcp") version "1.5.0" +} + +ktlint { + android.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } +} + +// ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 +// android-for-* は地図SDKドライバーなので、モジュール単位で許可する。 +kotlin { + compilerOptions { + optIn.add("com.mapconductor.core.InternalMapConductorApi") + } +} + +android { + namespace = "com.mapconductor.openmobilemaps" + compileSdk = project.property("compileSdk").toString().toInt() + + defaultConfig { + minSdk = project.property("minSdk").toString().toInt() + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + targetCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +val libraryGroupId = project.findProperty("libraryGroupId") as String? ?: "com.mapconductor" +val libraryArtifactId = "for-openmobilemaps" +val libraryVersion = project.findProperty("libraryVersion") as String? ?: "1.0.0" + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.foundation) + + // Lifecycle(MapView が Lifecycle を要求する) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.common.java8) + + // Open Mobile Maps SDK + api(libs.openmobilemaps.mapscore) + // ローカルタイルサーバの 404 を 204 へ書き換えるインターセプタで使う + // (mapscore の transitive 依存と同じ版。DataLoader が okhttp3.Interceptor を受け取る) + implementation("com.squareup.okhttp3:okhttp:5.1.0") + + // 集約ビルド(android-sdk)ではプロジェクト参照、単体ビルド(CI のリリース)では + // Maven 座標で解決する。他プロバイダと同じ形。 + if (findProject(":android-sdk-compose") != null) { + api(project(":android-sdk-compose")) + } else { + api("com.mapconductor:compose:$libraryVersion") + } + + testImplementation(libs.junit) +} + +// Set project version for NMCP plugin +version = libraryVersion +val libraryName = "MapConductor for Open Mobile Maps" +val libraryDescription = "Open Mobile Maps implementation for MapConductor unified mapping library" + +// Gradle 9.6 で `by tasks.registering` は非推奨(スクリプトコンパイルエラーになる)。 +// googlemaps 等の既存モジュールは古い形のままなので、再コンパイル時に同じ問題が出る。 +val javadocJar = + tasks.register("javadocJar") { + archiveClassifier.set("javadoc") + } + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = libraryGroupId + artifactId = libraryArtifactId + version = libraryVersion + + artifact(javadocJar.get()) + + pom { + name.set(libraryName) + description.set(libraryDescription) + url.set( + project.findProperty("libraryUrl") as String? + ?: "https://github.com/MapConductor/android-for-openmobilemaps", + ) + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set(project.findProperty("developerId") as String? ?: "mapconductor") + name.set(project.findProperty("developerName") as String? ?: "MapConductor Team") + email.set(project.findProperty("developerEmail") as String? ?: "info@mkgeeklab.com") + } + } + + scm { + connection.set("scm:git:git://github.com/MapConductor/android-for-openmobilemaps.git") + developerConnection + .set("scm:git:ssh://github.com:MapConductor/android-for-openmobilemaps.git") + url.set( + project.findProperty("scmUrl") as String? + ?: "https://github.com/MapConductor/android-for-openmobilemaps.git", + ) + } + } + } + } + + repositories { + maven { + name = "GitHubPackages" + setUrl("https://maven.pkg.github.com/MapConductor/android-for-openmobilemaps") + credentials { + username = + project.findProperty("gpr.user") as String? ?: System.getenv("GPR_USER") + ?: System.getenv("GITHUB_ACTOR") + password = + project.findProperty("gpr.key") as String? ?: System.getenv("GPR_TOKEN") + ?: System.getenv("GITHUB_TOKEN") + } + } + } + } + + signing { + val signingKey = findProperty("signingKey") as String? + val signingPassword = findProperty("signingPassword") as String? + if (!signingKey.isNullOrEmpty() && !signingPassword.isNullOrEmpty()) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications["release"]) + } + } + + if (project == rootProject) { + // standalone build only — in multi-project (android-sdk), parent configures nmcp + nmcp { + publishAllPublicationsToCentralPortal { + username.set(findProperty("ossrh_username") as String? ?: System.getenv("OSSRH_USERNAME") ?: "") + password.set(findProperty("ossrh_password") as String? ?: System.getenv("OSSRH_PASSWORD") ?: "") + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 949e00a..1a9e78e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,45 +1,44 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenLocal() - google() - mavenCentral() - if (!System.getenv("GPR_USER").isNullOrEmpty() && !System.getenv("GPR_TOKEN").isNullOrEmpty()) { - maven { - name = "GithubPackages" - url = uri("https://maven.pkg.github.com/mapconductor/android-for-openmobilemaps") - credentials { - username = System.getenv("GPR_USER") ?: "" - password = System.getenv("GPR_TOKEN") ?: "" - } - } - } - if (!System.getenv("GPR_USER").isNullOrEmpty() || !System.getenv("GITHUB_ACTOR").isNullOrEmpty()) { - maven { - name = "GithubPackages-core" - url = uri("https://maven.pkg.github.com/MapConductor/android-sdk-core") - credentials { - username = System.getenv("GPR_USER") ?: System.getenv("GITHUB_ACTOR") ?: "" - password = System.getenv("GPR_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" - } - } - } - } -} - -rootProject.name = "android-for-openmobilemaps" - +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + if (!System.getenv("GPR_USER").isNullOrEmpty() && !System.getenv("GPR_TOKEN").isNullOrEmpty()) { + maven { + name = "GithubPackages" + url = uri("https://maven.pkg.github.com/mapconductor/android-for-openmobilemaps") + credentials { + username = System.getenv("GPR_USER") ?: "" + password = System.getenv("GPR_TOKEN") ?: "" + } + } + } + if (!System.getenv("GPR_USER").isNullOrEmpty() || !System.getenv("GITHUB_ACTOR").isNullOrEmpty()) { + maven { + name = "GithubPackages-core" + url = uri("https://maven.pkg.github.com/MapConductor/android-sdk-core") + credentials { + username = System.getenv("GPR_USER") ?: System.getenv("GITHUB_ACTOR") ?: "" + password = System.getenv("GPR_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" + } + } + } + } +} + +rootProject.name = "android-for-openmobilemaps" diff --git a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt index ff0bcd2..58d1b51 100644 --- a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt +++ b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt @@ -1,423 +1,435 @@ -package com.mapconductor.openmobilemaps - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.mapconductor.compose.map.BaseMapViewSaver -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.circle.CircleManager -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.map.MapViewState -import com.mapconductor.core.map.MapViewStateInterface -import com.mapconductor.core.map.MutableMapServiceRegistry -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.polygon.PolygonManager -import com.mapconductor.core.polyline.PolylineManager -import com.mapconductor.openmobilemaps.marker.OpenMobileMapsMarkerController -import io.openmobilemaps.mapscore.MapsCore -import io.openmobilemaps.mapscore.map.loader.DataLoader -import io.openmobilemaps.mapscore.map.view.MapView -import io.openmobilemaps.mapscore.map.view.MapViewState as OmmMapViewState -import io.openmobilemaps.mapscore.shared.map.MapConfig -import io.openmobilemaps.mapscore.shared.map.coordinates.CoordinateSystemFactory -import io.openmobilemaps.mapscore.shared.map.loader.LoaderInterface -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody.Companion.toResponseBody -import java.util.UUID -import android.content.Context -import android.os.Bundle -import android.widget.FrameLayout -import kotlinx.coroutines.flow.first - -interface OpenMobileMapsViewStateInterface : MapViewStateInterface - -/** - * アプリ側の状態。 - * - * カメラの保持・[MapViewStateInterface.moveCameraTo]・`fitBounds` はコアの - * [MapViewState] が持つ。ドライバーが書くのは `id` / `mapDesignType` と、 - * 共変の [getMapViewHolder](**1 行だが消さないこと**。消すとアプリ側の - * `state.getMapViewHolder()?.map` が静的型を失いソース非互換になる)だけ。 - */ -class OpenMobileMapsViewState( - mapDesignType: OpenMobileMapsMapDesignTypeInterface, - override val id: String, - cameraPosition: MapCameraPosition = MapCameraPosition.Default, -) : MapViewState(cameraPosition), - OpenMobileMapsViewStateInterface { - private var controller: OpenMobileMapsMapViewController? = null - private var current: OpenMobileMapsMapDesignTypeInterface = mapDesignType - - override var mapDesignType: OpenMobileMapsMapDesignTypeInterface - get() = current - set(value) { - current = value - controller?.setMapDesignType(value) - } - - internal fun setController(controller: OpenMobileMapsMapViewController) { - this.controller = controller - attachController(controller) - } - - internal fun onMapDesignTypeChange(value: OpenMobileMapsMapDesignTypeInterface) { - current = value - } - - /** 戻り型をこのプロバイダのホルダーへ絞る(アプリが `?.map` を取れる形を保つため)。 */ - override fun getMapViewHolder(): OpenMobileMapsMapViewHolder? = - super.getMapViewHolder() as? OpenMobileMapsMapViewHolder - - internal fun updateCameraPosition(cameraPosition: MapCameraPosition) { - setCameraPositionInternal(cameraPosition) - } -} - -class OpenMobileMapsMapViewSaver : BaseMapViewSaver() { - override fun saveMapDesign( - state: OpenMobileMapsViewState, - bundle: Bundle, - ) { - bundle.putString("id", state.mapDesignType.id) - bundle.putString("tileUrlTemplate", state.mapDesignType.tileUrlTemplate) - bundle.putInt("tileSize", state.mapDesignType.tileSize) - } - - override fun createState( - stateId: String, - mapDesignBundle: Bundle?, - cameraPosition: MapCameraPosition, - ): OpenMobileMapsViewState = - OpenMobileMapsViewState( - id = stateId, - mapDesignType = - OpenMobileMapsDesign( - id = mapDesignBundle?.getString("id") ?: OpenMobileMapsDesign.OpenStreetMap.id, - tileUrlTemplate = - mapDesignBundle?.getString("tileUrlTemplate") - ?: OpenMobileMapsDesign.OpenStreetMap.tileUrlTemplate, - tileSize = mapDesignBundle?.getInt("tileSize")?.takeIf { it > 0 } ?: 256, - ), - cameraPosition = cameraPosition, - ) - - override fun getStateId(state: OpenMobileMapsViewState): String = state.id -} - -@Composable -fun rememberOpenMobileMapsMapViewState( - mapDesign: OpenMobileMapsMapDesignTypeInterface = OpenMobileMapsDesign.OpenStreetMap, - cameraPosition: MapCameraPositionInterface = MapCameraPosition.Default, -): OpenMobileMapsViewState { - val stateId by rememberSaveable { mutableStateOf(UUID.randomUUID().toString()) } - val state = - rememberSaveable(stateSaver = OpenMobileMapsMapViewSaver().createSaver()) { - mutableStateOf( - OpenMobileMapsViewState( - id = stateId, - mapDesignType = mapDesign, - cameraPosition = MapCameraPosition.from(cameraPosition), - ), - ) - } - return state.value -} - -/** - * 地図の Composable。 - * - * ## SDK 固有の必須手順が 2 つある - * - * 1. [MapsCore.initialize] … ネイティブライブラリを読む。忘れると `UnsatisfiedLinkError`。 - * 2. [MapView.registerLifecycle] … レンダリングループを回す。**忘れると地図が真っ黒のまま** - * エラーも出ない。 - */ -@Composable -fun OpenMobileMapsMapView( - state: OpenMobileMapsViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - content: (@Composable OpenMobileMapsMapViewScope.() -> Unit)? = null, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val scope = remember { OpenMobileMapsMapViewScope() } - val registry = remember { scope.buildRegistry() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val loaders = - remember { - arrayListOf( - DataLoader( - context, - context.cacheDir, - CACHE_BYTES, - "", - USER_AGENT, - listOf(emptyLocalTileInterceptor()), - emptyList(), - ), - ) - } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - OpenMobileMapsMapSurface(context).apply { - val inner = MapView(context) - mapView = inner - addView( - inner, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), - ) - // 地図は EPSG:3857 で構成する(タイルがすべて 3857 で、ズームの縮尺の - // 導出も「地図単位 = メルカトルメートル」を前提にしているため)。 - // - // 密度は densityDpi を渡すこと。SDK のサンプルは xdpi(実測の物理 dpi)を - // 渡すが、それだと端末ごとに縮尺が数 % ずれ、Google Maps と大きさが揃わない。 - inner.setupMap( - MapConfig(CoordinateSystemFactory.getEpsg3857System()), - context.resources.displayMetrics.densityDpi - .toFloat(), - false, - false, - ) - inner.registerLifecycle(lifecycleOwner.lifecycle) - } - }, - scope = scope, - registry = registry, - onMapLoaded = onMapLoaded, - holderProvider = { surface -> - // setupMap は非同期に GL スレッドで完了する。INITIALIZED になる前に - // レイヤを足すと落ちるので待つ。 - surface.mapView.mapViewState.first { it != OmmMapViewState.UNINITIALIZED } - OpenMobileMapsMapViewHolder(surface, surface.mapView.requireMapInterface()) - }, - controllerProvider = { holder -> - createOpenMobileMapsViewController( - holder = holder, - loaders = loaders, - density = context.resources.displayMetrics.density, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - serviceRegistry = state.serviceRegistry, - ).also { mapController -> - mapController.setMapDesignType(state.mapDesignType) - mapController.installListeners() - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - mapController.moveCamera(MapCameraPosition.from(state.cameraPosition)) - state.setController(mapController) - holder.mapView.post { mapController.sendInitialCameraUpdate() } - } - }, - sdkInitialize = { - if (sdkInitialize != null) { - sdkInitialize(context) - } else { - MapsCore.initialize() - true - } - }, - content = content, - ) -} - -/** - * 命令的なコントローラ一式を組み立てる。Compose からも、React Native のような - * 非 Compose ホストからも同じものを使えるようにここに置く。 - */ -fun createOpenMobileMapsViewController( - holder: OpenMobileMapsMapViewHolder, - loaders: ArrayList, - density: Float, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - serviceRegistry: MutableMapServiceRegistry? = null, -): OpenMobileMapsMapViewController { - val layers = OpenMobileMapsLayers() - layers.attachTo(holder.map) - - val markerRenderer = - OpenMobileMapsMarkerOverlayRenderer( - holder = holder, - markerManager = MarkerManager.defaultManager(), - iconLayer = layers.iconLayer, - ) - - val mapController = - OpenMobileMapsMapViewController( - holder = holder, - layers = layers, - markerController = OpenMobileMapsMarkerController(markerRenderer, markerTiling), - polylineController = - OpenMobileMapsPolylineController( - OpenMobileMapsPolylineOverlayRenderer( - holder = holder, - polylineManager = PolylineManager(), - lineLayer = layers.polylineLayer, - ), - ), - polygonController = - OpenMobileMapsPolygonController( - OpenMobileMapsPolygonOverlayRenderer( - holder = holder, - polygonManager = PolygonManager(), - fillLayer = layers.polygonFillLayer, - outlineLayer = layers.polygonOutlineLayer, - ), - ), - circleController = - OpenMobileMapsCircleController( - OpenMobileMapsCircleOverlayRenderer( - holder = holder, - circleManager = CircleManager(), - fillLayer = layers.circleFillLayer, - outlineLayer = layers.circleOutlineLayer, - ), - ), - groundImageController = - OpenMobileMapsGroundImageController( - OpenMobileMapsGroundImageOverlayRenderer(holder = holder, layers = layers), - ), - rasterLayerController = - OpenMobileMapsRasterLayerController( - OpenMobileMapsRasterLayerOverlayRenderer( - holder = holder, - layers = layers, - loaders = loaders, - ), - ), - loaders = loaders, - density = density, - ) - - serviceRegistry?.let { serviceRegistryTarget -> - mapController.declareCapabilities(serviceRegistryTarget) - serviceRegistryTarget.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = - mapController.createMarkerRenderer(strategy) - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController.createMarkerEventController(controller, renderer) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - } - - return mapController -} - -/** - * ローカルタイルサーバの 404 を「透明な 1x1 PNG の 200」へ書き換える。 - * - * ## なぜ要るのか - * - * マーカータイルのローカルサーバは、マーカーが 1 つも無いタイルに 404 を返す - * (MapLibre などはそれを「空タイル」として扱うため問題にならない)。 - * ところが Open Mobile Maps は 404 を **エラー** として扱い、そのタイルを - * 「存在しない」と記録する。子タイルが存在しない領域はステンシルマスクに - * **穴が開き、保持されている粗い親タイルがそこだけ透けて見え続ける** - * (2-6 の対策後も、マーカーの無い領域だけ巨大な赤い矩形が残る、という形で出た)。 - * - * ## 204 ではだめ - * - * SDK の `DataLoader` には 204 = 空タイルの分岐があるが、判定は - * `isSuccessful && bytes != null` が**先**に走る。204 は 2xx で本文も空配列として - * 読めてしまうので、その分岐には届かず「デコード失敗」のエラーになる。 - * 実際に描ける**本物の透明 PNG** を 200 で返すのが確実で、通常の読み込み経路を - * そのまま通る(1x1 の透明テクスチャがタイル全面に伸びるだけで、見た目には何も出ない)。 - */ -private fun emptyLocalTileInterceptor(): okhttp3.Interceptor = - okhttp3.Interceptor { chain -> - val response = chain.proceed(chain.request()) - val host = chain.request().url.host - if (response.code == 404 && (host == "127.0.0.1" || host == "localhost")) { - response.close() - okhttp3.Response - .Builder() - .request(chain.request()) - .protocol(okhttp3.Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(transparentTilePng.toResponseBody("image/png".toMediaType())) - .build() - } else { - response - } - } - -/** 空タイルとして返す透明 1x1 PNG。 */ -private val transparentTilePng: ByteArray by lazy { - val bitmap = android.graphics.Bitmap.createBitmap(1, 1, android.graphics.Bitmap.Config.ARGB_8888) - java.io.ByteArrayOutputStream().use { stream -> - bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) - bitmap.recycle() - stream.toByteArray() - } -} - -/** タイルのディスクキャッシュ。他プロバイダの既定と揃えてある。 */ -private const val CACHE_BYTES: Long = 50L * 1024 * 1024 -private const val USER_AGENT = "MapConductor" +package com.mapconductor.openmobilemaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.mapconductor.compose.map.BaseMapViewSaver +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.circle.CircleManager +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polyline.PolylineManager +import com.mapconductor.openmobilemaps.marker.OpenMobileMapsMarkerController +import io.openmobilemaps.mapscore.MapsCore +import io.openmobilemaps.mapscore.map.loader.DataLoader +import io.openmobilemaps.mapscore.map.view.MapView +import io.openmobilemaps.mapscore.map.view.MapViewState as OmmMapViewState +import io.openmobilemaps.mapscore.shared.map.MapConfig +import io.openmobilemaps.mapscore.shared.map.coordinates.CoordinateSystemFactory +import io.openmobilemaps.mapscore.shared.map.loader.LoaderInterface +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import java.util.UUID +import android.content.Context +import android.os.Bundle +import android.widget.FrameLayout +import kotlinx.coroutines.flow.first + +interface OpenMobileMapsViewStateInterface : MapViewStateInterface + +/** + * アプリ側の状態。 + * + * カメラの保持・[MapViewStateInterface.moveCameraTo]・`fitBounds` はコアの + * [MapViewState] が持つ。ドライバーが書くのは `id` / `mapDesignType` と、 + * 共変の [getMapViewHolder](**1 行だが消さないこと**。消すとアプリ側の + * `state.getMapViewHolder()?.map` が静的型を失いソース非互換になる)だけ。 + */ +class OpenMobileMapsViewState( + mapDesignType: OpenMobileMapsMapDesignTypeInterface, + override val id: String, + cameraPosition: MapCameraPosition = MapCameraPosition.Default, +) : MapViewState(cameraPosition), + OpenMobileMapsViewStateInterface { + private var controller: OpenMobileMapsMapViewController? = null + private var current: OpenMobileMapsMapDesignTypeInterface = mapDesignType + + override var mapDesignType: OpenMobileMapsMapDesignTypeInterface + get() = current + set(value) { + current = value + controller?.setMapDesignType(value) + } + + internal fun setController(controller: OpenMobileMapsMapViewController) { + this.controller = controller + attachController(controller) + } + + internal fun onMapDesignTypeChange(value: OpenMobileMapsMapDesignTypeInterface) { + current = value + } + + /** 戻り型をこのプロバイダのホルダーへ絞る(アプリが `?.map` を取れる形を保つため)。 */ + override fun getMapViewHolder(): OpenMobileMapsMapViewHolder? = + super.getMapViewHolder() as? OpenMobileMapsMapViewHolder + + internal fun updateCameraPosition(cameraPosition: MapCameraPosition) { + setCameraPositionInternal(cameraPosition) + } +} + +class OpenMobileMapsMapViewSaver : BaseMapViewSaver() { + override fun saveMapDesign( + state: OpenMobileMapsViewState, + bundle: Bundle, + ) { + bundle.putString("id", state.mapDesignType.id) + bundle.putString("tileUrlTemplate", state.mapDesignType.tileUrlTemplate) + bundle.putInt("tileSize", state.mapDesignType.tileSize) + } + + override fun createState( + stateId: String, + mapDesignBundle: Bundle?, + cameraPosition: MapCameraPosition, + ): OpenMobileMapsViewState = + OpenMobileMapsViewState( + id = stateId, + mapDesignType = + OpenMobileMapsDesign( + id = mapDesignBundle?.getString("id") ?: OpenMobileMapsDesign.OpenStreetMap.id, + tileUrlTemplate = + mapDesignBundle?.getString("tileUrlTemplate") + ?: OpenMobileMapsDesign.OpenStreetMap.tileUrlTemplate, + tileSize = mapDesignBundle?.getInt("tileSize")?.takeIf { it > 0 } ?: 256, + ), + cameraPosition = cameraPosition, + ) + + override fun getStateId(state: OpenMobileMapsViewState): String = state.id +} + +@Composable +fun rememberOpenMobileMapsMapViewState( + mapDesign: OpenMobileMapsMapDesignTypeInterface = OpenMobileMapsDesign.OpenStreetMap, + cameraPosition: MapCameraPositionInterface = MapCameraPosition.Default, +): OpenMobileMapsViewState { + val stateId by rememberSaveable { mutableStateOf(UUID.randomUUID().toString()) } + val state = + rememberSaveable(stateSaver = OpenMobileMapsMapViewSaver().createSaver()) { + mutableStateOf( + OpenMobileMapsViewState( + id = stateId, + mapDesignType = mapDesign, + cameraPosition = MapCameraPosition.from(cameraPosition), + ), + ) + } + return state.value +} + +/** + * 地図の Composable。 + * + * ## SDK 固有の必須手順が 2 つある + * + * 1. [MapsCore.initialize] … ネイティブライブラリを読む。忘れると `UnsatisfiedLinkError`。 + * 2. [MapView.registerLifecycle] … レンダリングループを回す。**忘れると地図が真っ黒のまま** + * エラーも出ない。 + */ +@Composable +fun OpenMobileMapsMapView( + state: OpenMobileMapsViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, + content: (@Composable OpenMobileMapsMapViewScope.() -> Unit)? = null, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = remember { OpenMobileMapsMapViewScope() } + val registry = remember { scope.buildRegistry() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val loaders = + remember { + arrayListOf( + DataLoader( + context, + context.cacheDir, + CACHE_BYTES, + "", + USER_AGENT, + listOf(emptyLocalTileInterceptor()), + emptyList(), + ), + ) + } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + OpenMobileMapsMapSurface(context).apply { + val inner = MapView(context) + mapView = inner + addView( + inner, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + // 地図は EPSG:3857 で構成する(タイルがすべて 3857 で、ズームの縮尺の + // 導出も「地図単位 = メルカトルメートル」を前提にしているため)。 + // + // 密度は densityDpi を渡すこと。SDK のサンプルは xdpi(実測の物理 dpi)を + // 渡すが、それだと端末ごとに縮尺が数 % ずれ、Google Maps と大きさが揃わない。 + inner.setupMap( + MapConfig(CoordinateSystemFactory.getEpsg3857System()), + context.resources.displayMetrics.densityDpi + .toFloat(), + false, + false, + ) + inner.registerLifecycle(lifecycleOwner.lifecycle) + } + }, + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + holderProvider = { surface -> + // setupMap は非同期に GL スレッドで完了する。INITIALIZED になる前に + // レイヤを足すと落ちるので待つ。 + surface.mapView.mapViewState.first { it != OmmMapViewState.UNINITIALIZED } + OpenMobileMapsMapViewHolder(surface, surface.mapView.requireMapInterface()) + }, + controllerProvider = { holder -> + createOpenMobileMapsViewController( + holder = holder, + loaders = loaders, + density = context.resources.displayMetrics.density, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + serviceRegistry = state.serviceRegistry, + ).also { mapController -> + mapController.setMapDesignType(state.mapDesignType) + mapController.installListeners() + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + mapController.moveCamera(MapCameraPosition.from(state.cameraPosition)) + state.setController(mapController) + holder.mapView.post { mapController.sendInitialCameraUpdate() } + } + }, + sdkInitialize = { + if (sdkInitialize != null) { + sdkInitialize(context) + } else { + MapsCore.initialize() + true + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} + +/** + * 命令的なコントローラ一式を組み立てる。Compose からも、React Native のような + * 非 Compose ホストからも同じものを使えるようにここに置く。 + */ +fun createOpenMobileMapsViewController( + holder: OpenMobileMapsMapViewHolder, + loaders: ArrayList, + density: Float, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + serviceRegistry: MutableMapServiceRegistry? = null, +): OpenMobileMapsMapViewController { + val layers = OpenMobileMapsLayers() + layers.attachTo(holder.map) + + val markerRenderer = + OpenMobileMapsMarkerOverlayRenderer( + holder = holder, + markerManager = MarkerManager.defaultManager(), + iconLayer = layers.iconLayer, + ) + + val mapController = + OpenMobileMapsMapViewController( + holder = holder, + layers = layers, + markerController = OpenMobileMapsMarkerController(markerRenderer, markerTiling), + polylineController = + OpenMobileMapsPolylineController( + OpenMobileMapsPolylineOverlayRenderer( + holder = holder, + polylineManager = PolylineManager(), + lineLayer = layers.polylineLayer, + ), + ), + polygonController = + OpenMobileMapsPolygonController( + OpenMobileMapsPolygonOverlayRenderer( + holder = holder, + polygonManager = PolygonManager(), + fillLayer = layers.polygonFillLayer, + outlineLayer = layers.polygonOutlineLayer, + ), + ), + circleController = + OpenMobileMapsCircleController( + OpenMobileMapsCircleOverlayRenderer( + holder = holder, + circleManager = CircleManager(), + fillLayer = layers.circleFillLayer, + outlineLayer = layers.circleOutlineLayer, + ), + ), + groundImageController = + OpenMobileMapsGroundImageController( + OpenMobileMapsGroundImageOverlayRenderer(holder = holder, layers = layers), + ), + rasterLayerController = + OpenMobileMapsRasterLayerController( + OpenMobileMapsRasterLayerOverlayRenderer( + holder = holder, + layers = layers, + loaders = loaders, + ), + ), + loaders = loaders, + density = density, + ) + + serviceRegistry?.let { serviceRegistryTarget -> + mapController.declareCapabilities(serviceRegistryTarget) + serviceRegistryTarget.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = + mapController.createMarkerRenderer(strategy) + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController.createMarkerEventController(controller, renderer) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + } + + return mapController +} + +/** + * ローカルタイルサーバの 404 を「透明な 1x1 PNG の 200」へ書き換える。 + * + * ## なぜ要るのか + * + * マーカータイルのローカルサーバは、マーカーが 1 つも無いタイルに 404 を返す + * (MapLibre などはそれを「空タイル」として扱うため問題にならない)。 + * ところが Open Mobile Maps は 404 を **エラー** として扱い、そのタイルを + * 「存在しない」と記録する。子タイルが存在しない領域はステンシルマスクに + * **穴が開き、保持されている粗い親タイルがそこだけ透けて見え続ける** + * (2-6 の対策後も、マーカーの無い領域だけ巨大な赤い矩形が残る、という形で出た)。 + * + * ## 204 ではだめ + * + * SDK の `DataLoader` には 204 = 空タイルの分岐があるが、判定は + * `isSuccessful && bytes != null` が**先**に走る。204 は 2xx で本文も空配列として + * 読めてしまうので、その分岐には届かず「デコード失敗」のエラーになる。 + * 実際に描ける**本物の透明 PNG** を 200 で返すのが確実で、通常の読み込み経路を + * そのまま通る(1x1 の透明テクスチャがタイル全面に伸びるだけで、見た目には何も出ない)。 + */ +private fun emptyLocalTileInterceptor(): okhttp3.Interceptor = + okhttp3.Interceptor { chain -> + val response = chain.proceed(chain.request()) + val host = chain.request().url.host + if (response.code == 404 && (host == "127.0.0.1" || host == "localhost")) { + response.close() + okhttp3.Response + .Builder() + .request(chain.request()) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(transparentTilePng.toResponseBody("image/png".toMediaType())) + .build() + } else { + response + } + } + +/** 空タイルとして返す透明 1x1 PNG。 */ +private val transparentTilePng: ByteArray by lazy { + val bitmap = android.graphics.Bitmap.createBitmap(1, 1, android.graphics.Bitmap.Config.ARGB_8888) + java.io.ByteArrayOutputStream().use { stream -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) + bitmap.recycle() + stream.toByteArray() + } +} + +/** タイルのディスクキャッシュ。他プロバイダの既定と揃えてある。 */ +private const val CACHE_BYTES: Long = 50L * 1024 * 1024 +private const val USER_AGENT = "MapConductor" diff --git a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt index a430217..b779867 100644 --- a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt +++ b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt @@ -1,90 +1,96 @@ -package com.mapconductor.openmobilemaps - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import com.mapconductor.openmobilemaps.zoom.ZoomAltitudeConverter -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -/** - * Open Mobile Maps の 2D カメラ向けの tilt 擬似表現。 - * - * ## なぜ擬似表現なのか - * - * [io.openmobilemaps.mapscore.shared.map.MapCameraInterface] にピッチが無い。 - * [io.openmobilemaps.mapscore.shared.map.MapCamera3dInterface] なら - * `setPoseCamera` で本当に傾けられるが、それは `setupMap(..., is3D = true)` で - * **地球儀表示**にしたときだけ手に入るもので、平面地図のまま傾けることはできない。 - * - * そこで android-for-arcgis の 2D(`ArcGIS2DTiltEmulation`)と**同じ方式・同じ定数**を使う: - * - * - 遠近感は [OpenMobileMapsMapSurface] がビューを X 軸まわりに回して作る - * - カメラ位置の付け替えはここが受け持つ - * - * ## tilt の符号 - * - * - `tilt >= 0`: 指定位置は**ターゲット**(画面中心)。カメラが後方へ下がるだけなので - * 中心もズームも動かさない。 - * - `tilt < 0`: 指定位置は**カメラ位置**。ターゲットが進行方向(bearing)へ前進する。 - * 前進量とズームオフセットは MapLibre / TomTom / Leaflet / ArcGIS2D と同一の式・同一定数。 - */ -internal object OpenMobileMapsTiltEmulation { - /** MapLibre / TomTom / Leaflet / ArcGIS2D と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ - private const val TARGET_DISTANCE_SCALE = 1.83 - private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 - const val MAX_TILT_DEGREES = 60.0 - - /** - * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 - * ArcGIS2D と同じ理由で、ここを触るとシフト量が他プロバイダとずれる。 - */ - private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) - - /** 論理カメラ → 実際に SDK へ渡す中心・統一ズーム。 */ - fun shiftedCamera(position: MapCameraPosition): Pair { - if (position.tilt >= 0) return position.position to position.zoom - - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, MAX_TILT_DEGREES) - val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = altitudeFor(position.zoom, position.position.latitude) - val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val target = Spherical.computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return target to zoom - } - - /** SDK から読み戻した中心・統一ズームを論理カメラへ戻す。 */ - fun restoreLogicalCamera( - center: GeoPointInterface, - zoom: Double, - bearing: Double, - logicalTilt: Double, - ): Pair { - val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, MAX_TILT_DEGREES) - if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom - - val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = altitudeFor(originalZoom, center.latitude) - val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val originalPosition: GeoPoint = Spherical.computeOffset(center, distanceBackward, CameraBearing.toNativeHeading(bearing) + 180.0) - return originalPosition to originalZoom - } - - /** - * 統一ズームでの視距離。 - * - * [ZoomAltitudeConverter.zoomLevelToAltitude] は SDK の縮尺を受け取る形なので、 - * ここでは統一ズームを一度縮尺へ戻してから渡す(他プロバイダの - * `converter.zoomLevelToAltitude(zoom, ...)` と同じ意味になる)。 - */ - private fun altitudeFor( - unifiedZoom: Double, - latitude: Double, - ): Double = converter.zoomLevelToAltitude(converter.toNativeZoom(unifiedZoom), latitude, 0.0) -} +package com.mapconductor.openmobilemaps + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import com.mapconductor.openmobilemaps.zoom.ZoomAltitudeConverter +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +/** + * Open Mobile Maps の 2D カメラ向けの tilt 擬似表現。 + * + * ## なぜ擬似表現なのか + * + * [io.openmobilemaps.mapscore.shared.map.MapCameraInterface] にピッチが無い。 + * [io.openmobilemaps.mapscore.shared.map.MapCamera3dInterface] なら + * `setPoseCamera` で本当に傾けられるが、それは `setupMap(..., is3D = true)` で + * **地球儀表示**にしたときだけ手に入るもので、平面地図のまま傾けることはできない。 + * + * そこで android-for-arcgis の 2D(`ArcGIS2DTiltEmulation`)と**同じ方式・同じ定数**を使う: + * + * - 遠近感は [OpenMobileMapsMapSurface] がビューを X 軸まわりに回して作る + * - カメラ位置の付け替えはここが受け持つ + * + * ## tilt の符号 + * + * - `tilt >= 0`: 指定位置は**ターゲット**(画面中心)。カメラが後方へ下がるだけなので + * 中心もズームも動かさない。 + * - `tilt < 0`: 指定位置は**カメラ位置**。ターゲットが進行方向(bearing)へ前進する。 + * 前進量とズームオフセットは MapLibre / TomTom / Leaflet / ArcGIS2D と同一の式・同一定数。 + */ +internal object OpenMobileMapsTiltEmulation { + /** MapLibre / TomTom / Leaflet / ArcGIS2D と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ + private const val TARGET_DISTANCE_SCALE = 1.83 + private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 + const val MAX_TILT_DEGREES = 60.0 + + /** + * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 + * ArcGIS2D と同じ理由で、ここを触るとシフト量が他プロバイダとずれる。 + */ + private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) + + /** 論理カメラ → 実際に SDK へ渡す中心・統一ズーム。 */ + fun shiftedCamera(position: MapCameraPosition): Pair { + if (position.tilt >= 0) return position.position to position.zoom + + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, MAX_TILT_DEGREES) + val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = altitudeFor(position.zoom, position.position.latitude) + val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return target to zoom + } + + /** SDK から読み戻した中心・統一ズームを論理カメラへ戻す。 */ + fun restoreLogicalCamera( + center: GeoPointInterface, + zoom: Double, + bearing: Double, + logicalTilt: Double, + ): Pair { + val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, MAX_TILT_DEGREES) + if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom + + val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = altitudeFor(originalZoom, center.latitude) + val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val originalPosition: GeoPoint = + Spherical.computeOffset( + center, distanceBackward, + CameraBearing.toNativeHeading(bearing) + 180.0, + ) + return originalPosition to originalZoom + } + + /** + * 統一ズームでの視距離。 + * + * [ZoomAltitudeConverter.zoomLevelToAltitude] は SDK の縮尺を受け取る形なので、 + * ここでは統一ズームを一度縮尺へ戻してから渡す(他プロバイダの + * `converter.zoomLevelToAltitude(zoom, ...)` と同じ意味になる)。 + */ + private fun altitudeFor( + unifiedZoom: Double, + latitude: Double, + ): Double = converter.zoomLevelToAltitude(converter.toNativeZoom(unifiedZoom), latitude, 0.0) +} From a619d85f8db25939810f68b8bd72263388295189 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:17 -0700 Subject: [PATCH 2/2] Keep each file's line endings as they are on develop The checkpoint commit rewrote whole files between LF and CRLF, which buried its real changes under line-ending noise. Content is unchanged. --- build.gradle.kts | 350 +++---- settings.gradle.kts | 88 +- .../openmobilemaps/OpenMobileMapsMapView.kt | 870 +++++++++--------- .../OpenMobileMapsTiltEmulation.kt | 192 ++-- 4 files changed, 750 insertions(+), 750 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 154a52b..4f276ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,175 +1,175 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.compose) - id("org.jlleitschuh.gradle.ktlint") version "14.2.0" - id("maven-publish") - id("signing") - id("com.gradleup.nmcp") version "1.5.0" -} - -ktlint { - android.set(true) - reporters { - reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) - reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) - } -} - -// ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 -// android-for-* は地図SDKドライバーなので、モジュール単位で許可する。 -kotlin { - compilerOptions { - optIn.add("com.mapconductor.core.InternalMapConductorApi") - } -} - -android { - namespace = "com.mapconductor.openmobilemaps" - compileSdk = project.property("compileSdk").toString().toInt() - - defaultConfig { - minSdk = project.property("minSdk").toString().toInt() - consumerProguardFiles("consumer-rules.pro") - } - - buildFeatures { - compose = true - } - - compileOptions { - sourceCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) - targetCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) - } - - publishing { - singleVariant("release") { - withSourcesJar() - } - } -} - -val libraryGroupId = project.findProperty("libraryGroupId") as String? ?: "com.mapconductor" -val libraryArtifactId = "for-openmobilemaps" -val libraryVersion = project.findProperty("libraryVersion") as String? ?: "1.0.0" - -dependencies { - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.foundation) - - // Lifecycle(MapView が Lifecycle を要求する) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.common.java8) - - // Open Mobile Maps SDK - api(libs.openmobilemaps.mapscore) - // ローカルタイルサーバの 404 を 204 へ書き換えるインターセプタで使う - // (mapscore の transitive 依存と同じ版。DataLoader が okhttp3.Interceptor を受け取る) - implementation("com.squareup.okhttp3:okhttp:5.1.0") - - // 集約ビルド(android-sdk)ではプロジェクト参照、単体ビルド(CI のリリース)では - // Maven 座標で解決する。他プロバイダと同じ形。 - if (findProject(":android-sdk-compose") != null) { - api(project(":android-sdk-compose")) - } else { - api("com.mapconductor:compose:$libraryVersion") - } - - testImplementation(libs.junit) -} - -// Set project version for NMCP plugin -version = libraryVersion -val libraryName = "MapConductor for Open Mobile Maps" -val libraryDescription = "Open Mobile Maps implementation for MapConductor unified mapping library" - -// Gradle 9.6 で `by tasks.registering` は非推奨(スクリプトコンパイルエラーになる)。 -// googlemaps 等の既存モジュールは古い形のままなので、再コンパイル時に同じ問題が出る。 -val javadocJar = - tasks.register("javadocJar") { - archiveClassifier.set("javadoc") - } - -afterEvaluate { - publishing { - publications { - create("release") { - from(components["release"]) - - groupId = libraryGroupId - artifactId = libraryArtifactId - version = libraryVersion - - artifact(javadocJar.get()) - - pom { - name.set(libraryName) - description.set(libraryDescription) - url.set( - project.findProperty("libraryUrl") as String? - ?: "https://github.com/MapConductor/android-for-openmobilemaps", - ) - - licenses { - license { - name.set("The Apache License, Version 2.0") - url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") - } - } - - developers { - developer { - id.set(project.findProperty("developerId") as String? ?: "mapconductor") - name.set(project.findProperty("developerName") as String? ?: "MapConductor Team") - email.set(project.findProperty("developerEmail") as String? ?: "info@mkgeeklab.com") - } - } - - scm { - connection.set("scm:git:git://github.com/MapConductor/android-for-openmobilemaps.git") - developerConnection - .set("scm:git:ssh://github.com:MapConductor/android-for-openmobilemaps.git") - url.set( - project.findProperty("scmUrl") as String? - ?: "https://github.com/MapConductor/android-for-openmobilemaps.git", - ) - } - } - } - } - - repositories { - maven { - name = "GitHubPackages" - setUrl("https://maven.pkg.github.com/MapConductor/android-for-openmobilemaps") - credentials { - username = - project.findProperty("gpr.user") as String? ?: System.getenv("GPR_USER") - ?: System.getenv("GITHUB_ACTOR") - password = - project.findProperty("gpr.key") as String? ?: System.getenv("GPR_TOKEN") - ?: System.getenv("GITHUB_TOKEN") - } - } - } - } - - signing { - val signingKey = findProperty("signingKey") as String? - val signingPassword = findProperty("signingPassword") as String? - if (!signingKey.isNullOrEmpty() && !signingPassword.isNullOrEmpty()) { - useInMemoryPgpKeys(signingKey, signingPassword) - sign(publishing.publications["release"]) - } - } - - if (project == rootProject) { - // standalone build only — in multi-project (android-sdk), parent configures nmcp - nmcp { - publishAllPublicationsToCentralPortal { - username.set(findProperty("ossrh_username") as String? ?: System.getenv("OSSRH_USERNAME") ?: "") - password.set(findProperty("ossrh_password") as String? ?: System.getenv("OSSRH_PASSWORD") ?: "") - } - } - } -} +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" + id("maven-publish") + id("signing") + id("com.gradleup.nmcp") version "1.5.0" +} + +ktlint { + android.set(true) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + } +} + +// ドライバー実装点(@InternalMapConductorApi)を使うためのオプトイン。 +// android-for-* は地図SDKドライバーなので、モジュール単位で許可する。 +kotlin { + compilerOptions { + optIn.add("com.mapconductor.core.InternalMapConductorApi") + } +} + +android { + namespace = "com.mapconductor.openmobilemaps" + compileSdk = project.property("compileSdk").toString().toInt() + + defaultConfig { + minSdk = project.property("minSdk").toString().toInt() + consumerProguardFiles("consumer-rules.pro") + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + targetCompatibility = JavaVersion.toVersion(project.property("javaVersion").toString()) + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +val libraryGroupId = project.findProperty("libraryGroupId") as String? ?: "com.mapconductor" +val libraryArtifactId = "for-openmobilemaps" +val libraryVersion = project.findProperty("libraryVersion") as String? ?: "1.0.0" + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.foundation) + + // Lifecycle(MapView が Lifecycle を要求する) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.common.java8) + + // Open Mobile Maps SDK + api(libs.openmobilemaps.mapscore) + // ローカルタイルサーバの 404 を 204 へ書き換えるインターセプタで使う + // (mapscore の transitive 依存と同じ版。DataLoader が okhttp3.Interceptor を受け取る) + implementation("com.squareup.okhttp3:okhttp:5.1.0") + + // 集約ビルド(android-sdk)ではプロジェクト参照、単体ビルド(CI のリリース)では + // Maven 座標で解決する。他プロバイダと同じ形。 + if (findProject(":android-sdk-compose") != null) { + api(project(":android-sdk-compose")) + } else { + api("com.mapconductor:compose:$libraryVersion") + } + + testImplementation(libs.junit) +} + +// Set project version for NMCP plugin +version = libraryVersion +val libraryName = "MapConductor for Open Mobile Maps" +val libraryDescription = "Open Mobile Maps implementation for MapConductor unified mapping library" + +// Gradle 9.6 で `by tasks.registering` は非推奨(スクリプトコンパイルエラーになる)。 +// googlemaps 等の既存モジュールは古い形のままなので、再コンパイル時に同じ問題が出る。 +val javadocJar = + tasks.register("javadocJar") { + archiveClassifier.set("javadoc") + } + +afterEvaluate { + publishing { + publications { + create("release") { + from(components["release"]) + + groupId = libraryGroupId + artifactId = libraryArtifactId + version = libraryVersion + + artifact(javadocJar.get()) + + pom { + name.set(libraryName) + description.set(libraryDescription) + url.set( + project.findProperty("libraryUrl") as String? + ?: "https://github.com/MapConductor/android-for-openmobilemaps", + ) + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + developers { + developer { + id.set(project.findProperty("developerId") as String? ?: "mapconductor") + name.set(project.findProperty("developerName") as String? ?: "MapConductor Team") + email.set(project.findProperty("developerEmail") as String? ?: "info@mkgeeklab.com") + } + } + + scm { + connection.set("scm:git:git://github.com/MapConductor/android-for-openmobilemaps.git") + developerConnection + .set("scm:git:ssh://github.com:MapConductor/android-for-openmobilemaps.git") + url.set( + project.findProperty("scmUrl") as String? + ?: "https://github.com/MapConductor/android-for-openmobilemaps.git", + ) + } + } + } + } + + repositories { + maven { + name = "GitHubPackages" + setUrl("https://maven.pkg.github.com/MapConductor/android-for-openmobilemaps") + credentials { + username = + project.findProperty("gpr.user") as String? ?: System.getenv("GPR_USER") + ?: System.getenv("GITHUB_ACTOR") + password = + project.findProperty("gpr.key") as String? ?: System.getenv("GPR_TOKEN") + ?: System.getenv("GITHUB_TOKEN") + } + } + } + } + + signing { + val signingKey = findProperty("signingKey") as String? + val signingPassword = findProperty("signingPassword") as String? + if (!signingKey.isNullOrEmpty() && !signingPassword.isNullOrEmpty()) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications["release"]) + } + } + + if (project == rootProject) { + // standalone build only — in multi-project (android-sdk), parent configures nmcp + nmcp { + publishAllPublicationsToCentralPortal { + username.set(findProperty("ossrh_username") as String? ?: System.getenv("OSSRH_USERNAME") ?: "") + password.set(findProperty("ossrh_password") as String? ?: System.getenv("OSSRH_PASSWORD") ?: "") + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a9e78e..f1aa3ad 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,44 +1,44 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenLocal() - google() - mavenCentral() - if (!System.getenv("GPR_USER").isNullOrEmpty() && !System.getenv("GPR_TOKEN").isNullOrEmpty()) { - maven { - name = "GithubPackages" - url = uri("https://maven.pkg.github.com/mapconductor/android-for-openmobilemaps") - credentials { - username = System.getenv("GPR_USER") ?: "" - password = System.getenv("GPR_TOKEN") ?: "" - } - } - } - if (!System.getenv("GPR_USER").isNullOrEmpty() || !System.getenv("GITHUB_ACTOR").isNullOrEmpty()) { - maven { - name = "GithubPackages-core" - url = uri("https://maven.pkg.github.com/MapConductor/android-sdk-core") - credentials { - username = System.getenv("GPR_USER") ?: System.getenv("GITHUB_ACTOR") ?: "" - password = System.getenv("GPR_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" - } - } - } - } -} - -rootProject.name = "android-for-openmobilemaps" +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + if (!System.getenv("GPR_USER").isNullOrEmpty() && !System.getenv("GPR_TOKEN").isNullOrEmpty()) { + maven { + name = "GithubPackages" + url = uri("https://maven.pkg.github.com/mapconductor/android-for-openmobilemaps") + credentials { + username = System.getenv("GPR_USER") ?: "" + password = System.getenv("GPR_TOKEN") ?: "" + } + } + } + if (!System.getenv("GPR_USER").isNullOrEmpty() || !System.getenv("GITHUB_ACTOR").isNullOrEmpty()) { + maven { + name = "GithubPackages-core" + url = uri("https://maven.pkg.github.com/MapConductor/android-sdk-core") + credentials { + username = System.getenv("GPR_USER") ?: System.getenv("GITHUB_ACTOR") ?: "" + password = System.getenv("GPR_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" + } + } + } + } +} + +rootProject.name = "android-for-openmobilemaps" diff --git a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt index 58d1b51..e347a9d 100644 --- a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt +++ b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsMapView.kt @@ -1,435 +1,435 @@ -package com.mapconductor.openmobilemaps - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.compose.LocalLifecycleOwner -import com.mapconductor.compose.map.BaseMapViewSaver -import com.mapconductor.compose.map.MapViewBase -import com.mapconductor.core.OnCameraMoveHandler -import com.mapconductor.core.OnMapEventHandler -import com.mapconductor.core.OnMapLoadedHandler -import com.mapconductor.core.circle.CircleManager -import com.mapconductor.core.map.CameraRestriction -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapCameraPositionInterface -import com.mapconductor.core.map.MapViewState -import com.mapconductor.core.map.MapViewStateInterface -import com.mapconductor.core.map.MapViewStyle -import com.mapconductor.core.map.MutableMapServiceRegistry -import com.mapconductor.core.marker.MarkerEventControllerInterface -import com.mapconductor.core.marker.MarkerManager -import com.mapconductor.core.marker.MarkerOverlayRendererInterface -import com.mapconductor.core.marker.MarkerRenderingStrategyInterface -import com.mapconductor.core.marker.MarkerRenderingSupport -import com.mapconductor.core.marker.MarkerRenderingSupportKey -import com.mapconductor.core.marker.MarkerTilingOptions -import com.mapconductor.core.marker.StrategyMarkerController -import com.mapconductor.core.polygon.PolygonManager -import com.mapconductor.core.polyline.PolylineManager -import com.mapconductor.openmobilemaps.marker.OpenMobileMapsMarkerController -import io.openmobilemaps.mapscore.MapsCore -import io.openmobilemaps.mapscore.map.loader.DataLoader -import io.openmobilemaps.mapscore.map.view.MapView -import io.openmobilemaps.mapscore.map.view.MapViewState as OmmMapViewState -import io.openmobilemaps.mapscore.shared.map.MapConfig -import io.openmobilemaps.mapscore.shared.map.coordinates.CoordinateSystemFactory -import io.openmobilemaps.mapscore.shared.map.loader.LoaderInterface -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.ResponseBody.Companion.toResponseBody -import java.util.UUID -import android.content.Context -import android.os.Bundle -import android.widget.FrameLayout -import kotlinx.coroutines.flow.first - -interface OpenMobileMapsViewStateInterface : MapViewStateInterface - -/** - * アプリ側の状態。 - * - * カメラの保持・[MapViewStateInterface.moveCameraTo]・`fitBounds` はコアの - * [MapViewState] が持つ。ドライバーが書くのは `id` / `mapDesignType` と、 - * 共変の [getMapViewHolder](**1 行だが消さないこと**。消すとアプリ側の - * `state.getMapViewHolder()?.map` が静的型を失いソース非互換になる)だけ。 - */ -class OpenMobileMapsViewState( - mapDesignType: OpenMobileMapsMapDesignTypeInterface, - override val id: String, - cameraPosition: MapCameraPosition = MapCameraPosition.Default, -) : MapViewState(cameraPosition), - OpenMobileMapsViewStateInterface { - private var controller: OpenMobileMapsMapViewController? = null - private var current: OpenMobileMapsMapDesignTypeInterface = mapDesignType - - override var mapDesignType: OpenMobileMapsMapDesignTypeInterface - get() = current - set(value) { - current = value - controller?.setMapDesignType(value) - } - - internal fun setController(controller: OpenMobileMapsMapViewController) { - this.controller = controller - attachController(controller) - } - - internal fun onMapDesignTypeChange(value: OpenMobileMapsMapDesignTypeInterface) { - current = value - } - - /** 戻り型をこのプロバイダのホルダーへ絞る(アプリが `?.map` を取れる形を保つため)。 */ - override fun getMapViewHolder(): OpenMobileMapsMapViewHolder? = - super.getMapViewHolder() as? OpenMobileMapsMapViewHolder - - internal fun updateCameraPosition(cameraPosition: MapCameraPosition) { - setCameraPositionInternal(cameraPosition) - } -} - -class OpenMobileMapsMapViewSaver : BaseMapViewSaver() { - override fun saveMapDesign( - state: OpenMobileMapsViewState, - bundle: Bundle, - ) { - bundle.putString("id", state.mapDesignType.id) - bundle.putString("tileUrlTemplate", state.mapDesignType.tileUrlTemplate) - bundle.putInt("tileSize", state.mapDesignType.tileSize) - } - - override fun createState( - stateId: String, - mapDesignBundle: Bundle?, - cameraPosition: MapCameraPosition, - ): OpenMobileMapsViewState = - OpenMobileMapsViewState( - id = stateId, - mapDesignType = - OpenMobileMapsDesign( - id = mapDesignBundle?.getString("id") ?: OpenMobileMapsDesign.OpenStreetMap.id, - tileUrlTemplate = - mapDesignBundle?.getString("tileUrlTemplate") - ?: OpenMobileMapsDesign.OpenStreetMap.tileUrlTemplate, - tileSize = mapDesignBundle?.getInt("tileSize")?.takeIf { it > 0 } ?: 256, - ), - cameraPosition = cameraPosition, - ) - - override fun getStateId(state: OpenMobileMapsViewState): String = state.id -} - -@Composable -fun rememberOpenMobileMapsMapViewState( - mapDesign: OpenMobileMapsMapDesignTypeInterface = OpenMobileMapsDesign.OpenStreetMap, - cameraPosition: MapCameraPositionInterface = MapCameraPosition.Default, -): OpenMobileMapsViewState { - val stateId by rememberSaveable { mutableStateOf(UUID.randomUUID().toString()) } - val state = - rememberSaveable(stateSaver = OpenMobileMapsMapViewSaver().createSaver()) { - mutableStateOf( - OpenMobileMapsViewState( - id = stateId, - mapDesignType = mapDesign, - cameraPosition = MapCameraPosition.from(cameraPosition), - ), - ) - } - return state.value -} - -/** - * 地図の Composable。 - * - * ## SDK 固有の必須手順が 2 つある - * - * 1. [MapsCore.initialize] … ネイティブライブラリを読む。忘れると `UnsatisfiedLinkError`。 - * 2. [MapView.registerLifecycle] … レンダリングループを回す。**忘れると地図が真っ黒のまま** - * エラーも出ない。 - */ -@Composable -fun OpenMobileMapsMapView( - state: OpenMobileMapsViewState, - modifier: Modifier = Modifier, - markerTiling: MarkerTilingOptions? = null, - cameraRestriction: CameraRestriction? = null, - sdkInitialize: (suspend (Context) -> Boolean)? = null, - onMapLoaded: OnMapLoadedHandler? = null, - onMapClick: OnMapEventHandler? = null, - onMapLongClick: OnMapEventHandler? = null, - onCameraMoveStart: OnCameraMoveHandler? = null, - onCameraMove: OnCameraMoveHandler? = null, - onCameraMoveEnd: OnCameraMoveHandler? = null, - /** - * How the map looks, when the app states it rather than naming a design. - * - * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds - * one; what happens underneath depends on this backend and the app does - * not have to know. - */ - style: MapViewStyle? = null, - onStyleDiagnostics: ((List) -> Unit)? = null, - content: (@Composable OpenMobileMapsMapViewScope.() -> Unit)? = null, -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val scope = remember { OpenMobileMapsMapViewScope() } - val registry = remember { scope.buildRegistry() } - val cameraState = remember { mutableStateOf(state.cameraPosition) } - val loaders = - remember { - arrayListOf( - DataLoader( - context, - context.cacheDir, - CACHE_BYTES, - "", - USER_AGENT, - listOf(emptyLocalTileInterceptor()), - emptyList(), - ), - ) - } - - MapViewBase( - state = state, - cameraState = cameraState, - modifier = modifier, - viewProvider = { - OpenMobileMapsMapSurface(context).apply { - val inner = MapView(context) - mapView = inner - addView( - inner, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), - ) - // 地図は EPSG:3857 で構成する(タイルがすべて 3857 で、ズームの縮尺の - // 導出も「地図単位 = メルカトルメートル」を前提にしているため)。 - // - // 密度は densityDpi を渡すこと。SDK のサンプルは xdpi(実測の物理 dpi)を - // 渡すが、それだと端末ごとに縮尺が数 % ずれ、Google Maps と大きさが揃わない。 - inner.setupMap( - MapConfig(CoordinateSystemFactory.getEpsg3857System()), - context.resources.displayMetrics.densityDpi - .toFloat(), - false, - false, - ) - inner.registerLifecycle(lifecycleOwner.lifecycle) - } - }, - scope = scope, - registry = registry, - onMapLoaded = onMapLoaded, - holderProvider = { surface -> - // setupMap は非同期に GL スレッドで完了する。INITIALIZED になる前に - // レイヤを足すと落ちるので待つ。 - surface.mapView.mapViewState.first { it != OmmMapViewState.UNINITIALIZED } - OpenMobileMapsMapViewHolder(surface, surface.mapView.requireMapInterface()) - }, - controllerProvider = { holder -> - createOpenMobileMapsViewController( - holder = holder, - loaders = loaders, - density = context.resources.displayMetrics.density, - markerTiling = markerTiling ?: MarkerTilingOptions.Default, - serviceRegistry = state.serviceRegistry, - ).also { mapController -> - mapController.setMapDesignType(state.mapDesignType) - mapController.installListeners() - mapController.setCameraMoveStartListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveStart?.invoke(it) - } - mapController.setCameraMoveListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMove?.invoke(it) - } - mapController.setCameraMoveEndListener { - cameraState.value = it - state.updateCameraPosition(it) - onCameraMoveEnd?.invoke(it) - } - mapController.setMapClickListener(onMapClick) - mapController.setMapLongClickListener(onMapLongClick) - mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) - cameraRestriction?.let { mapController.setCameraRestriction(it) } - mapController.moveCamera(MapCameraPosition.from(state.cameraPosition)) - state.setController(mapController) - holder.mapView.post { mapController.sendInitialCameraUpdate() } - } - }, - sdkInitialize = { - if (sdkInitialize != null) { - sdkInitialize(context) - } else { - MapsCore.initialize() - true - } - }, - style = style, - onStyleDiagnostics = onStyleDiagnostics, - content = content, - ) -} - -/** - * 命令的なコントローラ一式を組み立てる。Compose からも、React Native のような - * 非 Compose ホストからも同じものを使えるようにここに置く。 - */ -fun createOpenMobileMapsViewController( - holder: OpenMobileMapsMapViewHolder, - loaders: ArrayList, - density: Float, - markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, - serviceRegistry: MutableMapServiceRegistry? = null, -): OpenMobileMapsMapViewController { - val layers = OpenMobileMapsLayers() - layers.attachTo(holder.map) - - val markerRenderer = - OpenMobileMapsMarkerOverlayRenderer( - holder = holder, - markerManager = MarkerManager.defaultManager(), - iconLayer = layers.iconLayer, - ) - - val mapController = - OpenMobileMapsMapViewController( - holder = holder, - layers = layers, - markerController = OpenMobileMapsMarkerController(markerRenderer, markerTiling), - polylineController = - OpenMobileMapsPolylineController( - OpenMobileMapsPolylineOverlayRenderer( - holder = holder, - polylineManager = PolylineManager(), - lineLayer = layers.polylineLayer, - ), - ), - polygonController = - OpenMobileMapsPolygonController( - OpenMobileMapsPolygonOverlayRenderer( - holder = holder, - polygonManager = PolygonManager(), - fillLayer = layers.polygonFillLayer, - outlineLayer = layers.polygonOutlineLayer, - ), - ), - circleController = - OpenMobileMapsCircleController( - OpenMobileMapsCircleOverlayRenderer( - holder = holder, - circleManager = CircleManager(), - fillLayer = layers.circleFillLayer, - outlineLayer = layers.circleOutlineLayer, - ), - ), - groundImageController = - OpenMobileMapsGroundImageController( - OpenMobileMapsGroundImageOverlayRenderer(holder = holder, layers = layers), - ), - rasterLayerController = - OpenMobileMapsRasterLayerController( - OpenMobileMapsRasterLayerOverlayRenderer( - holder = holder, - layers = layers, - loaders = loaders, - ), - ), - loaders = loaders, - density = density, - ) - - serviceRegistry?.let { serviceRegistryTarget -> - mapController.declareCapabilities(serviceRegistryTarget) - serviceRegistryTarget.put( - MarkerRenderingSupportKey, - object : MarkerRenderingSupport { - override fun createMarkerRenderer( - strategy: MarkerRenderingStrategyInterface, - ): MarkerOverlayRendererInterface = - mapController.createMarkerRenderer(strategy) - - override fun createMarkerEventController( - controller: StrategyMarkerController, - renderer: MarkerOverlayRendererInterface, - ): MarkerEventControllerInterface = - mapController.createMarkerEventController(controller, renderer) - - override fun registerMarkerEventController( - controller: MarkerEventControllerInterface, - ) { - mapController.registerMarkerEventController(controller) - } - - override fun onMarkerRenderingReady() { - mapController.sendInitialCameraUpdate() - } - }, - ) - } - - return mapController -} - -/** - * ローカルタイルサーバの 404 を「透明な 1x1 PNG の 200」へ書き換える。 - * - * ## なぜ要るのか - * - * マーカータイルのローカルサーバは、マーカーが 1 つも無いタイルに 404 を返す - * (MapLibre などはそれを「空タイル」として扱うため問題にならない)。 - * ところが Open Mobile Maps は 404 を **エラー** として扱い、そのタイルを - * 「存在しない」と記録する。子タイルが存在しない領域はステンシルマスクに - * **穴が開き、保持されている粗い親タイルがそこだけ透けて見え続ける** - * (2-6 の対策後も、マーカーの無い領域だけ巨大な赤い矩形が残る、という形で出た)。 - * - * ## 204 ではだめ - * - * SDK の `DataLoader` には 204 = 空タイルの分岐があるが、判定は - * `isSuccessful && bytes != null` が**先**に走る。204 は 2xx で本文も空配列として - * 読めてしまうので、その分岐には届かず「デコード失敗」のエラーになる。 - * 実際に描ける**本物の透明 PNG** を 200 で返すのが確実で、通常の読み込み経路を - * そのまま通る(1x1 の透明テクスチャがタイル全面に伸びるだけで、見た目には何も出ない)。 - */ -private fun emptyLocalTileInterceptor(): okhttp3.Interceptor = - okhttp3.Interceptor { chain -> - val response = chain.proceed(chain.request()) - val host = chain.request().url.host - if (response.code == 404 && (host == "127.0.0.1" || host == "localhost")) { - response.close() - okhttp3.Response - .Builder() - .request(chain.request()) - .protocol(okhttp3.Protocol.HTTP_1_1) - .code(200) - .message("OK") - .body(transparentTilePng.toResponseBody("image/png".toMediaType())) - .build() - } else { - response - } - } - -/** 空タイルとして返す透明 1x1 PNG。 */ -private val transparentTilePng: ByteArray by lazy { - val bitmap = android.graphics.Bitmap.createBitmap(1, 1, android.graphics.Bitmap.Config.ARGB_8888) - java.io.ByteArrayOutputStream().use { stream -> - bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) - bitmap.recycle() - stream.toByteArray() - } -} - -/** タイルのディスクキャッシュ。他プロバイダの既定と揃えてある。 */ -private const val CACHE_BYTES: Long = 50L * 1024 * 1024 -private const val USER_AGENT = "MapConductor" +package com.mapconductor.openmobilemaps + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.mapconductor.compose.map.BaseMapViewSaver +import com.mapconductor.compose.map.MapViewBase +import com.mapconductor.core.OnCameraMoveHandler +import com.mapconductor.core.OnMapEventHandler +import com.mapconductor.core.OnMapLoadedHandler +import com.mapconductor.core.circle.CircleManager +import com.mapconductor.core.map.CameraRestriction +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapCameraPositionInterface +import com.mapconductor.core.map.MapViewState +import com.mapconductor.core.map.MapViewStateInterface +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.map.MutableMapServiceRegistry +import com.mapconductor.core.marker.MarkerEventControllerInterface +import com.mapconductor.core.marker.MarkerManager +import com.mapconductor.core.marker.MarkerOverlayRendererInterface +import com.mapconductor.core.marker.MarkerRenderingStrategyInterface +import com.mapconductor.core.marker.MarkerRenderingSupport +import com.mapconductor.core.marker.MarkerRenderingSupportKey +import com.mapconductor.core.marker.MarkerTilingOptions +import com.mapconductor.core.marker.StrategyMarkerController +import com.mapconductor.core.polygon.PolygonManager +import com.mapconductor.core.polyline.PolylineManager +import com.mapconductor.openmobilemaps.marker.OpenMobileMapsMarkerController +import io.openmobilemaps.mapscore.MapsCore +import io.openmobilemaps.mapscore.map.loader.DataLoader +import io.openmobilemaps.mapscore.map.view.MapView +import io.openmobilemaps.mapscore.map.view.MapViewState as OmmMapViewState +import io.openmobilemaps.mapscore.shared.map.MapConfig +import io.openmobilemaps.mapscore.shared.map.coordinates.CoordinateSystemFactory +import io.openmobilemaps.mapscore.shared.map.loader.LoaderInterface +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import java.util.UUID +import android.content.Context +import android.os.Bundle +import android.widget.FrameLayout +import kotlinx.coroutines.flow.first + +interface OpenMobileMapsViewStateInterface : MapViewStateInterface + +/** + * アプリ側の状態。 + * + * カメラの保持・[MapViewStateInterface.moveCameraTo]・`fitBounds` はコアの + * [MapViewState] が持つ。ドライバーが書くのは `id` / `mapDesignType` と、 + * 共変の [getMapViewHolder](**1 行だが消さないこと**。消すとアプリ側の + * `state.getMapViewHolder()?.map` が静的型を失いソース非互換になる)だけ。 + */ +class OpenMobileMapsViewState( + mapDesignType: OpenMobileMapsMapDesignTypeInterface, + override val id: String, + cameraPosition: MapCameraPosition = MapCameraPosition.Default, +) : MapViewState(cameraPosition), + OpenMobileMapsViewStateInterface { + private var controller: OpenMobileMapsMapViewController? = null + private var current: OpenMobileMapsMapDesignTypeInterface = mapDesignType + + override var mapDesignType: OpenMobileMapsMapDesignTypeInterface + get() = current + set(value) { + current = value + controller?.setMapDesignType(value) + } + + internal fun setController(controller: OpenMobileMapsMapViewController) { + this.controller = controller + attachController(controller) + } + + internal fun onMapDesignTypeChange(value: OpenMobileMapsMapDesignTypeInterface) { + current = value + } + + /** 戻り型をこのプロバイダのホルダーへ絞る(アプリが `?.map` を取れる形を保つため)。 */ + override fun getMapViewHolder(): OpenMobileMapsMapViewHolder? = + super.getMapViewHolder() as? OpenMobileMapsMapViewHolder + + internal fun updateCameraPosition(cameraPosition: MapCameraPosition) { + setCameraPositionInternal(cameraPosition) + } +} + +class OpenMobileMapsMapViewSaver : BaseMapViewSaver() { + override fun saveMapDesign( + state: OpenMobileMapsViewState, + bundle: Bundle, + ) { + bundle.putString("id", state.mapDesignType.id) + bundle.putString("tileUrlTemplate", state.mapDesignType.tileUrlTemplate) + bundle.putInt("tileSize", state.mapDesignType.tileSize) + } + + override fun createState( + stateId: String, + mapDesignBundle: Bundle?, + cameraPosition: MapCameraPosition, + ): OpenMobileMapsViewState = + OpenMobileMapsViewState( + id = stateId, + mapDesignType = + OpenMobileMapsDesign( + id = mapDesignBundle?.getString("id") ?: OpenMobileMapsDesign.OpenStreetMap.id, + tileUrlTemplate = + mapDesignBundle?.getString("tileUrlTemplate") + ?: OpenMobileMapsDesign.OpenStreetMap.tileUrlTemplate, + tileSize = mapDesignBundle?.getInt("tileSize")?.takeIf { it > 0 } ?: 256, + ), + cameraPosition = cameraPosition, + ) + + override fun getStateId(state: OpenMobileMapsViewState): String = state.id +} + +@Composable +fun rememberOpenMobileMapsMapViewState( + mapDesign: OpenMobileMapsMapDesignTypeInterface = OpenMobileMapsDesign.OpenStreetMap, + cameraPosition: MapCameraPositionInterface = MapCameraPosition.Default, +): OpenMobileMapsViewState { + val stateId by rememberSaveable { mutableStateOf(UUID.randomUUID().toString()) } + val state = + rememberSaveable(stateSaver = OpenMobileMapsMapViewSaver().createSaver()) { + mutableStateOf( + OpenMobileMapsViewState( + id = stateId, + mapDesignType = mapDesign, + cameraPosition = MapCameraPosition.from(cameraPosition), + ), + ) + } + return state.value +} + +/** + * 地図の Composable。 + * + * ## SDK 固有の必須手順が 2 つある + * + * 1. [MapsCore.initialize] … ネイティブライブラリを読む。忘れると `UnsatisfiedLinkError`。 + * 2. [MapView.registerLifecycle] … レンダリングループを回す。**忘れると地図が真っ黒のまま** + * エラーも出ない。 + */ +@Composable +fun OpenMobileMapsMapView( + state: OpenMobileMapsViewState, + modifier: Modifier = Modifier, + markerTiling: MarkerTilingOptions? = null, + cameraRestriction: CameraRestriction? = null, + sdkInitialize: (suspend (Context) -> Boolean)? = null, + onMapLoaded: OnMapLoadedHandler? = null, + onMapClick: OnMapEventHandler? = null, + onMapLongClick: OnMapEventHandler? = null, + onCameraMoveStart: OnCameraMoveHandler? = null, + onCameraMove: OnCameraMoveHandler? = null, + onCameraMoveEnd: OnCameraMoveHandler? = null, + /** + * How the map looks, when the app states it rather than naming a design. + * + * A vector style *is* the basemap. `com.mapconductor:vectorstyle` builds + * one; what happens underneath depends on this backend and the app does + * not have to know. + */ + style: MapViewStyle? = null, + onStyleDiagnostics: ((List) -> Unit)? = null, + content: (@Composable OpenMobileMapsMapViewScope.() -> Unit)? = null, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val scope = remember { OpenMobileMapsMapViewScope() } + val registry = remember { scope.buildRegistry() } + val cameraState = remember { mutableStateOf(state.cameraPosition) } + val loaders = + remember { + arrayListOf( + DataLoader( + context, + context.cacheDir, + CACHE_BYTES, + "", + USER_AGENT, + listOf(emptyLocalTileInterceptor()), + emptyList(), + ), + ) + } + + MapViewBase( + state = state, + cameraState = cameraState, + modifier = modifier, + viewProvider = { + OpenMobileMapsMapSurface(context).apply { + val inner = MapView(context) + mapView = inner + addView( + inner, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + // 地図は EPSG:3857 で構成する(タイルがすべて 3857 で、ズームの縮尺の + // 導出も「地図単位 = メルカトルメートル」を前提にしているため)。 + // + // 密度は densityDpi を渡すこと。SDK のサンプルは xdpi(実測の物理 dpi)を + // 渡すが、それだと端末ごとに縮尺が数 % ずれ、Google Maps と大きさが揃わない。 + inner.setupMap( + MapConfig(CoordinateSystemFactory.getEpsg3857System()), + context.resources.displayMetrics.densityDpi + .toFloat(), + false, + false, + ) + inner.registerLifecycle(lifecycleOwner.lifecycle) + } + }, + scope = scope, + registry = registry, + onMapLoaded = onMapLoaded, + holderProvider = { surface -> + // setupMap は非同期に GL スレッドで完了する。INITIALIZED になる前に + // レイヤを足すと落ちるので待つ。 + surface.mapView.mapViewState.first { it != OmmMapViewState.UNINITIALIZED } + OpenMobileMapsMapViewHolder(surface, surface.mapView.requireMapInterface()) + }, + controllerProvider = { holder -> + createOpenMobileMapsViewController( + holder = holder, + loaders = loaders, + density = context.resources.displayMetrics.density, + markerTiling = markerTiling ?: MarkerTilingOptions.Default, + serviceRegistry = state.serviceRegistry, + ).also { mapController -> + mapController.setMapDesignType(state.mapDesignType) + mapController.installListeners() + mapController.setCameraMoveStartListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveStart?.invoke(it) + } + mapController.setCameraMoveListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMove?.invoke(it) + } + mapController.setCameraMoveEndListener { + cameraState.value = it + state.updateCameraPosition(it) + onCameraMoveEnd?.invoke(it) + } + mapController.setMapClickListener(onMapClick) + mapController.setMapLongClickListener(onMapLongClick) + mapController.setMapDesignTypeChangeListener(state::onMapDesignTypeChange) + cameraRestriction?.let { mapController.setCameraRestriction(it) } + mapController.moveCamera(MapCameraPosition.from(state.cameraPosition)) + state.setController(mapController) + holder.mapView.post { mapController.sendInitialCameraUpdate() } + } + }, + sdkInitialize = { + if (sdkInitialize != null) { + sdkInitialize(context) + } else { + MapsCore.initialize() + true + } + }, + style = style, + onStyleDiagnostics = onStyleDiagnostics, + content = content, + ) +} + +/** + * 命令的なコントローラ一式を組み立てる。Compose からも、React Native のような + * 非 Compose ホストからも同じものを使えるようにここに置く。 + */ +fun createOpenMobileMapsViewController( + holder: OpenMobileMapsMapViewHolder, + loaders: ArrayList, + density: Float, + markerTiling: MarkerTilingOptions = MarkerTilingOptions.Default, + serviceRegistry: MutableMapServiceRegistry? = null, +): OpenMobileMapsMapViewController { + val layers = OpenMobileMapsLayers() + layers.attachTo(holder.map) + + val markerRenderer = + OpenMobileMapsMarkerOverlayRenderer( + holder = holder, + markerManager = MarkerManager.defaultManager(), + iconLayer = layers.iconLayer, + ) + + val mapController = + OpenMobileMapsMapViewController( + holder = holder, + layers = layers, + markerController = OpenMobileMapsMarkerController(markerRenderer, markerTiling), + polylineController = + OpenMobileMapsPolylineController( + OpenMobileMapsPolylineOverlayRenderer( + holder = holder, + polylineManager = PolylineManager(), + lineLayer = layers.polylineLayer, + ), + ), + polygonController = + OpenMobileMapsPolygonController( + OpenMobileMapsPolygonOverlayRenderer( + holder = holder, + polygonManager = PolygonManager(), + fillLayer = layers.polygonFillLayer, + outlineLayer = layers.polygonOutlineLayer, + ), + ), + circleController = + OpenMobileMapsCircleController( + OpenMobileMapsCircleOverlayRenderer( + holder = holder, + circleManager = CircleManager(), + fillLayer = layers.circleFillLayer, + outlineLayer = layers.circleOutlineLayer, + ), + ), + groundImageController = + OpenMobileMapsGroundImageController( + OpenMobileMapsGroundImageOverlayRenderer(holder = holder, layers = layers), + ), + rasterLayerController = + OpenMobileMapsRasterLayerController( + OpenMobileMapsRasterLayerOverlayRenderer( + holder = holder, + layers = layers, + loaders = loaders, + ), + ), + loaders = loaders, + density = density, + ) + + serviceRegistry?.let { serviceRegistryTarget -> + mapController.declareCapabilities(serviceRegistryTarget) + serviceRegistryTarget.put( + MarkerRenderingSupportKey, + object : MarkerRenderingSupport { + override fun createMarkerRenderer( + strategy: MarkerRenderingStrategyInterface, + ): MarkerOverlayRendererInterface = + mapController.createMarkerRenderer(strategy) + + override fun createMarkerEventController( + controller: StrategyMarkerController, + renderer: MarkerOverlayRendererInterface, + ): MarkerEventControllerInterface = + mapController.createMarkerEventController(controller, renderer) + + override fun registerMarkerEventController( + controller: MarkerEventControllerInterface, + ) { + mapController.registerMarkerEventController(controller) + } + + override fun onMarkerRenderingReady() { + mapController.sendInitialCameraUpdate() + } + }, + ) + } + + return mapController +} + +/** + * ローカルタイルサーバの 404 を「透明な 1x1 PNG の 200」へ書き換える。 + * + * ## なぜ要るのか + * + * マーカータイルのローカルサーバは、マーカーが 1 つも無いタイルに 404 を返す + * (MapLibre などはそれを「空タイル」として扱うため問題にならない)。 + * ところが Open Mobile Maps は 404 を **エラー** として扱い、そのタイルを + * 「存在しない」と記録する。子タイルが存在しない領域はステンシルマスクに + * **穴が開き、保持されている粗い親タイルがそこだけ透けて見え続ける** + * (2-6 の対策後も、マーカーの無い領域だけ巨大な赤い矩形が残る、という形で出た)。 + * + * ## 204 ではだめ + * + * SDK の `DataLoader` には 204 = 空タイルの分岐があるが、判定は + * `isSuccessful && bytes != null` が**先**に走る。204 は 2xx で本文も空配列として + * 読めてしまうので、その分岐には届かず「デコード失敗」のエラーになる。 + * 実際に描ける**本物の透明 PNG** を 200 で返すのが確実で、通常の読み込み経路を + * そのまま通る(1x1 の透明テクスチャがタイル全面に伸びるだけで、見た目には何も出ない)。 + */ +private fun emptyLocalTileInterceptor(): okhttp3.Interceptor = + okhttp3.Interceptor { chain -> + val response = chain.proceed(chain.request()) + val host = chain.request().url.host + if (response.code == 404 && (host == "127.0.0.1" || host == "localhost")) { + response.close() + okhttp3.Response + .Builder() + .request(chain.request()) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(transparentTilePng.toResponseBody("image/png".toMediaType())) + .build() + } else { + response + } + } + +/** 空タイルとして返す透明 1x1 PNG。 */ +private val transparentTilePng: ByteArray by lazy { + val bitmap = android.graphics.Bitmap.createBitmap(1, 1, android.graphics.Bitmap.Config.ARGB_8888) + java.io.ByteArrayOutputStream().use { stream -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, stream) + bitmap.recycle() + stream.toByteArray() + } +} + +/** タイルのディスクキャッシュ。他プロバイダの既定と揃えてある。 */ +private const val CACHE_BYTES: Long = 50L * 1024 * 1024 +private const val USER_AGENT = "MapConductor" diff --git a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt index b779867..fda852b 100644 --- a/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt +++ b/src/main/java/com/mapconductor/openmobilemaps/OpenMobileMapsTiltEmulation.kt @@ -1,96 +1,96 @@ -package com.mapconductor.openmobilemaps - -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter -import com.mapconductor.openmobilemaps.zoom.ZoomAltitudeConverter -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.tan - -/** - * Open Mobile Maps の 2D カメラ向けの tilt 擬似表現。 - * - * ## なぜ擬似表現なのか - * - * [io.openmobilemaps.mapscore.shared.map.MapCameraInterface] にピッチが無い。 - * [io.openmobilemaps.mapscore.shared.map.MapCamera3dInterface] なら - * `setPoseCamera` で本当に傾けられるが、それは `setupMap(..., is3D = true)` で - * **地球儀表示**にしたときだけ手に入るもので、平面地図のまま傾けることはできない。 - * - * そこで android-for-arcgis の 2D(`ArcGIS2DTiltEmulation`)と**同じ方式・同じ定数**を使う: - * - * - 遠近感は [OpenMobileMapsMapSurface] がビューを X 軸まわりに回して作る - * - カメラ位置の付け替えはここが受け持つ - * - * ## tilt の符号 - * - * - `tilt >= 0`: 指定位置は**ターゲット**(画面中心)。カメラが後方へ下がるだけなので - * 中心もズームも動かさない。 - * - `tilt < 0`: 指定位置は**カメラ位置**。ターゲットが進行方向(bearing)へ前進する。 - * 前進量とズームオフセットは MapLibre / TomTom / Leaflet / ArcGIS2D と同一の式・同一定数。 - */ -internal object OpenMobileMapsTiltEmulation { - /** MapLibre / TomTom / Leaflet / ArcGIS2D と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ - private const val TARGET_DISTANCE_SCALE = 1.83 - private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 - const val MAX_TILT_DEGREES = 60.0 - - /** - * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 - * ArcGIS2D と同じ理由で、ここを触るとシフト量が他プロバイダとずれる。 - */ - private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) - - /** 論理カメラ → 実際に SDK へ渡す中心・統一ズーム。 */ - fun shiftedCamera(position: MapCameraPosition): Pair { - if (position.tilt >= 0) return position.position to position.zoom - - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, MAX_TILT_DEGREES) - val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = altitudeFor(position.zoom, position.position.latitude) - val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val target = - Spherical - .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return target to zoom - } - - /** SDK から読み戻した中心・統一ズームを論理カメラへ戻す。 */ - fun restoreLogicalCamera( - center: GeoPointInterface, - zoom: Double, - bearing: Double, - logicalTilt: Double, - ): Pair { - val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, MAX_TILT_DEGREES) - if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom - - val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - val altitude = altitudeFor(originalZoom, center.latitude) - val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE - val originalPosition: GeoPoint = - Spherical.computeOffset( - center, distanceBackward, - CameraBearing.toNativeHeading(bearing) + 180.0, - ) - return originalPosition to originalZoom - } - - /** - * 統一ズームでの視距離。 - * - * [ZoomAltitudeConverter.zoomLevelToAltitude] は SDK の縮尺を受け取る形なので、 - * ここでは統一ズームを一度縮尺へ戻してから渡す(他プロバイダの - * `converter.zoomLevelToAltitude(zoom, ...)` と同じ意味になる)。 - */ - private fun altitudeFor( - unifiedZoom: Double, - latitude: Double, - ): Double = converter.zoomLevelToAltitude(converter.toNativeZoom(unifiedZoom), latitude, 0.0) -} +package com.mapconductor.openmobilemaps + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import com.mapconductor.core.zoom.AbstractZoomAltitudeConverter +import com.mapconductor.openmobilemaps.zoom.ZoomAltitudeConverter +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.tan + +/** + * Open Mobile Maps の 2D カメラ向けの tilt 擬似表現。 + * + * ## なぜ擬似表現なのか + * + * [io.openmobilemaps.mapscore.shared.map.MapCameraInterface] にピッチが無い。 + * [io.openmobilemaps.mapscore.shared.map.MapCamera3dInterface] なら + * `setPoseCamera` で本当に傾けられるが、それは `setupMap(..., is3D = true)` で + * **地球儀表示**にしたときだけ手に入るもので、平面地図のまま傾けることはできない。 + * + * そこで android-for-arcgis の 2D(`ArcGIS2DTiltEmulation`)と**同じ方式・同じ定数**を使う: + * + * - 遠近感は [OpenMobileMapsMapSurface] がビューを X 軸まわりに回して作る + * - カメラ位置の付け替えはここが受け持つ + * + * ## tilt の符号 + * + * - `tilt >= 0`: 指定位置は**ターゲット**(画面中心)。カメラが後方へ下がるだけなので + * 中心もズームも動かさない。 + * - `tilt < 0`: 指定位置は**カメラ位置**。ターゲットが進行方向(bearing)へ前進する。 + * 前進量とズームオフセットは MapLibre / TomTom / Leaflet / ArcGIS2D と同一の式・同一定数。 + */ +internal object OpenMobileMapsTiltEmulation { + /** MapLibre / TomTom / Leaflet / ArcGIS2D と同一値。プロバイダ間で挙動を揃えるため変えないこと。 */ + private const val TARGET_DISTANCE_SCALE = 1.83 + private const val ZOOM_OFFSET_AT_MAX_TILT = -0.9 + const val MAX_TILT_DEGREES = 60.0 + + /** + * 高度の算出にはプラットフォーム非依存の既定値(Google Maps 較正)を使う。 + * ArcGIS2D と同じ理由で、ここを触るとシフト量が他プロバイダとずれる。 + */ + private val converter = ZoomAltitudeConverter(AbstractZoomAltitudeConverter.DEFAULT_ZOOM0_ALTITUDE) + + /** 論理カメラ → 実際に SDK へ渡す中心・統一ズーム。 */ + fun shiftedCamera(position: MapCameraPosition): Pair { + if (position.tilt >= 0) return position.position to position.zoom + + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, MAX_TILT_DEGREES) + val zoom = position.zoom + ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = altitudeFor(position.zoom, position.position.latitude) + val distanceForward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return target to zoom + } + + /** SDK から読み戻した中心・統一ズームを論理カメラへ戻す。 */ + fun restoreLogicalCamera( + center: GeoPointInterface, + zoom: Double, + bearing: Double, + logicalTilt: Double, + ): Pair { + val tiltAbsDeg = abs(logicalTilt).coerceIn(0.0, MAX_TILT_DEGREES) + if (logicalTilt >= 0 || tiltAbsDeg == 0.0) return center to zoom + + val originalZoom = zoom - ZOOM_OFFSET_AT_MAX_TILT * (tiltAbsDeg / MAX_TILT_DEGREES) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + val altitude = altitudeFor(originalZoom, center.latitude) + val distanceBackward = altitude * cos(tiltAbsRad) * tan(tiltAbsRad) * TARGET_DISTANCE_SCALE + val originalPosition: GeoPoint = + Spherical.computeOffset( + center, distanceBackward, + CameraBearing.toNativeHeading(bearing) + 180.0, + ) + return originalPosition to originalZoom + } + + /** + * 統一ズームでの視距離。 + * + * [ZoomAltitudeConverter.zoomLevelToAltitude] は SDK の縮尺を受け取る形なので、 + * ここでは統一ズームを一度縮尺へ戻してから渡す(他プロバイダの + * `converter.zoomLevelToAltitude(zoom, ...)` と同じ意味になる)。 + */ + private fun altitudeFor( + unifiedZoom: Double, + latitude: Double, + ): Double = converter.zoomLevelToAltitude(converter.toNativeZoom(unifiedZoom), latitude, 0.0) +}