From 0e4813df2ec896c93490d4713acdc73859871ed4 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 10 Oct 2026 14:18:52 -0700 Subject: [PATCH 1/2] feat: integrate vector style support and pending map fixes --- api/android-for-longdo.api | 7 +- .../com/mapconductor/longdo/LongdoMapView.kt | 32 ++ .../longdo/LongdoMapViewController.kt | 58 +++- .../longdo/LongdoMapViewControllerCamera.kt | 232 ++++++++------- .../longdo/LongdoMapViewControllerRaster.kt | 281 +++++++++--------- .../longdo/LongdoMapViewHolder.kt | 140 ++++----- 6 files changed, 419 insertions(+), 331 deletions(-) diff --git a/api/android-for-longdo.api b/api/android-for-longdo.api index 90efe1f..f6e072b 100644 --- a/api/android-for-longdo.api +++ b/api/android-for-longdo.api @@ -106,6 +106,8 @@ public final class com.mapconductor.longdo.LongdoMapViewController extends com.m public void setOnMarkerClickListener(kotlin.jvm.functions.Function1); public java.lang.Object compositionRasterLayers(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object updateRasterLayer(com.mapconductor.core.raster.RasterLayerState, kotlin.coroutines.Continuation); + public void mountRasterLayer(com.mapconductor.core.raster.RasterLayerState); + public void unmountRasterLayer(java.lang.String); public boolean hasRasterLayer(com.mapconductor.core.raster.RasterLayerState); public boolean hasPolyline(com.mapconductor.core.polyline.PolylineState); public void setOnPolylineClickListener(kotlin.jvm.functions.Function1); @@ -149,6 +151,7 @@ public final class com.mapconductor.longdo.LongdoMapViewControllerRasterKt { public static final java.lang.String rasterSourceSpec(com.mapconductor.longdo.LongdoMapViewController, com.mapconductor.core.raster.RasterLayerSource); public static final java.lang.String rasterLayerSpec(com.mapconductor.longdo.LongdoMapViewController, java.lang.String, java.lang.String, float, boolean); public static final java.lang.String addRasterJs(com.mapconductor.longdo.LongdoMapViewController, java.lang.String, java.lang.String, java.lang.String, java.lang.String); + public static final java.lang.String readdRastersAfterStyleSwapJs(com.mapconductor.longdo.LongdoMapViewController, java.util.List); public static final java.lang.String removeRasterJs(com.mapconductor.longdo.LongdoMapViewController, java.lang.String); public static final java.lang.String setRasterOpacityJs(com.mapconductor.longdo.LongdoMapViewController, java.lang.String, float); public static final java.lang.String setRasterVisibilityJs(com.mapconductor.longdo.LongdoMapViewController, java.lang.String, boolean); @@ -169,8 +172,8 @@ public final class com.mapconductor.longdo.LongdoMapViewHolder implements com.ma public java.lang.Object getMap(); } public final class com.mapconductor.longdo.LongdoMapViewKt { - public static final void LongdoMapView(com.mapconductor.longdo.LongdoViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, 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 LongdoMapSurface(com.mapconductor.longdo.LongdoViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, 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.Function1, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); + public static final void LongdoMapView(com.mapconductor.longdo.LongdoViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, 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 void LongdoMapSurface(com.mapconductor.longdo.LongdoViewState, androidx.compose.ui.Modifier, com.mapconductor.core.marker.MarkerTilingOptions, com.mapconductor.core.map.CameraRestriction, 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.Function1, com.mapconductor.core.map.MapViewStyle, kotlin.jvm.functions.Function1, kotlin.Unit>, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer, int, int, int); } public final class com.mapconductor.longdo.LongdoMapViewSaver extends com.mapconductor.compose.map.BaseMapViewSaver { public static final java.lang.String KEY_DESIGN_ID; diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapView.kt b/src/main/java/com/mapconductor/longdo/LongdoMapView.kt index d32b4ed..d0e0f47 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapView.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapView.kt @@ -46,6 +46,8 @@ import com.mapconductor.core.map.MapCameraPosition import com.mapconductor.core.map.MapCapability import com.mapconductor.core.map.MapCapabilityStatus import com.mapconductor.core.map.MapServiceRegistrations +import com.mapconductor.core.map.MapViewStyle +import com.mapconductor.core.map.MapViewStyleEffect import com.mapconductor.core.map.VisibleRegion import com.mapconductor.core.marker.MarkerCapableInterface import com.mapconductor.core.marker.MarkerRenderingSupportKey @@ -88,6 +90,15 @@ fun LongdoMapView( 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 MapViewScope.() -> Unit)? = null, ) { LongdoMapSurface( @@ -101,6 +112,8 @@ fun LongdoMapView( onCameraMoveStart = onCameraMoveStart, onCameraMove = onCameraMove, onCameraMoveEnd = onCameraMoveEnd, + style = style, + onStyleDiagnostics = onStyleDiagnostics, content = content, ) } @@ -132,6 +145,15 @@ fun LongdoMapSurface( onCameraMove: OnCameraMoveHandler? = null, onCameraMoveEnd: OnCameraMoveHandler? = null, onControllerReady: ((LongdoMapViewController) -> Unit)? = 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 MapViewScope.() -> Unit)? = null, ) { val context = LocalContext.current @@ -206,6 +228,16 @@ fun LongdoMapSurface( onDispose { registrations.disposeAll() } } + // 同じ理由で、スタイルの設置もここで呼ぶ(MapViewBase を通るプロバイダは + // あちらで 1 回呼んでいる)。何が起きるかはバックエンドが宣言した能力で + // 決まるので、このプロバイダ固有の分岐は無い。 + MapViewStyleEffect( + state = state, + style = style, + controller = controller, + onDiagnostics = onStyleDiagnostics, + ) + // タイル経路に倒すかはコントローラが件数を見て決める(他プロバイダと同じ規則)。 // ここで null を Default へ正規化するのも maplibre / googlemaps / here と揃えてある。 controller.markerTilingOptions = diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewController.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewController.kt index 150438e..2161b8d 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewController.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewController.kt @@ -229,6 +229,11 @@ class LongdoMapViewController( dispatchCameraToOverlays() // Gesture JS issued before ready is dropped by the page; re-apply now. applyUISettings(appliedUISettings) + if (pendingMountedRasters.isNotEmpty()) { + val waiting = pendingMountedRasters.values.toList() + pendingMountedRasters.clear() + mainCoroutine.launch { waiting.forEach { applyRaster(it) } } + } } override fun setMapDesignType(value: LongdoMapDesignTypeInterface) { @@ -295,17 +300,62 @@ class LongdoMapViewController( override suspend fun compositionRasterLayers(data: List) { val newIds = data.map { it.id }.toSet() - appliedRasters.keys.filter { it !in newIds }.toList().forEach { id -> - runRasterJs(removeRasterJs(id)) - appliedRasters.remove(id) - } + appliedRasters.keys + .filter { it !in newIds && it !in mountedRasterIds } + .toList() + .forEach { id -> + runRasterJs(removeRasterJs(id)) + appliedRasters.remove(id) + } data.forEach { applyRaster(it) } } + /** + * content の外から載せたラスタの id。上の掃除から外すためにある。 + * + * `compositionRasterLayers` は「アプリが宣言した集合」で全置換するので、 + * 外から載せたものは次の再コンポーズで消える。他プロバイダでは + * [com.mapconductor.core.raster.RasterLayerController] の `upsert` が + * 同じ役割(`upsertedIds`)を持っている。 + */ + private val mountedRasterIds = mutableSetOf() + + /** [mountRasterLayer] が ready 前に受け取ったもの。 */ + private val pendingMountedRasters = mutableMapOf() + override suspend fun updateRasterLayer(state: RasterLayerState) { applyRaster(state) } + /** + * content の外から載せるラスタ(マーカータイル、ベクタースタイルのラスタ化)。 + * + * 他プロバイダは [com.mapconductor.core.raster.RasterLayerController] を + * オーバーレイコントローラとして登録してあるので基底の実装で足りるが、 + * Longdo は内部 MapLibre GL へ JS で直接足すのでそれが無い。 + * id は [mountedRasterIds] に控えて composition の掃除から外す。 + */ + override fun mountRasterLayer(state: RasterLayerState) { + mountedRasterIds.add(state.id) + // ready 前に流した JS はページに捨てられる(`runRasterJs` は握りつぶす)。 + // content 経由のラスタは composition が ready より後なので起きないが、 + // ビューの外から載せるものは地図より先に来る。退避して [onMapReady] で出す。 + if (!mapReady) { + pendingMountedRasters[state.id] = state + return + } + mainCoroutine.launch { applyRaster(state) } + } + + override fun unmountRasterLayer(id: String) { + mountedRasterIds.remove(id) + pendingMountedRasters.remove(id) + mainCoroutine.launch { + runRasterJs(removeRasterJs(id)) + appliedRasters.remove(id) + } + } + override fun hasRasterLayer(state: RasterLayerState): Boolean = appliedRasters.containsKey(state.id) // --- PolylineCapableInterface --- diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt index ca0e5c2..c75bcf9 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt @@ -1,115 +1,117 @@ -package com.mapconductor.longdo - -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import org.json.JSONObject -import kotlin.math.abs -import kotlin.math.tan - -// カメラの適用。Longdo のズームは MapConductor の論理ズームと 1 段ずれるため、 -// ZoomAltitudeConverter を通してから渡す。 - -/** - * カメラ停止時に [setCameraRestriction] の制限違反を補正する。補正したら true。 - * - * Longdo Map API3 には(Google の `setLatLngBoundsForCameraTarget` に相当する) - * カメラ範囲制限の JS API が無いため、android-sdk の HERE/ArcGIS/TomTom と同じく - * カメラ停止時に矩形内へクランプして再適用する方式で制限する。 - * 再適用すると再度カメライベントが発火し、そこでは補正不要になり通常フローへ進む。 - */ -internal fun LongdoMapViewController.applyCameraRestrictionCorrectionIfNeeded(current: MapCameraPosition): Boolean { - val corrected = correctForCameraRestriction(current) ?: return false - moveCamera(corrected) - return true -} - -internal fun LongdoMapViewController.handleMoveCamera(position: MapCameraPosition) { - if (mapReady) applyCamera(position) else pendingCameraPosition = position -} - -internal fun LongdoMapViewController.handleAnimateCamera( - position: MapCameraPosition, - duration: Long, -) { - // Longdo の JS ブリッジでは中心(location)とズーム(zoom)が別コマンドで、双方をアニメーション指定 - // すると後発のズームがパンのアニメーションを打ち切ってしまい中心が移動しない。カメラ同期では同期先が - // ソース側の連続イベントを追って滑らかに追従するため、ここでは瞬時に確定位置へ適用する。 - if (mapReady) applyCamera(position) else pendingCameraPosition = position -} - -/** - * Longdo の `map.location` / `map.zoom` / `map.rotate` / `map.pitch` を呼んでカメラを確定位置へ即時移動する。 - * - * - ズームは統一ズーム(Google)→ Longdo ネイティブズームへ変換して渡す。 - * - 方位([MapCameraPosition.bearing])は Longdo の `rotate`(北から時計回り=Google と同一規約)へ渡す。 - * - 傾き([MapCameraPosition.tilt])は Longdo の `pitch` へ渡す。負の傾き(仰角ビュー)は [nativeCameraFor] で - * 前進ターゲット+正 pitch に擬似変換する。 - * - * すべて `animate=false`(即時)で呼ぶ。中心・ズーム・方位・傾きのアニメーションは別コマンドのため、 - * いずれかをアニメーション指定すると後続コマンドが直前のアニメーションを打ち切ってしまう。 - */ -internal fun LongdoMapViewController.applyCamera(position: MapCameraPosition) { - val native = nativeCameraFor(position) - runCatching { - longdoMap.call("location", listOf(native.target.toLongdoLocation(), false)) {} - longdoMap.call("zoom", listOf(native.longdoZoom, false)) {} - longdoMap.call("rotate", listOf(CameraBearing.toNativeHeading(position.bearing), false)) {} - longdoMap.call("pitch", listOf(native.pitch)) {} - } -} - -/** Longdo ネイティブへ渡すカメラ(中心・ズーム・pitch)。 */ -internal data class NativeCamera( - val target: GeoPointInterface, - val longdoZoom: Double, - val pitch: Double, -) - -/** - * 統一カメラ([MapCameraPosition])を Longdo ネイティブのカメラへ変換する。 - * - * tilt ≥ 0 は中心・ズームをそのまま用い、pitch を 0〜[LongdoMapViewController.MAX_PITCH] にクランプする。 - * - * tilt < 0(水平線より上方を向く仰角ビュー)は Longdo(MapLibre GL)が上向き pitch を表現できないため、 - * Google Maps 実装と同方式で擬似再現する: カメラ eye を固定したまま、地面ターゲットを bearing 方向へ - * `altitude * tan(|tilt|)` メートル前進させ、`|tilt|` の下向き pitch で描画する。ズームは変更しない。 - */ -internal fun LongdoMapViewController.nativeCameraFor(position: MapCameraPosition): NativeCamera { - val longdoZoom = LongdoMapViewController.coreZoomToLongdo(position.zoom) - if (position.tilt >= 0.0) { - return NativeCamera( - position.position, longdoZoom, - position.tilt - .coerceIn(0.0, LongdoMapViewController.MAX_PITCH), - ) - } - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, LongdoMapViewController.MAX_PITCH) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - // 高度は統一ズーム(Google)基準で算出する(Longdo ネイティブズームではない)。 - val altitude = zoomConverter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) - val distanceForward = altitude * tan(tiltAbsRad) - val target = Spherical.computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return NativeCamera(target, longdoZoom, tiltAbsDeg) -} - -internal fun LongdoMapViewController.handleFitBounds( - bounds: GeoRectBounds, - padding: Int, -) { - val sw = bounds.southWest ?: return - val ne = bounds.northEast ?: return - val bound = - JSONObject() - .put("minLon", sw.longitude) - .put("minLat", sw.latitude) - .put("maxLon", ne.longitude) - .put("maxLat", ne.latitude) - // NOTE: Longdo Map API3 の map.bound(box) は境界矩形のみを引数に取り padding 相当の - // 余白パラメータを持たないため、padding は反映できない(矩形を膨らませる擬似対応は行わない)。 - if (mapReady) { - runCatching { longdoMap.call("bound", listOf(bound)) {} } - } -} +package com.mapconductor.longdo + +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.tan + +// カメラの適用。Longdo のズームは MapConductor の論理ズームと 1 段ずれるため、 +// ZoomAltitudeConverter を通してから渡す。 + +/** + * カメラ停止時に [setCameraRestriction] の制限違反を補正する。補正したら true。 + * + * Longdo Map API3 には(Google の `setLatLngBoundsForCameraTarget` に相当する) + * カメラ範囲制限の JS API が無いため、android-sdk の HERE/ArcGIS/TomTom と同じく + * カメラ停止時に矩形内へクランプして再適用する方式で制限する。 + * 再適用すると再度カメライベントが発火し、そこでは補正不要になり通常フローへ進む。 + */ +internal fun LongdoMapViewController.applyCameraRestrictionCorrectionIfNeeded(current: MapCameraPosition): Boolean { + val corrected = correctForCameraRestriction(current) ?: return false + moveCamera(corrected) + return true +} + +internal fun LongdoMapViewController.handleMoveCamera(position: MapCameraPosition) { + if (mapReady) applyCamera(position) else pendingCameraPosition = position +} + +internal fun LongdoMapViewController.handleAnimateCamera( + position: MapCameraPosition, + duration: Long, +) { + // Longdo の JS ブリッジでは中心(location)とズーム(zoom)が別コマンドで、双方をアニメーション指定 + // すると後発のズームがパンのアニメーションを打ち切ってしまい中心が移動しない。カメラ同期では同期先が + // ソース側の連続イベントを追って滑らかに追従するため、ここでは瞬時に確定位置へ適用する。 + if (mapReady) applyCamera(position) else pendingCameraPosition = position +} + +/** + * Longdo の `map.location` / `map.zoom` / `map.rotate` / `map.pitch` を呼んでカメラを確定位置へ即時移動する。 + * + * - ズームは統一ズーム(Google)→ Longdo ネイティブズームへ変換して渡す。 + * - 方位([MapCameraPosition.bearing])は Longdo の `rotate`(北から時計回り=Google と同一規約)へ渡す。 + * - 傾き([MapCameraPosition.tilt])は Longdo の `pitch` へ渡す。負の傾き(仰角ビュー)は [nativeCameraFor] で + * 前進ターゲット+正 pitch に擬似変換する。 + * + * すべて `animate=false`(即時)で呼ぶ。中心・ズーム・方位・傾きのアニメーションは別コマンドのため、 + * いずれかをアニメーション指定すると後続コマンドが直前のアニメーションを打ち切ってしまう。 + */ +internal fun LongdoMapViewController.applyCamera(position: MapCameraPosition) { + val native = nativeCameraFor(position) + runCatching { + longdoMap.call("location", listOf(native.target.toLongdoLocation(), false)) {} + longdoMap.call("zoom", listOf(native.longdoZoom, false)) {} + longdoMap.call("rotate", listOf(CameraBearing.toNativeHeading(position.bearing), false)) {} + longdoMap.call("pitch", listOf(native.pitch)) {} + } +} + +/** Longdo ネイティブへ渡すカメラ(中心・ズーム・pitch)。 */ +internal data class NativeCamera( + val target: GeoPointInterface, + val longdoZoom: Double, + val pitch: Double, +) + +/** + * 統一カメラ([MapCameraPosition])を Longdo ネイティブのカメラへ変換する。 + * + * tilt ≥ 0 は中心・ズームをそのまま用い、pitch を 0〜[LongdoMapViewController.MAX_PITCH] にクランプする。 + * + * tilt < 0(水平線より上方を向く仰角ビュー)は Longdo(MapLibre GL)が上向き pitch を表現できないため、 + * Google Maps 実装と同方式で擬似再現する: カメラ eye を固定したまま、地面ターゲットを bearing 方向へ + * `altitude * tan(|tilt|)` メートル前進させ、`|tilt|` の下向き pitch で描画する。ズームは変更しない。 + */ +internal fun LongdoMapViewController.nativeCameraFor(position: MapCameraPosition): NativeCamera { + val longdoZoom = LongdoMapViewController.coreZoomToLongdo(position.zoom) + if (position.tilt >= 0.0) { + return NativeCamera( + position.position, longdoZoom, + position.tilt + .coerceIn(0.0, LongdoMapViewController.MAX_PITCH), + ) + } + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, LongdoMapViewController.MAX_PITCH) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + // 高度は統一ズーム(Google)基準で算出する(Longdo ネイティブズームではない)。 + val altitude = zoomConverter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) + val distanceForward = altitude * tan(tiltAbsRad) + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return NativeCamera(target, longdoZoom, tiltAbsDeg) +} + +internal fun LongdoMapViewController.handleFitBounds( + bounds: GeoRectBounds, + padding: Int, +) { + val sw = bounds.southWest ?: return + val ne = bounds.northEast ?: return + val bound = + JSONObject() + .put("minLon", sw.longitude) + .put("minLat", sw.latitude) + .put("maxLon", ne.longitude) + .put("maxLat", ne.latitude) + // NOTE: Longdo Map API3 の map.bound(box) は境界矩形のみを引数に取り padding 相当の + // 余白パラメータを持たないため、padding は反映できない(矩形を膨らませる擬似対応は行わない)。 + if (mapReady) { + runCatching { longdoMap.call("bound", listOf(bound)) {} } + } +} diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt index bb55a8f..807d4e5 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt @@ -1,140 +1,141 @@ -package com.mapconductor.longdo - -import com.mapconductor.core.raster.RasterHeaderRuleSet -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import org.json.JSONArray -import org.json.JSONObject - -/** - * ラスターレイヤーの反映。 - * - * Longdo は WebView 上の JS 地図なので、レイヤーの追加・削除・不透明度は - * すべて JS 文字列を組み立てて流し込む形になる。ID は他レイヤーとぶつからない - * よう `mcrs_` / `mcrl_` を前置する。 - */ -internal fun LongdoMapViewController.applyRaster(state: RasterLayerState) { - RasterHeaderRuleSet.warnUnsupported(provider = "Longdo", state = state) - val srcId = rasterSourceId(state.id) - val layerId = rasterLayerId(state.id) - val spec = rasterSourceSpec(state.source) ?: return // 未対応ソースはスキップ - val prev = appliedRasters[state.id] - val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) - when { - prev == null -> - runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) - prev.source != state.source -> { - runRasterJs(removeRasterJs(state.id)) - runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) - } - else -> { - if (prev.opacity != state.opacity) runRasterJs(setRasterOpacityJs(layerId, state.opacity)) - if (prev.visible != state.visible) runRasterJs(setRasterVisibilityJs(layerId, state.visible)) - } - } - appliedRasters[state.id] = state -} - -internal fun LongdoMapViewController.runRasterJs(js: String) { - runCatching { longdoMap.run(js) {} } -} - -/** MapLibre のラスターソース仕様(JSON)。未対応ソースは null。 */ -internal fun LongdoMapViewController.rasterSourceSpec(source: RasterLayerSource): String? = - when (source) { - is RasterLayerSource.UrlTemplate -> - JSONObject() - .put("type", "raster") - .put("tiles", JSONArray().put(source.template)) - .put("tileSize", source.tileSize) - .apply { - source.minZoom?.let { put("minzoom", it) } - source.maxZoom?.let { put("maxzoom", it) } - }.put("scheme", if (source.scheme == TileScheme.TMS) "tms" else "xyz") - .toString() - - is RasterLayerSource.TileJson -> - JSONObject().put("type", "raster").put("url", source.url).toString() - - is RasterLayerSource.ArcGisService -> null - } - -internal fun LongdoMapViewController.rasterLayerSpec( - layerId: String, - sourceId: String, - opacity: Float, - visible: Boolean, -): String = - JSONObject() - .put("id", layerId) - .put("type", "raster") - .put("source", sourceId) - .put("paint", JSONObject().put("raster-opacity", opacity.toDouble())) - .put("layout", JSONObject().put("visibility", if (visible) "visible" else "none")) - .toString() - -internal fun LongdoMapViewController.addRasterJs( - sourceId: String, - layerId: String, - sourceSpec: String, - layerSpec: String, -): String = - """ - (function(){var m=map.Renderer; if(!m)return; - var tries=0; - function apply(){ - try{ - if(!m.getSource('$sourceId'))m.addSource('$sourceId',$sourceSpec); - if(!m.getLayer('$layerId'))m.addLayer($layerSpec); - }catch(e){ if(tries++<30)setTimeout(apply,150); } - } - apply(); - })() - """.trimIndent() - -/** - * ベースレイヤ切替(= 内部スタイルの差し替え)のあとに、[rasters] を載せ直す JS。 - * - * `style.load` で一度、保険に 1.5 秒後にもう一度。どちらも「無ければ足す」なので - * 二重にはならない。差し替え前に走った分は差し替えで消え、あとの分が残る。 - */ -internal fun LongdoMapViewController.readdRastersAfterStyleSwapJs(rasters: List): String { - val adds = - rasters.mapNotNull { state -> - val spec = rasterSourceSpec(state.source) ?: return@mapNotNull null - val srcId = rasterSourceId(state.id) - val layerId = rasterLayerId(state.id) - val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) - "try{if(!m.getSource('$srcId'))m.addSource('$srcId',$spec);" + - "if(!m.getLayer('$layerId'))m.addLayer($layerSpec);}catch(e){}" - }.joinToString("") - return "(function(){var m=map.Renderer;if(!m)return;function re(){$adds}" + - "m.once('style.load',function(){setTimeout(re,0);});setTimeout(re,1500);})()" -} - -internal fun LongdoMapViewController.removeRasterJs(id: String): String { - val srcId = rasterSourceId(id) - val layerId = rasterLayerId(id) - return "(function(){try{var m=map.Renderer;" + - "if(m.getLayer('$layerId'))m.removeLayer('$layerId');" + - "if(m.getSource('$srcId'))m.removeSource('$srcId');}catch(e){}})()" -} - -internal fun LongdoMapViewController.setRasterOpacityJs( - layerId: String, - opacity: Float, -): String = - "(function(){try{if(map.Renderer.getLayer('$layerId'))" + - "map.Renderer.setPaintProperty('$layerId','raster-opacity',${opacity.toDouble()});}catch(e){}})()" - -internal fun LongdoMapViewController.setRasterVisibilityJs( - layerId: String, - visible: Boolean, -): String = - "(function(){try{if(map.Renderer.getLayer('$layerId'))" + - "map.Renderer.setLayoutProperty('$layerId','visibility','${if (visible) "visible" else "none"}');}catch(e){}})()" - -internal fun LongdoMapViewController.rasterSourceId(id: String): String = "mcrs_$id" - -internal fun LongdoMapViewController.rasterLayerId(id: String): String = "mcrl_$id" +package com.mapconductor.longdo + +import com.mapconductor.core.raster.RasterHeaderRuleSet +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import org.json.JSONArray +import org.json.JSONObject + +/** + * ラスターレイヤーの反映。 + * + * Longdo は WebView 上の JS 地図なので、レイヤーの追加・削除・不透明度は + * すべて JS 文字列を組み立てて流し込む形になる。ID は他レイヤーとぶつからない + * よう `mcrs_` / `mcrl_` を前置する。 + */ +internal fun LongdoMapViewController.applyRaster(state: RasterLayerState) { + RasterHeaderRuleSet.warnUnsupported(provider = "Longdo", state = state) + val srcId = rasterSourceId(state.id) + val layerId = rasterLayerId(state.id) + val spec = rasterSourceSpec(state.source) ?: return // 未対応ソースはスキップ + val prev = appliedRasters[state.id] + val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) + when { + prev == null -> + runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) + prev.source != state.source -> { + runRasterJs(removeRasterJs(state.id)) + runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) + } + else -> { + if (prev.opacity != state.opacity) runRasterJs(setRasterOpacityJs(layerId, state.opacity)) + if (prev.visible != state.visible) runRasterJs(setRasterVisibilityJs(layerId, state.visible)) + } + } + appliedRasters[state.id] = state +} + +internal fun LongdoMapViewController.runRasterJs(js: String) { + runCatching { longdoMap.run(js) {} } +} + +/** MapLibre のラスターソース仕様(JSON)。未対応ソースは null。 */ +internal fun LongdoMapViewController.rasterSourceSpec(source: RasterLayerSource): String? = + when (source) { + is RasterLayerSource.UrlTemplate -> + JSONObject() + .put("type", "raster") + .put("tiles", JSONArray().put(source.template)) + .put("tileSize", source.tileSize) + .apply { + source.minZoom?.let { put("minzoom", it) } + source.maxZoom?.let { put("maxzoom", it) } + }.put("scheme", if (source.scheme == TileScheme.TMS) "tms" else "xyz") + .toString() + + is RasterLayerSource.TileJson -> + JSONObject().put("type", "raster").put("url", source.url).toString() + + is RasterLayerSource.ArcGisService -> null + } + +internal fun LongdoMapViewController.rasterLayerSpec( + layerId: String, + sourceId: String, + opacity: Float, + visible: Boolean, +): String = + JSONObject() + .put("id", layerId) + .put("type", "raster") + .put("source", sourceId) + .put("paint", JSONObject().put("raster-opacity", opacity.toDouble())) + .put("layout", JSONObject().put("visibility", if (visible) "visible" else "none")) + .toString() + +internal fun LongdoMapViewController.addRasterJs( + sourceId: String, + layerId: String, + sourceSpec: String, + layerSpec: String, +): String = + """ + (function(){var m=map.Renderer; if(!m)return; + var tries=0; + function apply(){ + try{ + if(!m.getSource('$sourceId'))m.addSource('$sourceId',$sourceSpec); + if(!m.getLayer('$layerId'))m.addLayer($layerSpec); + }catch(e){ if(tries++<30)setTimeout(apply,150); } + } + apply(); + })() + """.trimIndent() + +/** + * ベースレイヤ切替(= 内部スタイルの差し替え)のあとに、[rasters] を載せ直す JS。 + * + * `style.load` で一度、保険に 1.5 秒後にもう一度。どちらも「無ければ足す」なので + * 二重にはならない。差し替え前に走った分は差し替えで消え、あとの分が残る。 + */ +internal fun LongdoMapViewController.readdRastersAfterStyleSwapJs(rasters: List): String { + val adds = + rasters + .mapNotNull { state -> + val spec = rasterSourceSpec(state.source) ?: return@mapNotNull null + val srcId = rasterSourceId(state.id) + val layerId = rasterLayerId(state.id) + val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) + "try{if(!m.getSource('$srcId'))m.addSource('$srcId',$spec);" + + "if(!m.getLayer('$layerId'))m.addLayer($layerSpec);}catch(e){}" + }.joinToString("") + return "(function(){var m=map.Renderer;if(!m)return;function re(){$adds}" + + "m.once('style.load',function(){setTimeout(re,0);});setTimeout(re,1500);})()" +} + +internal fun LongdoMapViewController.removeRasterJs(id: String): String { + val srcId = rasterSourceId(id) + val layerId = rasterLayerId(id) + return "(function(){try{var m=map.Renderer;" + + "if(m.getLayer('$layerId'))m.removeLayer('$layerId');" + + "if(m.getSource('$srcId'))m.removeSource('$srcId');}catch(e){}})()" +} + +internal fun LongdoMapViewController.setRasterOpacityJs( + layerId: String, + opacity: Float, +): String = + "(function(){try{if(map.Renderer.getLayer('$layerId'))" + + "map.Renderer.setPaintProperty('$layerId','raster-opacity',${opacity.toDouble()});}catch(e){}})()" + +internal fun LongdoMapViewController.setRasterVisibilityJs( + layerId: String, + visible: Boolean, +): String = + "(function(){try{if(map.Renderer.getLayer('$layerId'))" + + "map.Renderer.setLayoutProperty('$layerId','visibility','${if (visible) "visible" else "none"}');}catch(e){}})()" + +internal fun LongdoMapViewController.rasterSourceId(id: String): String = "mcrs_$id" + +internal fun LongdoMapViewController.rasterLayerId(id: String): String = "mcrl_$id" diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt index aee0c40..100adef 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt @@ -1,70 +1,70 @@ -package com.mapconductor.longdo - -import androidx.compose.ui.geometry.Offset -import com.longdo.sdk3.LongdoMap -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapViewHolderInterface -import com.mapconductor.core.projection.WebMercatorScreenProjection - -/** - * Longdo Map 用の [MapViewHolderInterface] 実装。 - * - * Longdo Map API3 SDK は WebView(Longdo Map JS API3)ベースで、ネイティブの `MapView` / `Map` を - * 直接公開しない。実体としては [LongdoMap](WebView サブクラス)を保持し、地図操作は JS ブリッジ - * (`call` / `run`)経由で行う。 - * - * ブリッジは同期の座標変換 API を持たないが、Longdo の地図は Web Mercator なので - * 投影はカメラとビューの大きさだけで決まる。式はコアの [WebMercatorScreenProjection] - * にあり、ここはカメラとサイズを渡すだけ。**各所で式を書き直さないこと。** - */ -class LongdoMapViewHolder( - private val longdoMap: LongdoMap, -) : MapViewHolderInterface { - override val mapView: LongdoMap = longdoMap - override val map: LongdoMap = longdoMap - - /** - * 投影に使うカメラの取り出し口。[LongdoMapViewController] が自身の直近カメラを繋ぐ。 - * ホルダーはコントローラより先に作られるので、コンストラクタでは受け取れない。 - */ - internal var cameraProvider: (() -> MapCameraPosition?)? = null - - /** - * 地理座標 → 画面座標(**端末ピクセル**)。カメラ未確定(地図の準備前)なら null。 - * - * ホルダーの契約は端末ピクセル(MapLibre 等の `toScreenLocation` に合わせている)。 - * 一方 [WebMercatorScreenProjection] の世界の大きさ `256 * 2^zoom` は - * **密度非依存の単位**(dp / CSS ピクセル)なので、密度を挟まずに px を渡すと - * 中心からのずれが density 分だけ小さくなり、マーカーと吹き出しがずれる。 - */ - override fun toScreenOffset(position: GeoPointInterface): Offset? { - val camera = cameraProvider?.invoke() ?: return null - val density = ResourceProvider.getDensity() - val offsetDp = - WebMercatorScreenProjection.toScreenOffset( - position = position, - camera = camera, - widthPx = longdoMap.width / density, - heightPx = longdoMap.height / density, - ) ?: return null - return Offset(offsetDp.x * density, offsetDp.y * density) - } - - /** - * 画面座標 → 地理座標(同期)。タップの当たり判定はブリッジの応答を待てないので - * こちらを使う。非同期版は基底の既定実装がここへ委譲する。 - */ - override fun fromScreenOffsetSync(offset: Offset): GeoPoint? { - val camera = cameraProvider?.invoke() ?: return null - val density = ResourceProvider.getDensity() - return WebMercatorScreenProjection.fromScreenOffset( - offset = Offset(offset.x / density, offset.y / density), - camera = camera, - widthPx = longdoMap.width / density, - heightPx = longdoMap.height / density, - ) - } -} +package com.mapconductor.longdo + +import androidx.compose.ui.geometry.Offset +import com.longdo.sdk3.LongdoMap +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewHolderInterface +import com.mapconductor.core.projection.WebMercatorScreenProjection + +/** + * Longdo Map 用の [MapViewHolderInterface] 実装。 + * + * Longdo Map API3 SDK は WebView(Longdo Map JS API3)ベースで、ネイティブの `MapView` / `Map` を + * 直接公開しない。実体としては [LongdoMap](WebView サブクラス)を保持し、地図操作は JS ブリッジ + * (`call` / `run`)経由で行う。 + * + * ブリッジは同期の座標変換 API を持たないが、Longdo の地図は Web Mercator なので + * 投影はカメラとビューの大きさだけで決まる。式はコアの [WebMercatorScreenProjection] + * にあり、ここはカメラとサイズを渡すだけ。**各所で式を書き直さないこと。** + */ +class LongdoMapViewHolder( + private val longdoMap: LongdoMap, +) : MapViewHolderInterface { + override val mapView: LongdoMap = longdoMap + override val map: LongdoMap = longdoMap + + /** + * 投影に使うカメラの取り出し口。[LongdoMapViewController] が自身の直近カメラを繋ぐ。 + * ホルダーはコントローラより先に作られるので、コンストラクタでは受け取れない。 + */ + internal var cameraProvider: (() -> MapCameraPosition?)? = null + + /** + * 地理座標 → 画面座標(**端末ピクセル**)。カメラ未確定(地図の準備前)なら null。 + * + * ホルダーの契約は端末ピクセル(MapLibre 等の `toScreenLocation` に合わせている)。 + * 一方 [WebMercatorScreenProjection] の世界の大きさ `256 * 2^zoom` は + * **密度非依存の単位**(dp / CSS ピクセル)なので、密度を挟まずに px を渡すと + * 中心からのずれが density 分だけ小さくなり、マーカーと吹き出しがずれる。 + */ + override fun toScreenOffset(position: GeoPointInterface): Offset? { + val camera = cameraProvider?.invoke() ?: return null + val density = ResourceProvider.getDensity() + val offsetDp = + WebMercatorScreenProjection.toScreenOffset( + position = position, + camera = camera, + widthPx = longdoMap.width / density, + heightPx = longdoMap.height / density, + ) ?: return null + return Offset(offsetDp.x * density, offsetDp.y * density) + } + + /** + * 画面座標 → 地理座標(同期)。タップの当たり判定はブリッジの応答を待てないので + * こちらを使う。非同期版は基底の既定実装がここへ委譲する。 + */ + override fun fromScreenOffsetSync(offset: Offset): GeoPoint? { + val camera = cameraProvider?.invoke() ?: return null + val density = ResourceProvider.getDensity() + return WebMercatorScreenProjection.fromScreenOffset( + offset = Offset(offset.x / density, offset.y / density), + camera = camera, + widthPx = longdoMap.width / density, + heightPx = longdoMap.height / density, + ) + } +} From e2cf78b27769b46d702688f5a23208a904fdb3c0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 24 Oct 2026 03:50:08 -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. --- .../longdo/LongdoMapViewControllerCamera.kt | 234 +++++++-------- .../longdo/LongdoMapViewControllerRaster.kt | 282 +++++++++--------- .../longdo/LongdoMapViewHolder.kt | 140 ++++----- 3 files changed, 328 insertions(+), 328 deletions(-) diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt index c75bcf9..2c7f3e6 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerCamera.kt @@ -1,117 +1,117 @@ -package com.mapconductor.longdo - -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.features.GeoRectBounds -import com.mapconductor.core.map.CameraBearing -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.spherical.Spherical -import org.json.JSONObject -import kotlin.math.abs -import kotlin.math.tan - -// カメラの適用。Longdo のズームは MapConductor の論理ズームと 1 段ずれるため、 -// ZoomAltitudeConverter を通してから渡す。 - -/** - * カメラ停止時に [setCameraRestriction] の制限違反を補正する。補正したら true。 - * - * Longdo Map API3 には(Google の `setLatLngBoundsForCameraTarget` に相当する) - * カメラ範囲制限の JS API が無いため、android-sdk の HERE/ArcGIS/TomTom と同じく - * カメラ停止時に矩形内へクランプして再適用する方式で制限する。 - * 再適用すると再度カメライベントが発火し、そこでは補正不要になり通常フローへ進む。 - */ -internal fun LongdoMapViewController.applyCameraRestrictionCorrectionIfNeeded(current: MapCameraPosition): Boolean { - val corrected = correctForCameraRestriction(current) ?: return false - moveCamera(corrected) - return true -} - -internal fun LongdoMapViewController.handleMoveCamera(position: MapCameraPosition) { - if (mapReady) applyCamera(position) else pendingCameraPosition = position -} - -internal fun LongdoMapViewController.handleAnimateCamera( - position: MapCameraPosition, - duration: Long, -) { - // Longdo の JS ブリッジでは中心(location)とズーム(zoom)が別コマンドで、双方をアニメーション指定 - // すると後発のズームがパンのアニメーションを打ち切ってしまい中心が移動しない。カメラ同期では同期先が - // ソース側の連続イベントを追って滑らかに追従するため、ここでは瞬時に確定位置へ適用する。 - if (mapReady) applyCamera(position) else pendingCameraPosition = position -} - -/** - * Longdo の `map.location` / `map.zoom` / `map.rotate` / `map.pitch` を呼んでカメラを確定位置へ即時移動する。 - * - * - ズームは統一ズーム(Google)→ Longdo ネイティブズームへ変換して渡す。 - * - 方位([MapCameraPosition.bearing])は Longdo の `rotate`(北から時計回り=Google と同一規約)へ渡す。 - * - 傾き([MapCameraPosition.tilt])は Longdo の `pitch` へ渡す。負の傾き(仰角ビュー)は [nativeCameraFor] で - * 前進ターゲット+正 pitch に擬似変換する。 - * - * すべて `animate=false`(即時)で呼ぶ。中心・ズーム・方位・傾きのアニメーションは別コマンドのため、 - * いずれかをアニメーション指定すると後続コマンドが直前のアニメーションを打ち切ってしまう。 - */ -internal fun LongdoMapViewController.applyCamera(position: MapCameraPosition) { - val native = nativeCameraFor(position) - runCatching { - longdoMap.call("location", listOf(native.target.toLongdoLocation(), false)) {} - longdoMap.call("zoom", listOf(native.longdoZoom, false)) {} - longdoMap.call("rotate", listOf(CameraBearing.toNativeHeading(position.bearing), false)) {} - longdoMap.call("pitch", listOf(native.pitch)) {} - } -} - -/** Longdo ネイティブへ渡すカメラ(中心・ズーム・pitch)。 */ -internal data class NativeCamera( - val target: GeoPointInterface, - val longdoZoom: Double, - val pitch: Double, -) - -/** - * 統一カメラ([MapCameraPosition])を Longdo ネイティブのカメラへ変換する。 - * - * tilt ≥ 0 は中心・ズームをそのまま用い、pitch を 0〜[LongdoMapViewController.MAX_PITCH] にクランプする。 - * - * tilt < 0(水平線より上方を向く仰角ビュー)は Longdo(MapLibre GL)が上向き pitch を表現できないため、 - * Google Maps 実装と同方式で擬似再現する: カメラ eye を固定したまま、地面ターゲットを bearing 方向へ - * `altitude * tan(|tilt|)` メートル前進させ、`|tilt|` の下向き pitch で描画する。ズームは変更しない。 - */ -internal fun LongdoMapViewController.nativeCameraFor(position: MapCameraPosition): NativeCamera { - val longdoZoom = LongdoMapViewController.coreZoomToLongdo(position.zoom) - if (position.tilt >= 0.0) { - return NativeCamera( - position.position, longdoZoom, - position.tilt - .coerceIn(0.0, LongdoMapViewController.MAX_PITCH), - ) - } - val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, LongdoMapViewController.MAX_PITCH) - val tiltAbsRad = Math.toRadians(tiltAbsDeg) - // 高度は統一ズーム(Google)基準で算出する(Longdo ネイティブズームではない)。 - val altitude = zoomConverter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) - val distanceForward = altitude * tan(tiltAbsRad) - val target = - Spherical - .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) - return NativeCamera(target, longdoZoom, tiltAbsDeg) -} - -internal fun LongdoMapViewController.handleFitBounds( - bounds: GeoRectBounds, - padding: Int, -) { - val sw = bounds.southWest ?: return - val ne = bounds.northEast ?: return - val bound = - JSONObject() - .put("minLon", sw.longitude) - .put("minLat", sw.latitude) - .put("maxLon", ne.longitude) - .put("maxLat", ne.latitude) - // NOTE: Longdo Map API3 の map.bound(box) は境界矩形のみを引数に取り padding 相当の - // 余白パラメータを持たないため、padding は反映できない(矩形を膨らませる擬似対応は行わない)。 - if (mapReady) { - runCatching { longdoMap.call("bound", listOf(bound)) {} } - } -} +package com.mapconductor.longdo + +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.CameraBearing +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Spherical +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.tan + +// カメラの適用。Longdo のズームは MapConductor の論理ズームと 1 段ずれるため、 +// ZoomAltitudeConverter を通してから渡す。 + +/** + * カメラ停止時に [setCameraRestriction] の制限違反を補正する。補正したら true。 + * + * Longdo Map API3 には(Google の `setLatLngBoundsForCameraTarget` に相当する) + * カメラ範囲制限の JS API が無いため、android-sdk の HERE/ArcGIS/TomTom と同じく + * カメラ停止時に矩形内へクランプして再適用する方式で制限する。 + * 再適用すると再度カメライベントが発火し、そこでは補正不要になり通常フローへ進む。 + */ +internal fun LongdoMapViewController.applyCameraRestrictionCorrectionIfNeeded(current: MapCameraPosition): Boolean { + val corrected = correctForCameraRestriction(current) ?: return false + moveCamera(corrected) + return true +} + +internal fun LongdoMapViewController.handleMoveCamera(position: MapCameraPosition) { + if (mapReady) applyCamera(position) else pendingCameraPosition = position +} + +internal fun LongdoMapViewController.handleAnimateCamera( + position: MapCameraPosition, + duration: Long, +) { + // Longdo の JS ブリッジでは中心(location)とズーム(zoom)が別コマンドで、双方をアニメーション指定 + // すると後発のズームがパンのアニメーションを打ち切ってしまい中心が移動しない。カメラ同期では同期先が + // ソース側の連続イベントを追って滑らかに追従するため、ここでは瞬時に確定位置へ適用する。 + if (mapReady) applyCamera(position) else pendingCameraPosition = position +} + +/** + * Longdo の `map.location` / `map.zoom` / `map.rotate` / `map.pitch` を呼んでカメラを確定位置へ即時移動する。 + * + * - ズームは統一ズーム(Google)→ Longdo ネイティブズームへ変換して渡す。 + * - 方位([MapCameraPosition.bearing])は Longdo の `rotate`(北から時計回り=Google と同一規約)へ渡す。 + * - 傾き([MapCameraPosition.tilt])は Longdo の `pitch` へ渡す。負の傾き(仰角ビュー)は [nativeCameraFor] で + * 前進ターゲット+正 pitch に擬似変換する。 + * + * すべて `animate=false`(即時)で呼ぶ。中心・ズーム・方位・傾きのアニメーションは別コマンドのため、 + * いずれかをアニメーション指定すると後続コマンドが直前のアニメーションを打ち切ってしまう。 + */ +internal fun LongdoMapViewController.applyCamera(position: MapCameraPosition) { + val native = nativeCameraFor(position) + runCatching { + longdoMap.call("location", listOf(native.target.toLongdoLocation(), false)) {} + longdoMap.call("zoom", listOf(native.longdoZoom, false)) {} + longdoMap.call("rotate", listOf(CameraBearing.toNativeHeading(position.bearing), false)) {} + longdoMap.call("pitch", listOf(native.pitch)) {} + } +} + +/** Longdo ネイティブへ渡すカメラ(中心・ズーム・pitch)。 */ +internal data class NativeCamera( + val target: GeoPointInterface, + val longdoZoom: Double, + val pitch: Double, +) + +/** + * 統一カメラ([MapCameraPosition])を Longdo ネイティブのカメラへ変換する。 + * + * tilt ≥ 0 は中心・ズームをそのまま用い、pitch を 0〜[LongdoMapViewController.MAX_PITCH] にクランプする。 + * + * tilt < 0(水平線より上方を向く仰角ビュー)は Longdo(MapLibre GL)が上向き pitch を表現できないため、 + * Google Maps 実装と同方式で擬似再現する: カメラ eye を固定したまま、地面ターゲットを bearing 方向へ + * `altitude * tan(|tilt|)` メートル前進させ、`|tilt|` の下向き pitch で描画する。ズームは変更しない。 + */ +internal fun LongdoMapViewController.nativeCameraFor(position: MapCameraPosition): NativeCamera { + val longdoZoom = LongdoMapViewController.coreZoomToLongdo(position.zoom) + if (position.tilt >= 0.0) { + return NativeCamera( + position.position, longdoZoom, + position.tilt + .coerceIn(0.0, LongdoMapViewController.MAX_PITCH), + ) + } + val tiltAbsDeg = abs(position.tilt).coerceIn(0.0, LongdoMapViewController.MAX_PITCH) + val tiltAbsRad = Math.toRadians(tiltAbsDeg) + // 高度は統一ズーム(Google)基準で算出する(Longdo ネイティブズームではない)。 + val altitude = zoomConverter.zoomLevelToAltitude(position.zoom, position.position.latitude, 0.0) + val distanceForward = altitude * tan(tiltAbsRad) + val target = + Spherical + .computeOffset(position.position, distanceForward, CameraBearing.toNativeHeading(position.bearing)) + return NativeCamera(target, longdoZoom, tiltAbsDeg) +} + +internal fun LongdoMapViewController.handleFitBounds( + bounds: GeoRectBounds, + padding: Int, +) { + val sw = bounds.southWest ?: return + val ne = bounds.northEast ?: return + val bound = + JSONObject() + .put("minLon", sw.longitude) + .put("minLat", sw.latitude) + .put("maxLon", ne.longitude) + .put("maxLat", ne.latitude) + // NOTE: Longdo Map API3 の map.bound(box) は境界矩形のみを引数に取り padding 相当の + // 余白パラメータを持たないため、padding は反映できない(矩形を膨らませる擬似対応は行わない)。 + if (mapReady) { + runCatching { longdoMap.call("bound", listOf(bound)) {} } + } +} diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt index 807d4e5..5b1cdaa 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewControllerRaster.kt @@ -1,141 +1,141 @@ -package com.mapconductor.longdo - -import com.mapconductor.core.raster.RasterHeaderRuleSet -import com.mapconductor.core.raster.RasterLayerSource -import com.mapconductor.core.raster.RasterLayerState -import com.mapconductor.core.raster.TileScheme -import org.json.JSONArray -import org.json.JSONObject - -/** - * ラスターレイヤーの反映。 - * - * Longdo は WebView 上の JS 地図なので、レイヤーの追加・削除・不透明度は - * すべて JS 文字列を組み立てて流し込む形になる。ID は他レイヤーとぶつからない - * よう `mcrs_` / `mcrl_` を前置する。 - */ -internal fun LongdoMapViewController.applyRaster(state: RasterLayerState) { - RasterHeaderRuleSet.warnUnsupported(provider = "Longdo", state = state) - val srcId = rasterSourceId(state.id) - val layerId = rasterLayerId(state.id) - val spec = rasterSourceSpec(state.source) ?: return // 未対応ソースはスキップ - val prev = appliedRasters[state.id] - val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) - when { - prev == null -> - runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) - prev.source != state.source -> { - runRasterJs(removeRasterJs(state.id)) - runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) - } - else -> { - if (prev.opacity != state.opacity) runRasterJs(setRasterOpacityJs(layerId, state.opacity)) - if (prev.visible != state.visible) runRasterJs(setRasterVisibilityJs(layerId, state.visible)) - } - } - appliedRasters[state.id] = state -} - -internal fun LongdoMapViewController.runRasterJs(js: String) { - runCatching { longdoMap.run(js) {} } -} - -/** MapLibre のラスターソース仕様(JSON)。未対応ソースは null。 */ -internal fun LongdoMapViewController.rasterSourceSpec(source: RasterLayerSource): String? = - when (source) { - is RasterLayerSource.UrlTemplate -> - JSONObject() - .put("type", "raster") - .put("tiles", JSONArray().put(source.template)) - .put("tileSize", source.tileSize) - .apply { - source.minZoom?.let { put("minzoom", it) } - source.maxZoom?.let { put("maxzoom", it) } - }.put("scheme", if (source.scheme == TileScheme.TMS) "tms" else "xyz") - .toString() - - is RasterLayerSource.TileJson -> - JSONObject().put("type", "raster").put("url", source.url).toString() - - is RasterLayerSource.ArcGisService -> null - } - -internal fun LongdoMapViewController.rasterLayerSpec( - layerId: String, - sourceId: String, - opacity: Float, - visible: Boolean, -): String = - JSONObject() - .put("id", layerId) - .put("type", "raster") - .put("source", sourceId) - .put("paint", JSONObject().put("raster-opacity", opacity.toDouble())) - .put("layout", JSONObject().put("visibility", if (visible) "visible" else "none")) - .toString() - -internal fun LongdoMapViewController.addRasterJs( - sourceId: String, - layerId: String, - sourceSpec: String, - layerSpec: String, -): String = - """ - (function(){var m=map.Renderer; if(!m)return; - var tries=0; - function apply(){ - try{ - if(!m.getSource('$sourceId'))m.addSource('$sourceId',$sourceSpec); - if(!m.getLayer('$layerId'))m.addLayer($layerSpec); - }catch(e){ if(tries++<30)setTimeout(apply,150); } - } - apply(); - })() - """.trimIndent() - -/** - * ベースレイヤ切替(= 内部スタイルの差し替え)のあとに、[rasters] を載せ直す JS。 - * - * `style.load` で一度、保険に 1.5 秒後にもう一度。どちらも「無ければ足す」なので - * 二重にはならない。差し替え前に走った分は差し替えで消え、あとの分が残る。 - */ -internal fun LongdoMapViewController.readdRastersAfterStyleSwapJs(rasters: List): String { - val adds = - rasters - .mapNotNull { state -> - val spec = rasterSourceSpec(state.source) ?: return@mapNotNull null - val srcId = rasterSourceId(state.id) - val layerId = rasterLayerId(state.id) - val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) - "try{if(!m.getSource('$srcId'))m.addSource('$srcId',$spec);" + - "if(!m.getLayer('$layerId'))m.addLayer($layerSpec);}catch(e){}" - }.joinToString("") - return "(function(){var m=map.Renderer;if(!m)return;function re(){$adds}" + - "m.once('style.load',function(){setTimeout(re,0);});setTimeout(re,1500);})()" -} - -internal fun LongdoMapViewController.removeRasterJs(id: String): String { - val srcId = rasterSourceId(id) - val layerId = rasterLayerId(id) - return "(function(){try{var m=map.Renderer;" + - "if(m.getLayer('$layerId'))m.removeLayer('$layerId');" + - "if(m.getSource('$srcId'))m.removeSource('$srcId');}catch(e){}})()" -} - -internal fun LongdoMapViewController.setRasterOpacityJs( - layerId: String, - opacity: Float, -): String = - "(function(){try{if(map.Renderer.getLayer('$layerId'))" + - "map.Renderer.setPaintProperty('$layerId','raster-opacity',${opacity.toDouble()});}catch(e){}})()" - -internal fun LongdoMapViewController.setRasterVisibilityJs( - layerId: String, - visible: Boolean, -): String = - "(function(){try{if(map.Renderer.getLayer('$layerId'))" + - "map.Renderer.setLayoutProperty('$layerId','visibility','${if (visible) "visible" else "none"}');}catch(e){}})()" - -internal fun LongdoMapViewController.rasterSourceId(id: String): String = "mcrs_$id" - -internal fun LongdoMapViewController.rasterLayerId(id: String): String = "mcrl_$id" +package com.mapconductor.longdo + +import com.mapconductor.core.raster.RasterHeaderRuleSet +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.raster.TileScheme +import org.json.JSONArray +import org.json.JSONObject + +/** + * ラスターレイヤーの反映。 + * + * Longdo は WebView 上の JS 地図なので、レイヤーの追加・削除・不透明度は + * すべて JS 文字列を組み立てて流し込む形になる。ID は他レイヤーとぶつからない + * よう `mcrs_` / `mcrl_` を前置する。 + */ +internal fun LongdoMapViewController.applyRaster(state: RasterLayerState) { + RasterHeaderRuleSet.warnUnsupported(provider = "Longdo", state = state) + val srcId = rasterSourceId(state.id) + val layerId = rasterLayerId(state.id) + val spec = rasterSourceSpec(state.source) ?: return // 未対応ソースはスキップ + val prev = appliedRasters[state.id] + val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) + when { + prev == null -> + runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) + prev.source != state.source -> { + runRasterJs(removeRasterJs(state.id)) + runRasterJs(addRasterJs(srcId, layerId, spec, layerSpec)) + } + else -> { + if (prev.opacity != state.opacity) runRasterJs(setRasterOpacityJs(layerId, state.opacity)) + if (prev.visible != state.visible) runRasterJs(setRasterVisibilityJs(layerId, state.visible)) + } + } + appliedRasters[state.id] = state +} + +internal fun LongdoMapViewController.runRasterJs(js: String) { + runCatching { longdoMap.run(js) {} } +} + +/** MapLibre のラスターソース仕様(JSON)。未対応ソースは null。 */ +internal fun LongdoMapViewController.rasterSourceSpec(source: RasterLayerSource): String? = + when (source) { + is RasterLayerSource.UrlTemplate -> + JSONObject() + .put("type", "raster") + .put("tiles", JSONArray().put(source.template)) + .put("tileSize", source.tileSize) + .apply { + source.minZoom?.let { put("minzoom", it) } + source.maxZoom?.let { put("maxzoom", it) } + }.put("scheme", if (source.scheme == TileScheme.TMS) "tms" else "xyz") + .toString() + + is RasterLayerSource.TileJson -> + JSONObject().put("type", "raster").put("url", source.url).toString() + + is RasterLayerSource.ArcGisService -> null + } + +internal fun LongdoMapViewController.rasterLayerSpec( + layerId: String, + sourceId: String, + opacity: Float, + visible: Boolean, +): String = + JSONObject() + .put("id", layerId) + .put("type", "raster") + .put("source", sourceId) + .put("paint", JSONObject().put("raster-opacity", opacity.toDouble())) + .put("layout", JSONObject().put("visibility", if (visible) "visible" else "none")) + .toString() + +internal fun LongdoMapViewController.addRasterJs( + sourceId: String, + layerId: String, + sourceSpec: String, + layerSpec: String, +): String = + """ + (function(){var m=map.Renderer; if(!m)return; + var tries=0; + function apply(){ + try{ + if(!m.getSource('$sourceId'))m.addSource('$sourceId',$sourceSpec); + if(!m.getLayer('$layerId'))m.addLayer($layerSpec); + }catch(e){ if(tries++<30)setTimeout(apply,150); } + } + apply(); + })() + """.trimIndent() + +/** + * ベースレイヤ切替(= 内部スタイルの差し替え)のあとに、[rasters] を載せ直す JS。 + * + * `style.load` で一度、保険に 1.5 秒後にもう一度。どちらも「無ければ足す」なので + * 二重にはならない。差し替え前に走った分は差し替えで消え、あとの分が残る。 + */ +internal fun LongdoMapViewController.readdRastersAfterStyleSwapJs(rasters: List): String { + val adds = + rasters + .mapNotNull { state -> + val spec = rasterSourceSpec(state.source) ?: return@mapNotNull null + val srcId = rasterSourceId(state.id) + val layerId = rasterLayerId(state.id) + val layerSpec = rasterLayerSpec(layerId, srcId, state.opacity, state.visible) + "try{if(!m.getSource('$srcId'))m.addSource('$srcId',$spec);" + + "if(!m.getLayer('$layerId'))m.addLayer($layerSpec);}catch(e){}" + }.joinToString("") + return "(function(){var m=map.Renderer;if(!m)return;function re(){$adds}" + + "m.once('style.load',function(){setTimeout(re,0);});setTimeout(re,1500);})()" +} + +internal fun LongdoMapViewController.removeRasterJs(id: String): String { + val srcId = rasterSourceId(id) + val layerId = rasterLayerId(id) + return "(function(){try{var m=map.Renderer;" + + "if(m.getLayer('$layerId'))m.removeLayer('$layerId');" + + "if(m.getSource('$srcId'))m.removeSource('$srcId');}catch(e){}})()" +} + +internal fun LongdoMapViewController.setRasterOpacityJs( + layerId: String, + opacity: Float, +): String = + "(function(){try{if(map.Renderer.getLayer('$layerId'))" + + "map.Renderer.setPaintProperty('$layerId','raster-opacity',${opacity.toDouble()});}catch(e){}})()" + +internal fun LongdoMapViewController.setRasterVisibilityJs( + layerId: String, + visible: Boolean, +): String = + "(function(){try{if(map.Renderer.getLayer('$layerId'))" + + "map.Renderer.setLayoutProperty('$layerId','visibility','${if (visible) "visible" else "none"}');}catch(e){}})()" + +internal fun LongdoMapViewController.rasterSourceId(id: String): String = "mcrs_$id" + +internal fun LongdoMapViewController.rasterLayerId(id: String): String = "mcrl_$id" diff --git a/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt b/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt index 100adef..d26ea44 100644 --- a/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt +++ b/src/main/java/com/mapconductor/longdo/LongdoMapViewHolder.kt @@ -1,70 +1,70 @@ -package com.mapconductor.longdo - -import androidx.compose.ui.geometry.Offset -import com.longdo.sdk3.LongdoMap -import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.features.GeoPoint -import com.mapconductor.core.features.GeoPointInterface -import com.mapconductor.core.map.MapCameraPosition -import com.mapconductor.core.map.MapViewHolderInterface -import com.mapconductor.core.projection.WebMercatorScreenProjection - -/** - * Longdo Map 用の [MapViewHolderInterface] 実装。 - * - * Longdo Map API3 SDK は WebView(Longdo Map JS API3)ベースで、ネイティブの `MapView` / `Map` を - * 直接公開しない。実体としては [LongdoMap](WebView サブクラス)を保持し、地図操作は JS ブリッジ - * (`call` / `run`)経由で行う。 - * - * ブリッジは同期の座標変換 API を持たないが、Longdo の地図は Web Mercator なので - * 投影はカメラとビューの大きさだけで決まる。式はコアの [WebMercatorScreenProjection] - * にあり、ここはカメラとサイズを渡すだけ。**各所で式を書き直さないこと。** - */ -class LongdoMapViewHolder( - private val longdoMap: LongdoMap, -) : MapViewHolderInterface { - override val mapView: LongdoMap = longdoMap - override val map: LongdoMap = longdoMap - - /** - * 投影に使うカメラの取り出し口。[LongdoMapViewController] が自身の直近カメラを繋ぐ。 - * ホルダーはコントローラより先に作られるので、コンストラクタでは受け取れない。 - */ - internal var cameraProvider: (() -> MapCameraPosition?)? = null - - /** - * 地理座標 → 画面座標(**端末ピクセル**)。カメラ未確定(地図の準備前)なら null。 - * - * ホルダーの契約は端末ピクセル(MapLibre 等の `toScreenLocation` に合わせている)。 - * 一方 [WebMercatorScreenProjection] の世界の大きさ `256 * 2^zoom` は - * **密度非依存の単位**(dp / CSS ピクセル)なので、密度を挟まずに px を渡すと - * 中心からのずれが density 分だけ小さくなり、マーカーと吹き出しがずれる。 - */ - override fun toScreenOffset(position: GeoPointInterface): Offset? { - val camera = cameraProvider?.invoke() ?: return null - val density = ResourceProvider.getDensity() - val offsetDp = - WebMercatorScreenProjection.toScreenOffset( - position = position, - camera = camera, - widthPx = longdoMap.width / density, - heightPx = longdoMap.height / density, - ) ?: return null - return Offset(offsetDp.x * density, offsetDp.y * density) - } - - /** - * 画面座標 → 地理座標(同期)。タップの当たり判定はブリッジの応答を待てないので - * こちらを使う。非同期版は基底の既定実装がここへ委譲する。 - */ - override fun fromScreenOffsetSync(offset: Offset): GeoPoint? { - val camera = cameraProvider?.invoke() ?: return null - val density = ResourceProvider.getDensity() - return WebMercatorScreenProjection.fromScreenOffset( - offset = Offset(offset.x / density, offset.y / density), - camera = camera, - widthPx = longdoMap.width / density, - heightPx = longdoMap.height / density, - ) - } -} +package com.mapconductor.longdo + +import androidx.compose.ui.geometry.Offset +import com.longdo.sdk3.LongdoMap +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewHolderInterface +import com.mapconductor.core.projection.WebMercatorScreenProjection + +/** + * Longdo Map 用の [MapViewHolderInterface] 実装。 + * + * Longdo Map API3 SDK は WebView(Longdo Map JS API3)ベースで、ネイティブの `MapView` / `Map` を + * 直接公開しない。実体としては [LongdoMap](WebView サブクラス)を保持し、地図操作は JS ブリッジ + * (`call` / `run`)経由で行う。 + * + * ブリッジは同期の座標変換 API を持たないが、Longdo の地図は Web Mercator なので + * 投影はカメラとビューの大きさだけで決まる。式はコアの [WebMercatorScreenProjection] + * にあり、ここはカメラとサイズを渡すだけ。**各所で式を書き直さないこと。** + */ +class LongdoMapViewHolder( + private val longdoMap: LongdoMap, +) : MapViewHolderInterface { + override val mapView: LongdoMap = longdoMap + override val map: LongdoMap = longdoMap + + /** + * 投影に使うカメラの取り出し口。[LongdoMapViewController] が自身の直近カメラを繋ぐ。 + * ホルダーはコントローラより先に作られるので、コンストラクタでは受け取れない。 + */ + internal var cameraProvider: (() -> MapCameraPosition?)? = null + + /** + * 地理座標 → 画面座標(**端末ピクセル**)。カメラ未確定(地図の準備前)なら null。 + * + * ホルダーの契約は端末ピクセル(MapLibre 等の `toScreenLocation` に合わせている)。 + * 一方 [WebMercatorScreenProjection] の世界の大きさ `256 * 2^zoom` は + * **密度非依存の単位**(dp / CSS ピクセル)なので、密度を挟まずに px を渡すと + * 中心からのずれが density 分だけ小さくなり、マーカーと吹き出しがずれる。 + */ + override fun toScreenOffset(position: GeoPointInterface): Offset? { + val camera = cameraProvider?.invoke() ?: return null + val density = ResourceProvider.getDensity() + val offsetDp = + WebMercatorScreenProjection.toScreenOffset( + position = position, + camera = camera, + widthPx = longdoMap.width / density, + heightPx = longdoMap.height / density, + ) ?: return null + return Offset(offsetDp.x * density, offsetDp.y * density) + } + + /** + * 画面座標 → 地理座標(同期)。タップの当たり判定はブリッジの応答を待てないので + * こちらを使う。非同期版は基底の既定実装がここへ委譲する。 + */ + override fun fromScreenOffsetSync(offset: Offset): GeoPoint? { + val camera = cameraProvider?.invoke() ?: return null + val density = ResourceProvider.getDensity() + return WebMercatorScreenProjection.fromScreenOffset( + offset = Offset(offset.x / density, offset.y / density), + camera = camera, + widthPx = longdoMap.width / density, + heightPx = longdoMap.height / density, + ) + } +}