@react-native-kakao/map 2.2.6 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +5 -38
  3. package/RNCKakaoMap.podspec +16 -52
  4. package/android/build.gradle +12 -107
  5. package/android/consumer-rules.pro +2 -0
  6. package/android/src/main/AndroidManifest.xml +3 -1
  7. package/android/src/main/java/net/mjstudio/rnkakao/map/CameraOptions.kt +61 -0
  8. package/android/src/main/java/net/mjstudio/rnkakao/map/KakaoMapModule.kt +131 -0
  9. package/android/src/main/java/net/mjstudio/rnkakao/map/KakaoMapView.kt +1257 -0
  10. package/android/src/main/java/net/mjstudio/rnkakao/map/MapCameraCommands.kt +306 -0
  11. package/android/src/main/java/net/mjstudio/rnkakao/map/MapDimScreen.kt +431 -0
  12. package/android/src/main/java/net/mjstudio/rnkakao/map/MapGui.kt +899 -0
  13. package/android/src/main/java/net/mjstudio/rnkakao/map/MapImageLoader.kt +232 -0
  14. package/android/src/main/java/net/mjstudio/rnkakao/map/MapLabels.kt +1572 -0
  15. package/android/src/main/java/net/mjstudio/rnkakao/map/MapOptions.kt +360 -0
  16. package/android/src/main/java/net/mjstudio/rnkakao/map/MapRoutes.kt +805 -0
  17. package/android/src/main/java/net/mjstudio/rnkakao/map/MapShapes.kt +1096 -0
  18. package/android/src/main/java/net/mjstudio/rnkakao/map/MapUtilities.kt +495 -0
  19. package/expo-module.config.json +5 -0
  20. package/ios/CameraOptions.swift +41 -0
  21. package/ios/KakaoMapModule.swift +204 -0
  22. package/ios/KakaoMapView.swift +1018 -0
  23. package/ios/MapCameraCommands.swift +451 -0
  24. package/ios/MapDimScreen.swift +463 -0
  25. package/ios/MapGui.swift +1099 -0
  26. package/ios/MapImageLoader.swift +177 -0
  27. package/ios/MapLabels.swift +1676 -0
  28. package/ios/MapOptions.swift +374 -0
  29. package/ios/MapRoutes.swift +903 -0
  30. package/ios/MapShapes.swift +1294 -0
  31. package/ios/MapUnits.swift +20 -0
  32. package/ios/MapUtilities.swift +365 -0
  33. package/lib/commonjs/index.js +180 -29
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/index.web.js +18 -0
  36. package/lib/commonjs/index.web.js.map +1 -0
  37. package/lib/commonjs/package.json +1 -0
  38. package/lib/commonjs/types-assets.js +2 -0
  39. package/lib/commonjs/types-assets.js.map +1 -0
  40. package/lib/commonjs/types-camera.js +6 -0
  41. package/lib/commonjs/types-camera.js.map +1 -0
  42. package/lib/commonjs/types-dim.js +6 -0
  43. package/lib/commonjs/types-dim.js.map +1 -0
  44. package/lib/commonjs/types-gui.js +6 -0
  45. package/lib/commonjs/types-gui.js.map +1 -0
  46. package/lib/commonjs/types-labels.js +6 -0
  47. package/lib/commonjs/types-labels.js.map +1 -0
  48. package/lib/commonjs/types-routes.js +6 -0
  49. package/lib/commonjs/types-routes.js.map +1 -0
  50. package/lib/commonjs/types-shapes.js +6 -0
  51. package/lib/commonjs/types-shapes.js.map +1 -0
  52. package/lib/commonjs/types-utilities.js +6 -0
  53. package/lib/commonjs/types-utilities.js.map +1 -0
  54. package/lib/commonjs/types.js +6 -0
  55. package/lib/commonjs/types.js.map +1 -0
  56. package/lib/module/index.js +180 -22
  57. package/lib/module/index.js.map +1 -1
  58. package/lib/module/index.web.js +13 -0
  59. package/lib/module/index.web.js.map +1 -0
  60. package/lib/module/types-assets.js +2 -0
  61. package/lib/module/types-assets.js.map +1 -0
  62. package/lib/module/types-camera.js +4 -0
  63. package/lib/module/types-camera.js.map +1 -0
  64. package/lib/module/types-dim.js +4 -0
  65. package/lib/module/types-dim.js.map +1 -0
  66. package/lib/module/types-gui.js +4 -0
  67. package/lib/module/types-gui.js.map +1 -0
  68. package/lib/module/types-labels.js +4 -0
  69. package/lib/module/types-labels.js.map +1 -0
  70. package/lib/module/types-routes.js +4 -0
  71. package/lib/module/types-routes.js.map +1 -0
  72. package/lib/module/types-shapes.js +4 -0
  73. package/lib/module/types-shapes.js.map +1 -0
  74. package/lib/module/types-utilities.js +4 -0
  75. package/lib/module/types-utilities.js.map +1 -0
  76. package/lib/module/types.js +4 -0
  77. package/lib/module/types.js.map +1 -0
  78. package/lib/typescript/src/index.d.ts +59 -9
  79. package/lib/typescript/src/index.d.ts.map +1 -1
  80. package/lib/typescript/src/types-assets.d.ts +9 -0
  81. package/lib/typescript/src/types-assets.d.ts.map +1 -0
  82. package/lib/typescript/src/types-camera.d.ts +123 -0
  83. package/lib/typescript/src/types-camera.d.ts.map +1 -0
  84. package/lib/typescript/src/types-dim.d.ts +54 -0
  85. package/lib/typescript/src/types-dim.d.ts.map +1 -0
  86. package/lib/typescript/src/types-gui.d.ts +189 -0
  87. package/lib/typescript/src/types-gui.d.ts.map +1 -0
  88. package/lib/typescript/src/types-labels.d.ts +246 -0
  89. package/lib/typescript/src/types-labels.d.ts.map +1 -0
  90. package/lib/typescript/src/types-routes.d.ts +162 -0
  91. package/lib/typescript/src/types-routes.d.ts.map +1 -0
  92. package/lib/typescript/src/types-shapes.d.ts +253 -0
  93. package/lib/typescript/src/types-shapes.d.ts.map +1 -0
  94. package/lib/typescript/src/types-utilities.d.ts +130 -0
  95. package/lib/typescript/src/types-utilities.d.ts.map +1 -0
  96. package/lib/typescript/src/types.d.ts +222 -0
  97. package/lib/typescript/src/types.d.ts.map +1 -0
  98. package/package.json +24 -77
  99. package/src/index.tsx +322 -0
  100. package/src/index.web.tsx +46 -0
  101. package/src/types-assets.ts +8 -0
  102. package/src/types-camera.ts +97 -0
  103. package/src/types-dim.ts +54 -0
  104. package/src/types-gui.ts +171 -0
  105. package/src/types-labels.ts +207 -0
  106. package/src/types-routes.ts +118 -0
  107. package/src/types-shapes.ts +244 -0
  108. package/src/types-utilities.ts +115 -0
  109. package/src/types.ts +223 -0
  110. package/android/gradle.properties +0 -1
  111. package/android/src/main/AndroidManifestNew.xml +0 -2
  112. package/android/src/main/java/net/mjstudio/rnkakao/map/RNCKakaoMapModule.kt +0 -25
  113. package/android/src/main/java/net/mjstudio/rnkakao/map/RNCKakaoMapPackage.kt +0 -35
  114. package/android/src/newarch/KakaoMapSpec.kt +0 -7
  115. package/android/src/oldarch/KakaoMapSpec.kt +0 -14
  116. package/ios/Component/Map/RNCKakaoMapView.h +0 -23
  117. package/ios/Component/Map/RNCKakaoMapView.mm +0 -209
  118. package/ios/Component/Map/RNCKakaoMapViewManager.mm +0 -19
  119. package/ios/NativeModule/RNCKakaoMap.h +0 -7
  120. package/ios/NativeModule/RNCKakaoMap.mm +0 -19
  121. package/ios/NativeModule/RNCKakaoMapManager.swift +0 -7
  122. package/ios/Util/Util.h +0 -97
  123. package/lib/commonjs/component/KakaoMapView.js +0 -54
  124. package/lib/commonjs/component/KakaoMapView.js.map +0 -1
  125. package/lib/commonjs/spec/NativeKakaoMap.js +0 -9
  126. package/lib/commonjs/spec/NativeKakaoMap.js.map +0 -1
  127. package/lib/commonjs/spec/RNCKakaoMapViewNativeComponent.js +0 -15
  128. package/lib/commonjs/spec/RNCKakaoMapViewNativeComponent.js.map +0 -1
  129. package/lib/commonjs/type/type.js +0 -2
  130. package/lib/commonjs/type/type.js.map +0 -1
  131. package/lib/commonjs/util/Const.js +0 -14
  132. package/lib/commonjs/util/Const.js.map +0 -1
  133. package/lib/commonjs/util/Util.js +0 -15
  134. package/lib/commonjs/util/Util.js.map +0 -1
  135. package/lib/module/component/KakaoMapView.js +0 -48
  136. package/lib/module/component/KakaoMapView.js.map +0 -1
  137. package/lib/module/spec/NativeKakaoMap.js +0 -3
  138. package/lib/module/spec/NativeKakaoMap.js.map +0 -1
  139. package/lib/module/spec/RNCKakaoMapViewNativeComponent.js +0 -10
  140. package/lib/module/spec/RNCKakaoMapViewNativeComponent.js.map +0 -1
  141. package/lib/module/type/type.js +0 -2
  142. package/lib/module/type/type.js.map +0 -1
  143. package/lib/module/util/Const.js +0 -8
  144. package/lib/module/util/Const.js.map +0 -1
  145. package/lib/module/util/Util.js +0 -9
  146. package/lib/module/util/Util.js.map +0 -1
  147. package/lib/typescript/src/component/KakaoMapView.d.ts +0 -25
  148. package/lib/typescript/src/component/KakaoMapView.d.ts.map +0 -1
  149. package/lib/typescript/src/spec/NativeKakaoMap.d.ts +0 -7
  150. package/lib/typescript/src/spec/NativeKakaoMap.d.ts.map +0 -1
  151. package/lib/typescript/src/spec/RNCKakaoMapViewNativeComponent.d.ts +0 -32
  152. package/lib/typescript/src/spec/RNCKakaoMapViewNativeComponent.d.ts.map +0 -1
  153. package/lib/typescript/src/type/type.d.ts +0 -8
  154. package/lib/typescript/src/type/type.d.ts.map +0 -1
  155. package/lib/typescript/src/util/Const.d.ts +0 -8
  156. package/lib/typescript/src/util/Const.d.ts.map +0 -1
  157. package/lib/typescript/src/util/Util.d.ts +0 -3
  158. package/lib/typescript/src/util/Util.d.ts.map +0 -1
  159. package/src/component/KakaoMapView.tsx +0 -82
  160. package/src/index.ts +0 -43
  161. package/src/spec/NativeKakaoMap.ts +0 -8
  162. package/src/spec/RNCKakaoMapViewNativeComponent.ts +0 -38
  163. package/src/type/type.ts +0 -7
  164. package/src/util/Const.ts +0 -7
  165. package/src/util/Util.ts +0 -11
@@ -0,0 +1,1257 @@
1
+ package net.mjstudio.rnkakao.map
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import com.facebook.react.bridge.LifecycleEventListener
7
+ import com.facebook.react.bridge.ReactContext
8
+ import com.facebook.react.common.LifecycleState
9
+ import com.kakao.vectormap.GestureType
10
+ import com.kakao.vectormap.KakaoMap
11
+ import com.kakao.vectormap.KakaoMapReadyCallback
12
+ import com.kakao.vectormap.LatLng
13
+ import com.kakao.vectormap.MapAuthException
14
+ import com.kakao.vectormap.MapLifeCycleCallback
15
+ import com.kakao.vectormap.MapView
16
+ import com.kakao.vectormap.MapViewInfo
17
+ import com.kakao.vectormap.camera.CameraAnimation
18
+ import com.kakao.vectormap.camera.CameraPosition
19
+ import com.kakao.vectormap.camera.CameraUpdate
20
+ import com.kakao.vectormap.camera.CameraUpdateFactory
21
+ import expo.modules.kotlin.AppContext
22
+ import expo.modules.kotlin.Promise
23
+ import expo.modules.kotlin.exception.CodedException
24
+ import expo.modules.kotlin.viewevent.EventDispatcher
25
+ import expo.modules.kotlin.views.ExpoView
26
+ import java.lang.ref.WeakReference
27
+
28
+ private const val CAMERA_REQUEST_TIMEOUT_MILLIS = 3_000L
29
+ private const val CAMERA_TIMEOUT = "ERR_KAKAO_MAP_CAMERA_TIMEOUT"
30
+ private const val CAMERA_SUPERSEDED = "ERR_KAKAO_MAP_CAMERA_SUPERSEDED"
31
+ private const val SHAPE_INVALID_ARGUMENT = "ERR_KAKAO_MAP_SHAPE_INVALID_ARGUMENT"
32
+ private const val ROUTE_INVALID_ARGUMENT = "ERR_KAKAO_MAP_ROUTE_INVALID_ARGUMENT"
33
+ private const val VIEW_DESTROYED = "ERR_KAKAO_MAP_VIEW_DESTROYED"
34
+ private const val VIEW_DESTROYED_MESSAGE = "The map view was destroyed."
35
+
36
+ class KakaoMapView(
37
+ context: Context,
38
+ appContext: AppContext,
39
+ ) : ExpoView(context, appContext),
40
+ LifecycleEventListener {
41
+ override val shouldUseAndroidLayout = true
42
+
43
+ private val onMapReady by EventDispatcher()
44
+ private val onError by EventDispatcher()
45
+ private val onCameraMoveStart by EventDispatcher()
46
+ private val onCameraIdle by EventDispatcher()
47
+ private val onMapClick by EventDispatcher()
48
+ private val onMapLongClick by EventDispatcher()
49
+ private val onTerrainClick by EventDispatcher()
50
+ private val onViewportClick by EventDispatcher()
51
+ private val onVisibilityChanged by EventDispatcher()
52
+ private val onFocusChanged by EventDispatcher()
53
+ private val onPoiClick by EventDispatcher()
54
+ private val onCompassClick by EventDispatcher()
55
+ private val onMapTypeChanged by EventDispatcher()
56
+ private val onPaddingChanged by EventDispatcher()
57
+ private val onViewportChanged by EventDispatcher()
58
+ private val onLabelClick by EventDispatcher()
59
+ private val onLabelAnimationStopped by EventDispatcher()
60
+ private val onLabelsChanged by EventDispatcher()
61
+ private val onShapeAnimationStop by EventDispatcher()
62
+ private val onShapesChanged by EventDispatcher()
63
+ private val onRoutesChanged by EventDispatcher()
64
+ private val onRouteProgress by EventDispatcher()
65
+ private val onRouteAnimationStop by EventDispatcher()
66
+ private val onGuiEvent by EventDispatcher()
67
+ private val onDimScreenEvent by EventDispatcher()
68
+ private var labels: MapLabels? = null
69
+ private var shapes: MapShapes? = null
70
+ private var routes: MapRoutes? = null
71
+ private var gui: MapGui? = null
72
+ private var dimScreen: MapDimScreen? = null
73
+ private var utilities: MapUtilities? = null
74
+ private var cameraCommands: MapCameraCommands? = null
75
+ private var imageLoader: MapImageLoader? = null
76
+ private var pendingLabels: MapLabelSceneRecord? = null
77
+ private var pendingShapes: Map<String, Any?>? = null
78
+ private var pendingRoutes: Map<String, Any?>? = null
79
+ private var hasUsedRoutes = false
80
+ private var routeGeneration = 0L
81
+ private var labelGeneration = 0L
82
+ private var mapView: MapView? = null
83
+ private var map: KakaoMap? = null
84
+ private var isDestroyed = false
85
+ private var isStarted = false
86
+ private var hasInitialProps = false
87
+ private var initialCamera: CameraOptions? = null
88
+ private var lastCameraValues: List<Double>? = null
89
+ private var queuedOptions: MapOptionsRecord? = null
90
+ private val appliedOptions = MapOptionsRecord()
91
+ private val activeOverlays = mutableSetOf<String>()
92
+ private val activeCustomOverlays = mutableSetOf<String>()
93
+ private val mainHandler = Handler(Looper.getMainLooper())
94
+ private val pendingCameraRequests = mutableMapOf<Any, PendingCameraRequest>()
95
+ private val pendingCameraDispatches = mutableMapOf<Any, PendingCameraRequest>()
96
+ private var cameraCommandGeneration = 0L
97
+ private val reactContext = appContext.reactContext as? ReactContext
98
+ var pendingInitialCamera: CameraOptions? = null
99
+ var camera: CameraOptions? = null
100
+ var pendingOptions: MapOptionsRecord? = null
101
+
102
+ init {
103
+ reactContext?.addLifecycleEventListener(this)
104
+ }
105
+
106
+ fun updateProps() {
107
+ if (!hasInitialProps) {
108
+ hasInitialProps = true
109
+ initialCamera = pendingInitialCamera
110
+ }
111
+ if (map != null) {
112
+ val values = camera?.values()
113
+ if (values != lastCameraValues) {
114
+ camera?.let { applyCameraProp(it) }
115
+ lastCameraValues = values
116
+ }
117
+ }
118
+ pendingOptions?.let { options ->
119
+ if (options.mapType != null && options.viewInfo != null) {
120
+ reportError(CodedException(INVALID_ARGUMENT, "Map type and view info are mutually exclusive.", null))
121
+ } else if (map == null) {
122
+ queuedOptions = mergeMapOptions(queuedOptions, options)
123
+ } else {
124
+ applyOptionsSafely(options)
125
+ }
126
+ pendingOptions = null
127
+ }
128
+ applyPendingShapesIfReady()
129
+ applyPendingRoutesIfReady()
130
+ applyPendingLabelsIfReady()
131
+ }
132
+
133
+ fun queueLabels(scene: MapLabelSceneRecord?) {
134
+ if (scene == null && labels == null) {
135
+ pendingLabels = null
136
+ return
137
+ }
138
+ pendingLabels = scene ?: MapLabelSceneRecord()
139
+ }
140
+
141
+ fun queueShapes(scene: Map<String, Any?>?) {
142
+ if (scene == null && shapes == null) {
143
+ pendingShapes = null
144
+ return
145
+ }
146
+ try {
147
+ pendingShapes = normalizeShapeObject(scene ?: emptyMap<String, Any?>(), "shapes")
148
+ } catch (error: Exception) {
149
+ pendingShapes = null
150
+ if (!isDestroyed) reportError(error)
151
+ }
152
+ }
153
+
154
+ fun queueRoutes(scene: Map<String, Any?>?) {
155
+ if (scene == null && !hasUsedRoutes) return
156
+ hasUsedRoutes = true
157
+ try {
158
+ pendingRoutes = normalizeShapeObject(scene ?: emptyMap<String, Any?>(), "routes", ROUTE_INVALID_ARGUMENT)
159
+ } catch (error: Exception) {
160
+ pendingRoutes = null
161
+ if (!isDestroyed) reportError(error)
162
+ }
163
+ }
164
+
165
+ override fun onAttachedToWindow() {
166
+ super.onAttachedToWindow()
167
+ startIfNeeded()
168
+ }
169
+
170
+ private fun startIfNeeded() {
171
+ if (isDestroyed || !isAttachedToWindow || width == 0 || height == 0) return
172
+ if (isStarted) {
173
+ resume()
174
+ return
175
+ }
176
+ isStarted = true
177
+ if (KakaoMapModule.appKey == null) {
178
+ onError(mapOf("code" to NOT_INITIALIZED, "message" to "Call initializeKakaoMapSDK before mounting the map."))
179
+ return
180
+ }
181
+ val view = MapView(context)
182
+ mapView = view
183
+ addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
184
+ view.start(
185
+ object : MapLifeCycleCallback() {
186
+ override fun onMapDestroy() {
187
+ releaseMapObjects()
188
+ map = null
189
+ rejectPendingCameraRequests(NOT_READY, "The map was destroyed before returning its camera position.")
190
+ rejectPendingCameraDispatches(NOT_READY, "The map was destroyed before dispatching the camera update.")
191
+ }
192
+
193
+ override fun onMapError(error: Exception) {
194
+ if (isDestroyed) return
195
+ releaseMapObjects()
196
+ map = null
197
+ val code = if (error is MapAuthException) error.errorCode.toString() else error.javaClass.simpleName
198
+ val message = error.message ?: "Map error"
199
+ rejectPendingCameraRequests(code, message)
200
+ rejectPendingCameraDispatches(code, message)
201
+ onError(
202
+ mapOf(
203
+ "code" to code,
204
+ "message" to message,
205
+ ),
206
+ )
207
+ }
208
+ },
209
+ object : KakaoMapReadyCallback() {
210
+ override fun onMapReady(readyMap: KakaoMap) {
211
+ if (isDestroyed) return
212
+ map = readyMap
213
+ readyMap.setOnCameraMoveStartListener { _, gesture ->
214
+ if (!isDestroyed) onCameraMoveStart(mapOf("reason" to reason(gesture)))
215
+ }
216
+ readyMap.setOnCameraMoveEndListener { _, position, gesture ->
217
+ if (!isDestroyed) onCameraIdle(mapOf("reason" to reason(gesture), "camera" to position.toPayload()))
218
+ }
219
+ readyMap.setOnMapClickListener { _, position, point, _ ->
220
+ if (!isDestroyed) {
221
+ onMapClick(
222
+ mapOf(
223
+ "latitude" to position.latitude,
224
+ "longitude" to position.longitude,
225
+ "x" to point.x / resources.displayMetrics.density,
226
+ "y" to point.y / resources.displayMetrics.density,
227
+ ),
228
+ )
229
+ }
230
+ }
231
+ readyMap.setOnTerrainClickListener { _, position, _ ->
232
+ if (!isDestroyed) onTerrainClick(mapOf("latitude" to position.latitude, "longitude" to position.longitude))
233
+ }
234
+ readyMap.setOnViewportClickListener { _, position, point ->
235
+ if (!isDestroyed) {
236
+ val density = resources.displayMetrics.density
237
+ onViewportClick(
238
+ mapOf(
239
+ "latitude" to position.latitude,
240
+ "longitude" to position.longitude,
241
+ "x" to point.x / density,
242
+ "y" to point.y / density,
243
+ ),
244
+ )
245
+ }
246
+ }
247
+ readyMap.setOnVisibleChangeListener { _, visible ->
248
+ if (!isDestroyed) onVisibilityChanged(mapOf("visible" to visible))
249
+ }
250
+ readyMap.setOnTerrainLongClickListener { _, position, _ ->
251
+ if (!isDestroyed) {
252
+ onMapLongClick(mapOf("latitude" to position.latitude, "longitude" to position.longitude))
253
+ }
254
+ }
255
+ readyMap.setOnPoiClickListener { _, position, layerId, id ->
256
+ if (!isDestroyed) {
257
+ onPoiClick(
258
+ mapOf(
259
+ "latitude" to position.latitude,
260
+ "longitude" to position.longitude,
261
+ "id" to id,
262
+ "layerId" to layerId,
263
+ ),
264
+ )
265
+ }
266
+ }
267
+ readyMap.setOnCompassClickListener { _, _ ->
268
+ if (!isDestroyed) onCompassClick(emptyMap<String, Any>())
269
+ }
270
+ readyMap.setOnMapViewInfoChangeListener(
271
+ object : KakaoMap.OnMapViewInfoChangeListener {
272
+ override fun onMapViewInfoChanged(mapViewInfo: MapViewInfo) {
273
+ if (!isDestroyed) onMapTypeChanged(mapOf("viewInfoName" to mapViewInfo.mapType))
274
+ }
275
+
276
+ override fun onMapViewInfoChangeFailed() {
277
+ if (!isDestroyed) {
278
+ onError(
279
+ mapOf(
280
+ "code" to "ERR_KAKAO_MAP_VIEW_INFO_CHANGE_FAILED",
281
+ "message" to "Kakao Maps could not change the map view information.",
282
+ ),
283
+ )
284
+ }
285
+ }
286
+ },
287
+ )
288
+ readyMap.setOnPaddingChangeListener { changedMap ->
289
+ if (!isDestroyed) {
290
+ val density = resources.displayMetrics.density
291
+ val padding = changedMap.padding
292
+ onPaddingChanged(
293
+ mapOf(
294
+ "top" to padding.top / density,
295
+ "right" to padding.right / density,
296
+ "bottom" to padding.bottom / density,
297
+ "left" to padding.left / density,
298
+ ),
299
+ )
300
+ }
301
+ }
302
+ readyMap.setOnViewportChangeListener { _, viewport ->
303
+ if (!isDestroyed) {
304
+ val density = resources.displayMetrics.density
305
+ onViewportChanged(
306
+ mapOf(
307
+ "width" to viewport.width() / density,
308
+ "height" to viewport.height() / density,
309
+ "x" to viewport.left / density,
310
+ "y" to viewport.top / density,
311
+ ),
312
+ )
313
+ }
314
+ }
315
+ queuedOptions?.let(::applyOptionsSafely)
316
+ queuedOptions = null
317
+ (camera ?: initialCamera)?.let { applyCameraProp(it) }
318
+ lastCameraValues = camera?.values()
319
+ applyPendingShapesIfReady()
320
+ applyPendingRoutesIfReady()
321
+ applyPendingLabelsIfReady()
322
+ onMapReady(emptyMap<String, Any>())
323
+ if (!isAttachedToWindow || reactContext?.lifecycleState != LifecycleState.RESUMED) view.pause()
324
+ }
325
+ },
326
+ )
327
+ view.setFinishManually(true)
328
+ if (reactContext?.lifecycleState == LifecycleState.RESUMED && isAttachedToWindow) {
329
+ view.resume()
330
+ } else {
331
+ view.pause()
332
+ }
333
+ }
334
+
335
+ fun moveCamera(
336
+ camera: CameraOptions,
337
+ duration: Double,
338
+ ) {
339
+ val map = requireMap()
340
+ val durationMillis = validateDuration(duration)
341
+ val update = CameraUpdateFactory.newCameraPosition(camera.position(map.cameraMinLevel, map.cameraMaxLevel))
342
+ beginCameraCommand()
343
+ dispatchCameraUpdate(map, update, durationMillis)
344
+ }
345
+
346
+ fun updateCamera(
347
+ update: CameraUpdateRecord,
348
+ duration: Double,
349
+ promise: Promise,
350
+ ) {
351
+ try {
352
+ val map = requireMap()
353
+ val durationMillis = validateDuration(duration)
354
+ if (update.type == "orientation") {
355
+ updateOrientation(map, update, durationMillis, promise)
356
+ return
357
+ }
358
+ val cameraUpdate =
359
+ when (update.type) {
360
+ "position" -> {
361
+ val position = update.position ?: throw invalidCameraUpdate("position")
362
+ position.validate("Camera position")
363
+ requireOnlyCameraFields(update, "position")
364
+ CameraUpdateFactory.newCenterPosition(LatLng.from(position.latitude, position.longitude))
365
+ }
366
+
367
+ "zoom" -> {
368
+ val zoomLevel = update.zoomLevel ?: throw invalidCameraUpdate("zoomLevel")
369
+ if (!zoomLevel.isFinite() || zoomLevel % 1.0 != 0.0 || zoomLevel < map.cameraMinLevel || zoomLevel > map.cameraMaxLevel) {
370
+ throw CodedException(INVALID_ARGUMENT, "Camera zoom level is outside the configured zoom range.", null)
371
+ }
372
+ requireOnlyCameraFields(update, "zoom")
373
+ CameraUpdateFactory.zoomTo(zoomLevel.toInt())
374
+ }
375
+
376
+ "fit" -> {
377
+ val coordinates = update.coordinates ?: throw invalidCameraUpdate("coordinates")
378
+ if (coordinates.isEmpty()) {
379
+ throw CodedException(INVALID_ARGUMENT, "Camera fit requires at least one coordinate.", null)
380
+ }
381
+ coordinates.forEachIndexed { index, coordinate -> coordinate.validate("Camera fit coordinate $index") }
382
+ requireOnlyCameraFields(update, "fit")
383
+ CameraUpdateFactory.fitMapPoints(
384
+ coordinates.map { LatLng.from(it.latitude, it.longitude) }.toTypedArray(),
385
+ )
386
+ }
387
+
388
+ else -> {
389
+ throw CodedException(INVALID_ARGUMENT, "Unsupported camera update type: ${update.type}.", null)
390
+ }
391
+ }
392
+ beginCameraCommand()
393
+ dispatchCameraUpdate(map, cameraUpdate, durationMillis)
394
+ promise.resolve(null)
395
+ } catch (error: CodedException) {
396
+ promise.reject(error)
397
+ } catch (error: Exception) {
398
+ promise.reject(error.javaClass.simpleName, error.message ?: "Camera update failed.", error)
399
+ }
400
+ }
401
+
402
+ fun getMapInfo(): Map<String, Any> {
403
+ val map = requireMap()
404
+ val density = resources.displayMetrics.density
405
+ val padding = map.padding
406
+ val viewport = map.viewport
407
+ val mapViewInfo =
408
+ map.mapViewInfo
409
+ ?: throw CodedException("ERR_KAKAO_MAP_VIEW_INFO_UNAVAILABLE", "Kakao Maps did not return map view information.", null)
410
+ return mapOf(
411
+ "mapType" to mapTypePayload(mapViewInfo.mapType),
412
+ "viewInfoName" to mapViewInfo.mapType,
413
+ "zoomRange" to mapOf("min" to map.cameraMinLevel, "max" to map.cameraMaxLevel),
414
+ "padding" to
415
+ mapOf(
416
+ "top" to padding.top / density,
417
+ "right" to padding.right / density,
418
+ "bottom" to padding.bottom / density,
419
+ "left" to padding.left / density,
420
+ ),
421
+ "size" to mapOf("width" to viewport.width() / density, "height" to viewport.height() / density),
422
+ "viewport" to
423
+ mapOf(
424
+ "x" to viewport.left / density,
425
+ "y" to viewport.top / density,
426
+ "width" to viewport.width() / density,
427
+ "height" to viewport.height() / density,
428
+ ),
429
+ "language" to (map.poiLanguage ?: ""),
430
+ "supportedLanguages" to (map.supportedLanguages?.toList() ?: emptyList<String>()),
431
+ "capabilities" to mapOf("coordinateToScreen" to true),
432
+ )
433
+ }
434
+
435
+ fun screenToCoordinate(point: ScreenPointRecord): Map<String, Double> {
436
+ val map = requireMap()
437
+ point.validate()
438
+ validatePixelValue(point.x)
439
+ validatePixelValue(point.y)
440
+ val density = resources.displayMetrics.density
441
+ val x = (point.x * density).toInt()
442
+ val y = (point.y * density).toInt()
443
+ if (!map.viewport.contains(x, y)) {
444
+ throw CodedException(INVALID_ARGUMENT, "Screen point must be inside the map viewport.", null)
445
+ }
446
+ val coordinate =
447
+ map.fromScreenPoint(x, y)
448
+ ?: throw CodedException("ERR_KAKAO_MAP_PROJECTION_FAILED", "The screen point could not be projected to a coordinate.", null)
449
+ return mapOf("latitude" to coordinate.latitude, "longitude" to coordinate.longitude)
450
+ }
451
+
452
+ fun coordinateToScreen(coordinate: CoordinateRecord): Map<String, Double> {
453
+ val map = requireMap()
454
+ coordinate.validate()
455
+ val density = resources.displayMetrics.density
456
+ val point =
457
+ map.toScreenPoint(LatLng.from(coordinate.latitude, coordinate.longitude))
458
+ ?: throw CodedException("ERR_KAKAO_MAP_PROJECTION_FAILED", "The coordinate could not be projected to a screen point.", null)
459
+ return mapOf("x" to point.x / density.toDouble(), "y" to point.y / density.toDouble())
460
+ }
461
+
462
+ fun clearCache() {
463
+ requireUtilities().command(mapOf("type" to "clearCache", "kind" to "all"))
464
+ }
465
+
466
+ internal fun requireUtilities(): MapUtilities {
467
+ val readyMap = requireMap()
468
+ return utilities ?: MapUtilities(readyMap, context).also { utilities = it }
469
+ }
470
+
471
+ fun executeCameraCommand(
472
+ command: Map<String, Any?>,
473
+ promise: Promise,
474
+ ) {
475
+ if (command["type"] == "getState") {
476
+ getCamera(promise, isAdvancedState = true)
477
+ return
478
+ }
479
+ val result = requireCameraCommands().command(command)
480
+ if (result["type"] == "dispatched") beginCameraCommand()
481
+ promise.resolve(result)
482
+ }
483
+
484
+ private fun requireCameraCommands(): MapCameraCommands {
485
+ val readyMap = requireMap()
486
+ return cameraCommands ?: MapCameraCommands(readyMap, resources.displayMetrics.density).also { cameraCommands = it }
487
+ }
488
+
489
+ fun runLabelCommand(command: MapLabelCommandRecord) {
490
+ requireLabels().command(command)
491
+ }
492
+
493
+ fun getLabelCapabilities() = requireLabels().capabilities()
494
+
495
+ fun getLabelState() = requireLabels().state()
496
+
497
+ fun applyShapes(scene: Map<String, Any?>): Map<String, Any> {
498
+ pendingShapes = null
499
+ val counts = requireShapes().apply(normalizeShapeObject(scene, "scene"))
500
+ if (!isDestroyed) onShapesChanged(counts)
501
+ return counts
502
+ }
503
+
504
+ fun executeShapeCommand(command: Map<String, Any?>): Map<String, Any?> = requireShapes().command(normalizeShapeObject(command, "command"))
505
+
506
+ private fun requireShapes(): MapShapes {
507
+ val readyMap = requireMap()
508
+ shapes?.let { return it }
509
+ val viewReference = WeakReference(this)
510
+ lateinit var manager: MapShapes
511
+ manager =
512
+ MapShapes(readyMap, resources.displayMetrics.density) { event, payload ->
513
+ viewReference.get()?.let { view ->
514
+ if (!view.isDestroyed && view.shapes === manager && event == "onShapeAnimationStop") {
515
+ (payload["animationId"] as? String)?.let { animationId ->
516
+ view.onShapeAnimationStop(mapOf("animationId" to animationId))
517
+ }
518
+ }
519
+ }
520
+ }
521
+ shapes = manager
522
+ return manager
523
+ }
524
+
525
+ private fun requireLabels(): MapLabels {
526
+ val readyMap = requireMap()
527
+ labels?.let { return it }
528
+ val loader = requireImageLoader()
529
+ val viewReference = WeakReference(this)
530
+ val manager =
531
+ MapLabels(
532
+ readyMap,
533
+ loader,
534
+ emitClick = { event ->
535
+ viewReference.get()?.let { view -> if (!view.isDestroyed) view.onLabelClick(event) }
536
+ },
537
+ emitAnimationStopped = { event ->
538
+ viewReference.get()?.let { view -> if (!view.isDestroyed) view.onLabelAnimationStopped(event) }
539
+ },
540
+ resolvePolygon = { id -> viewReference.get()?.shapes?.resolvePolygon(id) },
541
+ density = resources.displayMetrics.density,
542
+ )
543
+ labels = manager
544
+ readyMap.setOnLabelClickListener { _, _, label ->
545
+ viewReference.get()?.labels?.handleLabelClick(label)
546
+ true
547
+ }
548
+ readyMap.setOnLodLabelClickListener { _, _, label ->
549
+ viewReference.get()?.labels?.handleLodLabelClick(label)
550
+ true
551
+ }
552
+ return manager
553
+ }
554
+
555
+ fun applyRoutes(
556
+ scene: Map<String, Any?>,
557
+ promise: Promise,
558
+ ) {
559
+ pendingRoutes = null
560
+ hasUsedRoutes = true
561
+ val generation = ++routeGeneration
562
+ val viewReference = WeakReference(this)
563
+ requireRoutes().apply(normalizeShapeObject(scene, "scene", ROUTE_INVALID_ARGUMENT)) { result ->
564
+ result.fold(
565
+ onSuccess = { counts ->
566
+ viewReference.get()?.let { view ->
567
+ if (!view.isDestroyed && generation == view.routeGeneration) view.onRoutesChanged(counts)
568
+ }
569
+ promise.resolve(counts)
570
+ },
571
+ onFailure = { error ->
572
+ if (error is CodedException) {
573
+ promise.reject(error)
574
+ } else {
575
+ promise.reject("ERR_KAKAO_MAP_ROUTE", error.message, error)
576
+ }
577
+ },
578
+ )
579
+ }
580
+ }
581
+
582
+ fun executeRouteCommand(command: Map<String, Any?>): Map<String, Any?> =
583
+ requireRoutes().command(normalizeShapeObject(command, "command", ROUTE_INVALID_ARGUMENT))
584
+
585
+ private fun requireRoutes(): MapRoutes {
586
+ val readyMap = requireMap()
587
+ routes?.let { return it }
588
+ val viewReference = WeakReference(this)
589
+ val manager =
590
+ MapRoutes(readyMap, resources.displayMetrics.density, requireImageLoader()) { event, payload ->
591
+ viewReference.get()?.let { view ->
592
+ if (!view.isDestroyed) {
593
+ when (event) {
594
+ "onRouteProgress" -> view.onRouteProgress(payload)
595
+ "onRouteAnimationStop" -> view.onRouteAnimationStop(payload)
596
+ }
597
+ }
598
+ }
599
+ }
600
+ routes = manager
601
+ return manager
602
+ }
603
+
604
+ internal fun requireGui(): MapGui {
605
+ val readyMap = requireMap()
606
+ gui?.let { return it }
607
+ val viewReference = WeakReference(this)
608
+ val manager =
609
+ MapGui(readyMap, resources.displayMetrics.density, requireImageLoader()) { payload ->
610
+ viewReference.get()?.let { view -> if (!view.isDestroyed) view.onGuiEvent(payload) }
611
+ }
612
+ gui = manager
613
+ return manager
614
+ }
615
+
616
+ internal fun requireDimScreen(): MapDimScreen {
617
+ val readyMap = requireMap()
618
+ dimScreen?.let { return it }
619
+ val viewReference = WeakReference(this)
620
+ val manager =
621
+ MapDimScreen(readyMap, resources.displayMetrics.density) { payload ->
622
+ viewReference.get()?.let { view -> if (!view.isDestroyed) view.onDimScreenEvent(payload) }
623
+ }
624
+ dimScreen = manager
625
+ return manager
626
+ }
627
+
628
+ private fun requireImageLoader(): MapImageLoader = imageLoader ?: MapImageLoader(appContext).also { imageLoader = it }
629
+
630
+ private fun applyPendingRoutesIfReady() {
631
+ if (map == null) return
632
+ val scene = pendingRoutes ?: return
633
+ pendingRoutes = null
634
+ val generation = ++routeGeneration
635
+ val viewReference = WeakReference(this)
636
+ try {
637
+ requireRoutes().apply(scene) { result ->
638
+ viewReference.get()?.let { view ->
639
+ if (!view.isDestroyed && generation == view.routeGeneration) {
640
+ result.fold(
641
+ onSuccess = { view.onRoutesChanged(it) },
642
+ onFailure = { view.reportError(it as? Exception ?: Exception(it)) },
643
+ )
644
+ }
645
+ }
646
+ }
647
+ } catch (error: Exception) {
648
+ reportError(error)
649
+ }
650
+ }
651
+
652
+ private fun applyPendingLabelsIfReady() {
653
+ if (map == null) return
654
+ val scene = pendingLabels ?: return
655
+ pendingLabels = null
656
+ val previousGeneration = labelGeneration
657
+ val generation = ++labelGeneration
658
+ try {
659
+ val viewReference = WeakReference(this)
660
+ requireLabels().apply(scene) { result ->
661
+ viewReference.get()?.let { view ->
662
+ if (!view.isDestroyed && generation == view.labelGeneration) {
663
+ result.fold(
664
+ onSuccess = { view.labels?.let { view.onLabelsChanged(it.state()) } },
665
+ onFailure = { error -> view.reportError(error as? Exception ?: Exception(error)) },
666
+ )
667
+ }
668
+ }
669
+ }
670
+ } catch (error: Exception) {
671
+ if (labelGeneration == generation) labelGeneration = previousGeneration
672
+ reportError(error)
673
+ }
674
+ }
675
+
676
+ private fun applyPendingShapesIfReady() {
677
+ if (map == null) return
678
+ val scene = pendingShapes ?: return
679
+ pendingShapes = null
680
+ try {
681
+ val counts = requireShapes().apply(scene)
682
+ if (!isDestroyed) onShapesChanged(counts)
683
+ } catch (error: Exception) {
684
+ reportError(error)
685
+ }
686
+ }
687
+
688
+ private fun releaseMapObjects() {
689
+ labelGeneration++
690
+ routeGeneration++
691
+ utilities?.dispose()
692
+ utilities = null
693
+ cameraCommands?.dispose()
694
+ cameraCommands = null
695
+ val manager = labels
696
+ labels = null
697
+ try {
698
+ manager?.dispose()
699
+ } catch (error: Exception) {
700
+ if (!isDestroyed) reportError(error)
701
+ }
702
+ val guiManager = gui
703
+ gui = null
704
+ try {
705
+ guiManager?.dispose()
706
+ } catch (error: Exception) {
707
+ if (!isDestroyed) reportError(error)
708
+ }
709
+ val dimManager = dimScreen
710
+ dimScreen = null
711
+ try {
712
+ dimManager?.dispose()
713
+ } catch (error: Exception) {
714
+ if (!isDestroyed) reportError(error)
715
+ }
716
+ val routeManager = routes
717
+ routes = null
718
+ try {
719
+ routeManager?.dispose()
720
+ } catch (error: Exception) {
721
+ if (!isDestroyed) reportError(error)
722
+ }
723
+ val shapeManager = shapes
724
+ shapes = null
725
+ try {
726
+ shapeManager?.dispose()
727
+ } catch (error: Exception) {
728
+ if (!isDestroyed) reportError(error)
729
+ }
730
+ val loader = imageLoader
731
+ imageLoader = null
732
+ try {
733
+ loader?.dispose()
734
+ } catch (error: Exception) {
735
+ if (!isDestroyed) reportError(error)
736
+ }
737
+ }
738
+
739
+ private fun applyOptionsSafely(options: MapOptionsRecord) {
740
+ try {
741
+ val map = requireMap()
742
+ validateOptions(map, options)
743
+ val changed = changedMapOptions(appliedOptions, options)
744
+ if (changed.isEmpty()) return
745
+ applyOptions(map, changed)
746
+ mergeMapOptions(appliedOptions, changed)
747
+ } catch (error: Exception) {
748
+ if (!isDestroyed) reportError(error)
749
+ }
750
+ }
751
+
752
+ private fun applyOptions(
753
+ map: KakaoMap,
754
+ options: MapOptionsRecord,
755
+ ) {
756
+ options.viewInfo?.let { viewInfo ->
757
+ val nativeViewInfo =
758
+ options.mapType?.let { MapViewInfo.from(viewInfo.appName ?: "openmap", nativeMapType(it)) }
759
+ ?: MapViewInfo.from(viewInfo.appName ?: "openmap", viewInfo.name)
760
+ map.changeMapViewInfo(nativeViewInfo)
761
+ } ?: options.mapType?.let { map.changeMapType(nativeMapType(it)) }
762
+
763
+ options.overlays?.let { overlays ->
764
+ (activeOverlays - overlays.toSet()).forEach { map.hideOverlay(nativeMapOverlay(it)) }
765
+ (overlays.toSet() - activeOverlays).forEach { map.showOverlay(nativeMapOverlay(it)) }
766
+ activeOverlays.clear()
767
+ activeOverlays += overlays
768
+ }
769
+ options.customOverlays?.let { overlays ->
770
+ (activeCustomOverlays - overlays.toSet()).forEach(map::hideOverlay)
771
+ (overlays.toSet() - activeCustomOverlays).forEach(map::showOverlay)
772
+ activeCustomOverlays.clear()
773
+ activeCustomOverlays += overlays
774
+ }
775
+ options.language?.let { language ->
776
+ if (!map.setPoiLanguage(language)) {
777
+ throw CodedException("ERR_KAKAO_MAP_LANGUAGE_CHANGE_FAILED", "Kakao Maps could not change the POI language to $language.", null)
778
+ }
779
+ }
780
+ options.poi?.let { poi ->
781
+ poi.visible?.let(map::setPoiVisible)
782
+ poi.clickable?.let(map::setPoiClickable)
783
+ poi.scale?.let { map.poiScale = nativePoiScale(it) }
784
+ }
785
+ options.buildingScale?.let { map.buildingHeightScale = it.toFloat() }
786
+ options.padding?.let { padding ->
787
+ map.setPadding(toPixels(padding.left), toPixels(padding.top), toPixels(padding.right), toPixels(padding.bottom))
788
+ }
789
+ options.zoomRange?.let { range ->
790
+ val min = range.min.toInt()
791
+ val max = range.max.toInt()
792
+ if (max < map.cameraMinLevel) {
793
+ map.cameraMinLevel = min
794
+ map.cameraMaxLevel = max
795
+ } else {
796
+ map.cameraMaxLevel = max
797
+ map.cameraMinLevel = min
798
+ }
799
+ }
800
+ options.gestures?.forEach { (gesture, enabled) -> map.setGestureEnable(nativeGesture(gesture), enabled) }
801
+ options.compass?.let { compassOptions ->
802
+ val compass =
803
+ map.compass
804
+ ?: throw CodedException("ERR_KAKAO_MAP_WIDGET_UNAVAILABLE", "The compass is unavailable.", null)
805
+ compassOptions.visible?.let { if (it) compass.show() else compass.hide() }
806
+ compassOptions.position?.let { compass.setPosition(it.nativeGravity(), toPixelsFloat(it.x), toPixelsFloat(it.y)) }
807
+ }
808
+ options.scaleBar?.let { scaleBarOptions ->
809
+ val scaleBar =
810
+ map.scaleBar
811
+ ?: throw CodedException("ERR_KAKAO_MAP_WIDGET_UNAVAILABLE", "The scale bar is unavailable.", null)
812
+ scaleBarOptions.visible?.let { if (it) scaleBar.show() else scaleBar.hide() }
813
+ scaleBarOptions.position?.let { scaleBar.setPosition(it.nativeGravity(), toPixelsFloat(it.x), toPixelsFloat(it.y)) }
814
+ scaleBarOptions.autoHide?.let { scaleBar.isAutoHide = it }
815
+ scaleBarOptions.fade?.let { fade ->
816
+ scaleBar.setFadeInOutTime(
817
+ fade.inDuration.toInt(),
818
+ fade.outDuration.toInt(),
819
+ fade.retentionDuration.toInt(),
820
+ )
821
+ }
822
+ }
823
+ options.logo?.let { position ->
824
+ val logo = map.logo ?: throw CodedException("ERR_KAKAO_MAP_WIDGET_UNAVAILABLE", "The Kakao logo is unavailable.", null)
825
+ logo.setPosition(position.nativeGravity(), toPixelsFloat(position.x), toPixelsFloat(position.y))
826
+ }
827
+ }
828
+
829
+ private fun validateOptions(
830
+ map: KakaoMap,
831
+ options: MapOptionsRecord,
832
+ ) {
833
+ if (options.mapType != null && options.viewInfo != null) {
834
+ throw CodedException(INVALID_ARGUMENT, "Map type and view info are mutually exclusive.", null)
835
+ }
836
+ options.mapType?.let(::nativeMapType)
837
+ options.viewInfo?.validate()
838
+ options.overlays?.forEach(::nativeMapOverlay)
839
+ options.customOverlays?.let { overlays ->
840
+ if (overlays.any(String::isBlank)) {
841
+ throw CodedException(INVALID_ARGUMENT, "Custom overlay IDs must not be blank.", null)
842
+ }
843
+ }
844
+ options.language?.let { language ->
845
+ if (language.isBlank() || language !in (map.supportedLanguages?.toList() ?: emptyList())) {
846
+ throw CodedException(INVALID_ARGUMENT, "Unsupported POI language: $language.", null)
847
+ }
848
+ }
849
+ options.poi?.scale?.let(::nativePoiScale)
850
+ options.buildingScale?.let { scale ->
851
+ if (!scale.isFinite() || scale !in 0.0..1.0) {
852
+ throw CodedException(INVALID_ARGUMENT, "Building scale must be between 0 and 1.", null)
853
+ }
854
+ }
855
+ options.padding?.let { padding ->
856
+ padding.validate()
857
+ listOf(padding.top, padding.right, padding.bottom, padding.left).forEach(::validatePixelValue)
858
+ }
859
+ options.zoomRange?.let { range ->
860
+ if (
861
+ !range.min.isFinite() ||
862
+ !range.max.isFinite() ||
863
+ range.min % 1.0 != 0.0 ||
864
+ range.max % 1.0 != 0.0 ||
865
+ range.min > range.max ||
866
+ range.min < map.minZoomLevel ||
867
+ range.max > map.maxZoomLevel
868
+ ) {
869
+ throw CodedException(INVALID_ARGUMENT, "Zoom range must contain integer levels within the SDK limits.", null)
870
+ }
871
+ }
872
+ options.gestures?.keys?.forEach(::nativeGesture)
873
+ options.compass?.position?.validate("Compass position")
874
+ options.scaleBar?.let { scaleBar ->
875
+ scaleBar.position?.validate("Scale bar position")
876
+ scaleBar.fade?.let { fade ->
877
+ validateDuration(fade.inDuration)
878
+ validateDuration(fade.outDuration)
879
+ validateDuration(fade.retentionDuration)
880
+ }
881
+ }
882
+ options.logo?.validate("Logo position")
883
+ listOfNotNull(options.compass?.position, options.scaleBar?.position, options.logo).forEach { position ->
884
+ validatePixelValue(position.x)
885
+ validatePixelValue(position.y)
886
+ }
887
+ }
888
+
889
+ private fun dispatchCameraUpdate(
890
+ map: KakaoMap,
891
+ update: CameraUpdate,
892
+ durationMillis: Int,
893
+ ) {
894
+ if (durationMillis == 0) map.moveCamera(update) else map.moveCamera(update, CameraAnimation.from(durationMillis, false, false))
895
+ }
896
+
897
+ private fun updateOrientation(
898
+ map: KakaoMap,
899
+ update: CameraUpdateRecord,
900
+ durationMillis: Int,
901
+ promise: Promise,
902
+ ) {
903
+ val rotation = update.rotation ?: throw invalidCameraUpdate("rotation")
904
+ val tilt = update.tilt ?: throw invalidCameraUpdate("tilt")
905
+ if (!rotation.isFinite() || !tilt.isFinite() || tilt !in 0.0..90.0) {
906
+ throw CodedException(INVALID_ARGUMENT, "Camera orientation must contain a finite rotation and tilt from 0 to 90 degrees.", null)
907
+ }
908
+ requireOnlyCameraFields(update, "orientation")
909
+ val commandGeneration = beginCameraCommand()
910
+ val requestId = Any()
911
+ val viewReference = WeakReference(this)
912
+ val timeout =
913
+ Runnable {
914
+ viewReference.get()?.rejectCameraDispatch(
915
+ requestId,
916
+ CAMERA_TIMEOUT,
917
+ "Kakao Maps did not return a settled camera position within $CAMERA_REQUEST_TIMEOUT_MILLIS milliseconds.",
918
+ )
919
+ }
920
+ pendingCameraDispatches[requestId] = PendingCameraRequest(promise, timeout)
921
+ mainHandler.postDelayed(timeout, CAMERA_REQUEST_TIMEOUT_MILLIS)
922
+ val callbackHandler = mainHandler
923
+ val mapReference = WeakReference(map)
924
+ try {
925
+ map.getCameraPosition { position ->
926
+ callbackHandler.post {
927
+ viewReference.get()?.resolveCameraDispatch(
928
+ requestId,
929
+ mapReference.get(),
930
+ position,
931
+ rotation,
932
+ tilt,
933
+ durationMillis,
934
+ commandGeneration,
935
+ )
936
+ }
937
+ }
938
+ } catch (error: Exception) {
939
+ val code = if (error is CodedException) error.code else error.javaClass.simpleName
940
+ rejectCameraDispatch(requestId, code, error.message ?: "Camera update failed.")
941
+ }
942
+ }
943
+
944
+ private fun resolveCameraDispatch(
945
+ requestId: Any,
946
+ expectedMap: KakaoMap?,
947
+ position: CameraPosition,
948
+ rotation: Double,
949
+ tilt: Double,
950
+ durationMillis: Int,
951
+ commandGeneration: Long,
952
+ ) {
953
+ val request = pendingCameraDispatches.remove(requestId) ?: return
954
+ mainHandler.removeCallbacks(request.timeout)
955
+ if (isDestroyed || expectedMap == null || map !== expectedMap) {
956
+ request.promise.reject(VIEW_DESTROYED, VIEW_DESTROYED_MESSAGE, null)
957
+ return
958
+ }
959
+ if (commandGeneration != cameraCommandGeneration) {
960
+ request.promise.reject(
961
+ CAMERA_SUPERSEDED,
962
+ "The orientation update was superseded by a newer camera command before dispatch.",
963
+ null,
964
+ )
965
+ return
966
+ }
967
+ try {
968
+ val cameraUpdate =
969
+ CameraUpdateFactory.newCameraPosition(
970
+ CameraPosition.from(
971
+ CameraPosition
972
+ .Builder()
973
+ .setPosition(position.position)
974
+ .setZoomLevel(position.zoomLevel)
975
+ .setTiltAngle(Math.toRadians(tilt))
976
+ .setRotationAngle(rotation.toNativeCameraRotation()),
977
+ ),
978
+ )
979
+ dispatchCameraUpdate(expectedMap, cameraUpdate, durationMillis)
980
+ request.promise.resolve(null)
981
+ } catch (error: Exception) {
982
+ val code = if (error is CodedException) error.code else error.javaClass.simpleName
983
+ request.promise.reject(code, error.message ?: "Camera update failed.", error)
984
+ }
985
+ }
986
+
987
+ private fun rejectCameraDispatch(
988
+ requestId: Any,
989
+ code: String,
990
+ message: String,
991
+ ) {
992
+ val request = pendingCameraDispatches.remove(requestId) ?: return
993
+ mainHandler.removeCallbacks(request.timeout)
994
+ request.promise.reject(code, message, null)
995
+ }
996
+
997
+ private fun rejectPendingCameraDispatches(
998
+ code: String,
999
+ message: String,
1000
+ ) {
1001
+ pendingCameraDispatches.keys.toList().forEach { rejectCameraDispatch(it, code, message) }
1002
+ }
1003
+
1004
+ private fun validateDuration(duration: Double): Int {
1005
+ if (!duration.isFinite() || duration < 0 || duration > Int.MAX_VALUE || duration % 1.0 != 0.0) {
1006
+ throw CodedException(INVALID_ARGUMENT, "Duration must be an integer between 0 and 2147483647 milliseconds.", null)
1007
+ }
1008
+ return duration.toInt()
1009
+ }
1010
+
1011
+ private fun beginCameraCommand(): Long {
1012
+ cameraCommandGeneration += 1
1013
+ return cameraCommandGeneration
1014
+ }
1015
+
1016
+ private fun validatePixelValue(value: Double) {
1017
+ val pixels = value * resources.displayMetrics.density
1018
+ if (!pixels.isFinite() || pixels < Int.MIN_VALUE || pixels > Int.MAX_VALUE) {
1019
+ throw CodedException(INVALID_ARGUMENT, "Screen point values exceed the supported pixel range.", null)
1020
+ }
1021
+ }
1022
+
1023
+ private fun toPixels(value: Double) = (value * resources.displayMetrics.density).toInt()
1024
+
1025
+ private fun toPixelsFloat(value: Double) = (value * resources.displayMetrics.density).toFloat()
1026
+
1027
+ private fun requireOnlyCameraFields(
1028
+ update: CameraUpdateRecord,
1029
+ type: String,
1030
+ ) {
1031
+ val isValid =
1032
+ when (type) {
1033
+ "position" -> update.zoomLevel == null && update.rotation == null && update.tilt == null && update.coordinates == null
1034
+ "zoom" -> update.position == null && update.rotation == null && update.tilt == null && update.coordinates == null
1035
+ "orientation" -> update.position == null && update.zoomLevel == null && update.coordinates == null
1036
+ "fit" -> update.position == null && update.zoomLevel == null && update.rotation == null && update.tilt == null
1037
+ else -> false
1038
+ }
1039
+ if (!isValid) {
1040
+ throw CodedException(INVALID_ARGUMENT, "Camera update type $type contains fields for another update type.", null)
1041
+ }
1042
+ }
1043
+
1044
+ private fun invalidCameraUpdate(field: String) =
1045
+ CodedException(INVALID_ARGUMENT, "Camera update is missing required field: $field.", null)
1046
+
1047
+ private fun reportError(error: Exception) {
1048
+ val code = if (error is CodedException) error.code else error.javaClass.simpleName
1049
+ onError(mapOf("code" to code, "message" to (error.message ?: "Map error")))
1050
+ }
1051
+
1052
+ fun getCamera(
1053
+ promise: Promise,
1054
+ isAdvancedState: Boolean = false,
1055
+ ) {
1056
+ val readyMap =
1057
+ try {
1058
+ requireMap()
1059
+ } catch (error: CodedException) {
1060
+ promise.reject(error)
1061
+ return
1062
+ }
1063
+ val requestId = Any()
1064
+ val viewReference = WeakReference(this)
1065
+ val timeout =
1066
+ Runnable {
1067
+ viewReference.get()?.rejectCameraRequest(
1068
+ requestId,
1069
+ CAMERA_TIMEOUT,
1070
+ "Kakao Maps did not return a camera position within $CAMERA_REQUEST_TIMEOUT_MILLIS milliseconds.",
1071
+ )
1072
+ }
1073
+ pendingCameraRequests[requestId] = PendingCameraRequest(promise, timeout, isAdvancedState)
1074
+ mainHandler.postDelayed(timeout, CAMERA_REQUEST_TIMEOUT_MILLIS)
1075
+ val callbackHandler = mainHandler
1076
+ try {
1077
+ readyMap.getCameraPosition { position ->
1078
+ callbackHandler.post {
1079
+ viewReference.get()?.resolveCameraRequest(requestId, position)
1080
+ }
1081
+ }
1082
+ } catch (error: Exception) {
1083
+ val code = if (error is CodedException) error.code else error.javaClass.simpleName
1084
+ rejectCameraRequest(requestId, code, error.message ?: "Camera query failed.")
1085
+ }
1086
+ }
1087
+
1088
+ private fun resolveCameraRequest(
1089
+ requestId: Any,
1090
+ position: CameraPosition,
1091
+ ) {
1092
+ val request = pendingCameraRequests.remove(requestId) ?: return
1093
+ mainHandler.removeCallbacks(request.timeout)
1094
+ try {
1095
+ request.promise.resolve(if (request.isAdvancedState) requireCameraCommands().stateResult(position) else position.toPayload())
1096
+ } catch (error: Exception) {
1097
+ if (error is CodedException) {
1098
+ request.promise.reject(error)
1099
+ } else {
1100
+ request.promise.reject("ERR_KAKAO_MAP_CAMERA", error.message, error)
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ private fun rejectCameraRequest(
1106
+ requestId: Any,
1107
+ code: String,
1108
+ message: String,
1109
+ ) {
1110
+ val request = pendingCameraRequests.remove(requestId) ?: return
1111
+ mainHandler.removeCallbacks(request.timeout)
1112
+ request.promise.reject(code, message, null)
1113
+ }
1114
+
1115
+ private fun rejectPendingCameraRequests(
1116
+ code: String,
1117
+ message: String,
1118
+ ) {
1119
+ pendingCameraRequests.keys.toList().forEach { rejectCameraRequest(it, code, message) }
1120
+ }
1121
+
1122
+ private fun applyCameraProp(camera: CameraOptions) {
1123
+ try {
1124
+ moveCamera(camera, 0.0)
1125
+ } catch (error: CodedException) {
1126
+ onError(mapOf("code" to error.code, "message" to error.localizedMessage))
1127
+ }
1128
+ }
1129
+
1130
+ private fun requireMap(): KakaoMap {
1131
+ if (isDestroyed) throw CodedException(VIEW_DESTROYED, VIEW_DESTROYED_MESSAGE, null)
1132
+ if (KakaoMapModule.appKey == null) throw CodedException(NOT_INITIALIZED, "Initialize the map SDK first.", null)
1133
+ return map ?: throw CodedException(NOT_READY, "Wait for onMapReady before calling map methods.", null)
1134
+ }
1135
+
1136
+ override fun onLayout(
1137
+ changed: Boolean,
1138
+ left: Int,
1139
+ top: Int,
1140
+ right: Int,
1141
+ bottom: Int,
1142
+ ) {
1143
+ super.onLayout(changed, left, top, right, bottom)
1144
+ startIfNeeded()
1145
+ mapView?.let { view ->
1146
+ // A map created during layout needs measurement before its surface can start.
1147
+ view.measure(
1148
+ MeasureSpec.makeMeasureSpec(right - left, MeasureSpec.EXACTLY),
1149
+ MeasureSpec.makeMeasureSpec(bottom - top, MeasureSpec.EXACTLY),
1150
+ )
1151
+ view.layout(0, 0, right - left, bottom - top)
1152
+ }
1153
+ }
1154
+
1155
+ private fun resume() {
1156
+ if (!isDestroyed && isAttachedToWindow && reactContext?.lifecycleState == LifecycleState.RESUMED) mapView?.resume()
1157
+ }
1158
+
1159
+ override fun onDetachedFromWindow() {
1160
+ mapView?.pause()
1161
+ super.onDetachedFromWindow()
1162
+ }
1163
+
1164
+ override fun onHostResume() {
1165
+ resume()
1166
+ }
1167
+
1168
+ override fun onHostPause() {
1169
+ mapView?.pause()
1170
+ }
1171
+
1172
+ override fun onHostDestroy() {
1173
+ destroy()
1174
+ }
1175
+
1176
+ fun destroy() {
1177
+ if (isDestroyed) return
1178
+ isDestroyed = true
1179
+ releaseMapObjects()
1180
+ beginCameraCommand()
1181
+ rejectPendingCameraRequests(VIEW_DESTROYED, VIEW_DESTROYED_MESSAGE)
1182
+ rejectPendingCameraDispatches(VIEW_DESTROYED, VIEW_DESTROYED_MESSAGE)
1183
+ reactContext?.removeLifecycleEventListener(this)
1184
+ map?.setOnCameraMoveStartListener(null)
1185
+ map?.setOnCameraMoveEndListener(null)
1186
+ map?.setOnMapClickListener(null)
1187
+ map?.setOnTerrainClickListener(null)
1188
+ map?.setOnViewportClickListener(null)
1189
+ map?.setOnVisibleChangeListener(null)
1190
+ map?.setOnTerrainLongClickListener(null)
1191
+ map?.setOnPoiClickListener(null)
1192
+ map?.setOnCompassClickListener(null)
1193
+ map?.setOnMapViewInfoChangeListener(null)
1194
+ map?.setOnPaddingChangeListener(null)
1195
+ map?.setOnViewportChangeListener(null)
1196
+ map?.setOnLabelClickListener(null)
1197
+ map?.setOnLodLabelClickListener(null)
1198
+ map = null
1199
+ queuedOptions = null
1200
+ pendingShapes = null
1201
+ pendingRoutes = null
1202
+ activeOverlays.clear()
1203
+ activeCustomOverlays.clear()
1204
+ val view = mapView
1205
+ mapView = null
1206
+ view?.finish()
1207
+ view?.let(::removeView)
1208
+ }
1209
+
1210
+ private fun reason(gesture: GestureType) = if (gesture == GestureType.Unknown) "developer" else "gesture"
1211
+
1212
+ private data class PendingCameraRequest(
1213
+ val promise: Promise,
1214
+ val timeout: Runnable,
1215
+ val isAdvancedState: Boolean = false,
1216
+ )
1217
+ }
1218
+
1219
+ private fun normalizeShapeObject(
1220
+ value: Map<*, *>,
1221
+ path: String,
1222
+ errorCode: String = SHAPE_INVALID_ARGUMENT,
1223
+ ): Map<String, Any?> =
1224
+ value.entries.associate { (rawKey, rawValue) ->
1225
+ val key = rawKey as? String ?: throw CodedException(errorCode, "$path must use string object keys.", null)
1226
+ key to normalizeShapeValue(rawValue, "$path.$key", errorCode)
1227
+ }
1228
+
1229
+ private fun normalizeShapeValue(
1230
+ value: Any?,
1231
+ path: String,
1232
+ errorCode: String,
1233
+ ): Any? =
1234
+ when (value) {
1235
+ null, is String, is Boolean -> {
1236
+ value
1237
+ }
1238
+
1239
+ is Number -> {
1240
+ if (!value.toDouble().isFinite()) {
1241
+ throw CodedException(errorCode, "$path must be a finite JSON number.", null)
1242
+ }
1243
+ value
1244
+ }
1245
+
1246
+ is Map<*, *> -> {
1247
+ normalizeShapeObject(value, path, errorCode)
1248
+ }
1249
+
1250
+ is List<*> -> {
1251
+ value.mapIndexed { index, item -> normalizeShapeValue(item, "$path[$index]", errorCode) }
1252
+ }
1253
+
1254
+ else -> {
1255
+ throw CodedException(errorCode, "$path contains an unsupported JSON value.", null)
1256
+ }
1257
+ }