@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,1099 @@
1
+ import ExpoModulesCore
2
+ import KakaoMapsSDK
3
+ import UIKit
4
+
5
+ private let mapGuiInvalidArgument = "ERR_KAKAO_MAP_GUI_INVALID_ARGUMENT"
6
+ private let mapGuiNotFound = "ERR_KAKAO_MAP_GUI_NOT_FOUND"
7
+ private let mapGuiUnsupported = "ERR_KAKAO_MAP_UNSUPPORTED_PLATFORM_FEATURE"
8
+ private let mapGuiApplyCancelled = "ERR_KAKAO_MAP_GUI_APPLY_CANCELLED"
9
+
10
+ struct MapGuiImageSourceRecord: Record {
11
+ @Field(.required) var uri = ""
12
+ @Field var width: Double?
13
+ @Field var height: Double?
14
+ }
15
+
16
+ struct MapGuiPointRecord: Record {
17
+ @Field(.required) var x = Double.nan
18
+ @Field(.required) var y = Double.nan
19
+ }
20
+
21
+ struct MapGuiSizeRecord: Record {
22
+ @Field(.required) var width = Double.nan
23
+ @Field(.required) var height = Double.nan
24
+ }
25
+
26
+ struct MapGuiStrokeRecord: Record {
27
+ @Field(.required) var color = ""
28
+ @Field(.required) var width = Double.nan
29
+ }
30
+
31
+ struct MapGuiSplitLineRecord: Record {
32
+ @Field(.required) var color = ""
33
+ @Field(.required) var width = Double.nan
34
+ }
35
+
36
+ struct MapGuiIosLayoutRecord: Record {
37
+ @Field var backgroundColor: String?
38
+ @Field var splitLine: MapGuiSplitLineRecord?
39
+ }
40
+
41
+ struct MapGuiAndroidLayoutRecord: Record {
42
+ @Field var background: MapGuiNodeRecord?
43
+ }
44
+
45
+ struct MapGuiPaddingRecord: Record {
46
+ @Field(.required) var top = Double.nan
47
+ @Field(.required) var right = Double.nan
48
+ @Field(.required) var bottom = Double.nan
49
+ @Field(.required) var left = Double.nan
50
+ }
51
+
52
+ struct MapGuiCoordinateRecord: Record {
53
+ @Field(.required) var latitude = Double.nan
54
+ @Field(.required) var longitude = Double.nan
55
+ }
56
+
57
+ final class MapGuiNodeRecord: Record {
58
+ @Field(.required) var id = ""
59
+ @Field(.required) var type = ""
60
+ @Field var platform: String?
61
+ @Field var clickable = false
62
+ @Field var padding: MapGuiPaddingRecord?
63
+ @Field var origin: String?
64
+ @Field var align: String?
65
+ @Field var orientation: String?
66
+ @Field var children: [MapGuiNodeRecord] = []
67
+ @Field var child: MapGuiNodeRecord?
68
+ @Field var source: MapGuiImageSourceRecord?
69
+ @Field var pressedSource: MapGuiImageSourceRecord?
70
+ @Field var sources: [MapGuiImageSourceRecord] = []
71
+ @Field var size: MapGuiSizeRecord?
72
+ @Field var pressedSize: MapGuiSizeRecord?
73
+ @Field var stretch: MapGuiPaddingRecord?
74
+ @Field var text: String?
75
+ @Field var textSize: Double?
76
+ @Field var color: String?
77
+ @Field var stroke: MapGuiStrokeRecord?
78
+ @Field var duration: Double?
79
+ @Field var `repeat`: Double?
80
+ @Field var autoStart = false
81
+ @Field var ios: MapGuiIosLayoutRecord?
82
+ @Field var android: MapGuiAndroidLayoutRecord?
83
+ }
84
+
85
+ struct MapGuiAutoMoveRecord: Record {
86
+ @Field(.required) var enabled = false
87
+ @Field var duration: Double?
88
+ }
89
+
90
+ struct MapGuiInfoWindowRecord: Record {
91
+ @Field(.required) var id = ""
92
+ @Field(.required) var position = MapGuiCoordinateRecord()
93
+ @Field(.required) var body = MapGuiNodeRecord()
94
+ @Field var tail: MapGuiNodeRecord?
95
+ @Field var bodyOffset: MapGuiPointRecord?
96
+ @Field var tailOffset: MapGuiPointRecord?
97
+ @Field var visible = true
98
+ @Field var zIndex = 0
99
+ @Field var autoMove: MapGuiAutoMoveRecord?
100
+ }
101
+
102
+ struct MapGuiWidgetPositionRecord: Record {
103
+ @Field(.required) var gravity = "topLeft"
104
+ @Field(.required) var x = Double.nan
105
+ @Field(.required) var y = Double.nan
106
+ }
107
+
108
+ struct MapGuiWidgetRecord: Record {
109
+ @Field(.required) var id = ""
110
+ @Field(.required) var position = MapGuiWidgetPositionRecord()
111
+ @Field(.required) var root = MapGuiNodeRecord()
112
+ @Field var visible = true
113
+ @Field var zIndex = 0
114
+ @Field var rotation = 0.0
115
+ @Field var ios: MapGuiIosLayoutRecord?
116
+ }
117
+
118
+ struct MapGuiDropAnimationRecord: Record {
119
+ @Field(.required) var id = ""
120
+ @Field(.required) var platform = "ios"
121
+ @Field(.required) var infoWindowIds: [String] = []
122
+ @Field(.required) var duration = Double.nan
123
+ @Field(.required) var pixelHeight = Double.nan
124
+ @Field var interpolation = "linear"
125
+ @Field var `repeat` = 1.0
126
+ @Field var hideAtStop = false
127
+ @Field var removeAtStop = false
128
+ }
129
+
130
+ struct MapGuiSceneRecord: Record {
131
+ @Field var infoWindows: [MapGuiInfoWindowRecord] = []
132
+ @Field var widgets: [MapGuiWidgetRecord] = []
133
+ @Field var animations: [MapGuiDropAnimationRecord] = []
134
+ }
135
+
136
+ final class MapGui {
137
+ typealias EventEmitter = ([String: Any]) -> Void
138
+
139
+ private static let transparentInfoWindowImage = UIGraphicsImageRenderer(
140
+ size: CGSize(width: 1, height: 1),
141
+ format: MapUnits.imageRendererFormat
142
+ ).image { _ in }
143
+
144
+ private weak var map: KakaoMap?
145
+ private var imageLoader: MapImageLoader?
146
+ private let emit: EventEmitter
147
+ private var infoWindows: [String: InfoWindow] = [:]
148
+ private var widgets: [String: SpriteGui] = [:]
149
+ private var animations: [String: InfoWindowAnimator] = [:]
150
+ private var eventHandlers: [DisposableEventHandler] = []
151
+ private var imageTasks: [MapImageLoadToken] = []
152
+ private var applyGeneration = 0
153
+ private var pendingApply: ((Error?) -> Void)?
154
+ private var isDisposed = false
155
+
156
+ init(map: KakaoMap, imageLoader: MapImageLoader, emit: @escaping EventEmitter) {
157
+ self.map = map
158
+ self.imageLoader = imageLoader
159
+ self.emit = emit
160
+ }
161
+
162
+ func apply(_ scene: MapGuiSceneRecord, completion: @escaping (Error?) -> Void) {
163
+ guard !isDisposed else {
164
+ completion(KakaoMapException(mapGuiNotFound, "The GUI manager was disposed."))
165
+ return
166
+ }
167
+ do {
168
+ try validate(scene)
169
+ } catch {
170
+ completion(error)
171
+ return
172
+ }
173
+
174
+ applyGeneration += 1
175
+ pendingApply?(KakaoMapException(
176
+ mapGuiApplyCancelled,
177
+ "A newer GUI scene replaced this apply request."
178
+ ))
179
+ pendingApply = completion
180
+ let generation = applyGeneration
181
+ imageTasks.forEach { $0.cancel() }
182
+ imageTasks.removeAll()
183
+ loadImages(scene, generation: generation) { [weak self] result in
184
+ guard let self, !self.isDisposed, generation == applyGeneration else { return }
185
+ switch result {
186
+ case let .success(images):
187
+ do {
188
+ try replaceScene(scene, images: images)
189
+ pendingApply = nil
190
+ completion(nil)
191
+ } catch {
192
+ clearScene()
193
+ pendingApply = nil
194
+ completion(error)
195
+ }
196
+ case let .failure(error):
197
+ pendingApply = nil
198
+ completion(error)
199
+ }
200
+ }
201
+ }
202
+
203
+ func showInfoWindow(id: String, autoMove: Bool, duration: Double?) throws {
204
+ if let duration, duration != 0 {
205
+ throw KakaoMapException(
206
+ mapGuiUnsupported,
207
+ "iOS SDK 2.12.19 does not expose an auto-move duration for info windows."
208
+ )
209
+ }
210
+ let infoWindow = try requireInfoWindow(id)
211
+ if autoMove {
212
+ infoWindow.showWithAutoMove()
213
+ } else {
214
+ infoWindow.show()
215
+ }
216
+ }
217
+
218
+ func hideInfoWindow(id: String) throws {
219
+ try requireInfoWindow(id).hide()
220
+ }
221
+
222
+ func moveInfoWindow(id: String, position: MapGuiCoordinateRecord, duration: Double) throws {
223
+ try validateDuration(duration)
224
+ try requireInfoWindow(id).moveAt(mapPoint(position), duration: UInt(duration))
225
+ }
226
+
227
+ func showWidget(id: String) throws {
228
+ try requireWidget(id).show()
229
+ }
230
+
231
+ func hideWidget(id: String) throws {
232
+ try requireWidget(id).hide()
233
+ }
234
+
235
+ func positionWidget(id: String, position: MapGuiWidgetPositionRecord) throws {
236
+ try validatePoint(position.x, position.y, name: "widget position")
237
+ let widget = try requireWidget(id)
238
+ widget.origin = try alignment(position.gravity)
239
+ widget.position = try CGPoint(
240
+ x: pixelDouble(position.x, name: "widget position x"),
241
+ y: pixelDouble(position.y, name: "widget position y")
242
+ )
243
+ widget.updateGui()
244
+ }
245
+
246
+ func rotateWidget(id: String, rotation: Double) throws {
247
+ guard rotation.isFinite else { throw exception("Rotation must be finite.") }
248
+ let widget = try requireWidget(id)
249
+ widget.orientation = rotation * .pi / 180
250
+ widget.updateGui()
251
+ }
252
+
253
+ func startAnimation(id: String) throws {
254
+ try requireAnimation(id).start()
255
+ }
256
+
257
+ func stopAnimation(id: String) throws {
258
+ try requireAnimation(id).stop()
259
+ }
260
+
261
+ func controlAnimatedImage(
262
+ ownerType: String,
263
+ ownerId: String,
264
+ componentId: String,
265
+ action: String
266
+ ) throws {
267
+ let owner: GuiBase
268
+ switch ownerType {
269
+ case "infoWindow": owner = try requireInfoWindow(ownerId)
270
+ case "widget": owner = try requireWidget(ownerId)
271
+ default: throw exception("Unknown GUI owner type: \(ownerType).")
272
+ }
273
+ guard let image = owner.getChild(componentId) as? GuiAnimatedImage else {
274
+ throw KakaoMapException(mapGuiNotFound, "Animated image \(componentId) was not found.")
275
+ }
276
+ switch action {
277
+ case "start": image.start()
278
+ case "pause": image.pause()
279
+ case "resume": image.resume()
280
+ case "stop": image.stop()
281
+ default: throw exception("Unknown animated-image action: \(action).")
282
+ }
283
+ }
284
+
285
+ func status() throws -> [String: Any] {
286
+ guard !isDisposed, let manager = map?.getGuiManager() else {
287
+ throw KakaoMapException(mapGuiNotFound, "The GUI manager was disposed.")
288
+ }
289
+ let infoWindowStatus = (manager.infoWindowLayer.getAllInfoWindows() ?? [])
290
+ .sorted { $0.name < $1.name }.compactMap { infoWindow -> [String: Any]? in
291
+ guard let coordinate = infoWindow.rawPosition()?.wgsCoord else { return nil }
292
+ return [
293
+ "id": infoWindow.name,
294
+ "visible": infoWindow.isShow,
295
+ "position": [
296
+ "latitude": coordinate.latitude,
297
+ "longitude": coordinate.longitude
298
+ ],
299
+ "zIndex": infoWindow.zOrder
300
+ ] as [String: Any]
301
+ }
302
+ let widgetStatus = (manager.spriteGuiLayer.getAllSpriteGuis() ?? [])
303
+ .sorted { $0.name < $1.name }.map { widget in
304
+ [
305
+ "id": widget.name,
306
+ "visible": widget.isShow,
307
+ "position": [
308
+ "gravity": alignmentName(widget.origin),
309
+ "x": widget.position.x / MapUnits.pixelsPerPoint,
310
+ "y": widget.position.y / MapUnits.pixelsPerPoint
311
+ ],
312
+ "rotation": widget.orientation * 180 / .pi,
313
+ "zIndex": widget.zOrder
314
+ ] as [String: Any]
315
+ }
316
+ let animationStatus = animations.keys.sorted().compactMap { id -> [String: Any]? in
317
+ guard let animation = manager.getInfoWindowAnimator(animatorID: id) else { return nil }
318
+ return ["id": animation.animatorID, "running": animation.isStart]
319
+ }
320
+ return [
321
+ "infoWindows": infoWindowStatus,
322
+ "widgets": widgetStatus,
323
+ "animations": animationStatus,
324
+ "counts": [
325
+ "infoWindows": infoWindowStatus.count,
326
+ "widgets": widgetStatus.count,
327
+ "animations": animationStatus.count
328
+ ],
329
+ "capabilities": [
330
+ "button": true,
331
+ "animatedImage": true,
332
+ "infoWindowAnimation": true,
333
+ "arbitraryComponentClicks": false,
334
+ "componentAnimationEvents": true,
335
+ "guiMoveEvents": true,
336
+ "autoMoveDuration": false
337
+ ]
338
+ ]
339
+ }
340
+
341
+ func dispose() {
342
+ guard !isDisposed else { return }
343
+ isDisposed = true
344
+ applyGeneration += 1
345
+ pendingApply?(KakaoMapException(mapGuiApplyCancelled, "The GUI manager was disposed."))
346
+ pendingApply = nil
347
+ imageTasks.forEach { $0.cancel() }
348
+ imageTasks.removeAll()
349
+ clearScene()
350
+ map = nil
351
+ imageLoader = nil
352
+ }
353
+
354
+ private func replaceScene(_ scene: MapGuiSceneRecord, images: [String: UIImage]) throws {
355
+ guard let map
356
+ else { throw KakaoMapException(mapGuiNotFound, "The map is no longer available.") }
357
+ clearScene()
358
+ let manager = map.getGuiManager()
359
+ for record in scene.infoWindows {
360
+ let infoWindow = InfoWindow(record.id)
361
+ infoWindow.body = try bodyImage(record.body, images: images, ownerId: record.id)
362
+ if let tail = record.tail {
363
+ infoWindow.tail = try image(tail, images: images, ownerId: record.id)
364
+ }
365
+ infoWindow.position = try mapPoint(record.position)
366
+ infoWindow.bodyOffset = try pixelPoint(record.bodyOffset)
367
+ infoWindow.positionOffset = try pixelPoint(record.tailOffset)
368
+ infoWindow.zOrder = record.zIndex
369
+ installHandlers(infoWindow, ownerType: "infoWindow", ownerId: record.id)
370
+ manager.infoWindowLayer.addInfoWindow(infoWindow)
371
+ if record.visible {
372
+ record.autoMove?.enabled == true ? infoWindow.showWithAutoMove() : infoWindow.show()
373
+ } else {
374
+ infoWindow.hide()
375
+ }
376
+ infoWindows[record.id] = infoWindow
377
+ startAutoImages(record.body, owner: infoWindow)
378
+ }
379
+ for record in scene.widgets {
380
+ let widget = SpriteGui(record.id)
381
+ try widget.addChild(component(record.root, images: images, ownerId: record.id))
382
+ widget.origin = try alignment(record.position.gravity)
383
+ widget.position = try CGPoint(
384
+ x: pixelDouble(record.position.x, name: "widget position x"),
385
+ y: pixelDouble(record.position.y, name: "widget position y")
386
+ )
387
+ widget.orientation = record.rotation * .pi / 180
388
+ widget.zOrder = record.zIndex
389
+ if let backgroundColor = record.ios?.backgroundColor {
390
+ widget.bgColor = try color(backgroundColor)
391
+ }
392
+ if let splitLine = record.ios?.splitLine {
393
+ widget.showSplitLine = true
394
+ widget.splitLineColor = try color(splitLine.color)
395
+ widget.splitLineWidth = try nonnegativePixelInt(splitLine.width, name: "split-line width")
396
+ }
397
+ installHandlers(widget, ownerType: "widget", ownerId: record.id)
398
+ manager.spriteGuiLayer.addSpriteGui(widget)
399
+ record.visible ? widget.show() : widget.hide()
400
+ widgets[record.id] = widget
401
+ startAutoImages(record.root, owner: widget)
402
+ }
403
+ for record in scene.animations {
404
+ let effect = try DropAnimationEffect(pixelHeight: pixelFloat(
405
+ record.pixelHeight,
406
+ name: "drop height"
407
+ ))
408
+ effect.interpolation = try interpolation(record.interpolation, duration: record.duration)
409
+ effect.playCount = UInt(record.repeat)
410
+ effect.hideAtStop = record.hideAtStop
411
+ effect.removeAtStop = record.removeAtStop
412
+ guard let animator = manager.addInfoWindowAnimator(animatorID: record.id, effect: effect)
413
+ else {
414
+ throw exception("Could not create info-window animator \(record.id).")
415
+ }
416
+ try animator.addInfoWindows(record.infoWindowIds.map(requireInfoWindow))
417
+ let animatorIdentity = ObjectIdentifier(animator)
418
+ animator.setStopCallback { [weak self] _ in
419
+ guard let self, !self.isDisposed,
420
+ let active = animations[record.id],
421
+ ObjectIdentifier(active) == animatorIdentity
422
+ else { return }
423
+ emit(["type": "animatorStopped", "id": record.id])
424
+ }
425
+ animations[record.id] = animator
426
+ }
427
+ }
428
+
429
+ private func clearScene() {
430
+ eventHandlers.forEach { $0.dispose() }
431
+ eventHandlers.removeAll()
432
+ let removedAnimations = Array(animations.values)
433
+ animations.removeAll()
434
+ removedAnimations.forEach { $0.stop() }
435
+ map?.getGuiManager().clearAllInfoWindowAnimators()
436
+ map?.getGuiManager().infoWindowLayer.clear()
437
+ map?.getGuiManager().spriteGuiLayer.clear()
438
+ infoWindows.removeAll()
439
+ widgets.removeAll()
440
+ }
441
+
442
+ private func installHandlers(_ gui: GuiBase, ownerType: String, ownerId: String) {
443
+ eventHandlers.append(gui.addTapEventHandler(target: self) { owner in { event in
444
+ owner.emit([
445
+ "type": "tap",
446
+ "ownerType": ownerType,
447
+ "ownerId": ownerId,
448
+ "componentId": event.guiComponentName ?? ""
449
+ ])
450
+ } })
451
+ eventHandlers.append(gui.addAnimationEventHandler(target: self) { owner in { event in
452
+ owner.emit([
453
+ "type": "componentAnimationState",
454
+ "ownerType": ownerType,
455
+ "ownerId": ownerId,
456
+ "componentId": event.guiComponentName ?? "",
457
+ "state": owner.animationStateName(event.state)
458
+ ])
459
+ } })
460
+ eventHandlers.append(gui.addMoveEndEventHandler(target: self) { owner in { event in
461
+ let coordinate = event.position.wgsCoord
462
+ owner.emit([
463
+ "type": "guiMoveEnd",
464
+ "ownerType": ownerType,
465
+ "ownerId": ownerId,
466
+ "position": ["latitude": coordinate.latitude, "longitude": coordinate.longitude]
467
+ ])
468
+ } })
469
+ }
470
+
471
+ private func bodyImage(
472
+ _ record: MapGuiNodeRecord,
473
+ images: [String: UIImage],
474
+ ownerId: String
475
+ ) throws -> GuiImage {
476
+ let child = try component(record, images: images, ownerId: ownerId)
477
+ if let image = child as? GuiImage {
478
+ return image
479
+ }
480
+ let wrapper = GuiImage("\(record.id)-body")
481
+ wrapper.image = Self.transparentInfoWindowImage
482
+ wrapper.child = child
483
+ return wrapper
484
+ }
485
+
486
+ private func component(
487
+ _ record: MapGuiNodeRecord,
488
+ images: [String: UIImage],
489
+ ownerId: String
490
+ ) throws -> GuiComponentBase {
491
+ let result: GuiComponentBase
492
+ switch record.type {
493
+ case "layout":
494
+ let layout = GuiLayout(
495
+ record.id,
496
+ arrangement: record.orientation == "horizontal" ? .horizontal : .vertical
497
+ )
498
+ for child in record.children {
499
+ try layout.addChild(component(child, images: images, ownerId: ownerId))
500
+ }
501
+ if let backgroundColor = record.ios?.backgroundColor {
502
+ layout.bgColor = try color(backgroundColor)
503
+ }
504
+ if let splitLine = record.ios?.splitLine {
505
+ layout.showSplitLine = true
506
+ layout.splitLineColor = try color(splitLine.color)
507
+ layout.splitLineWidth = try nonnegativePixelInt(splitLine.width, name: "split-line width")
508
+ }
509
+ result = layout
510
+ case "image":
511
+ result = try image(record, images: images, ownerId: ownerId)
512
+ case "text":
513
+ guard let textValue = record.text else { throw exception("GUI text is required.") }
514
+ let text = GuiText(record.id)
515
+ try text.addText(
516
+ text: textValue,
517
+ style: TextStyle(
518
+ fontSize: UInt(positivePixelInt(record.textSize ?? 0, name: "text size")),
519
+ fontColor: color(record.color ?? ""),
520
+ strokeThickness: UInt(nonnegativePixelInt(
521
+ record.stroke?.width ?? 0,
522
+ name: "stroke width"
523
+ )),
524
+ strokeColor: color(record.stroke?.color ?? "#00000000")
525
+ )
526
+ )
527
+ result = text
528
+ case "button":
529
+ guard record.platform == "ios"
530
+ else { throw exception("GUI buttons must specify platform: ios.") }
531
+ let button = GuiButton(record.id)
532
+ let normalImage = try preparedImage(
533
+ requiredImage(record.source, images: images),
534
+ explicitSize: record.size,
535
+ source: record.source
536
+ )
537
+ let pressedImage = try preparedImage(
538
+ requiredImage(record.pressedSource, images: images),
539
+ explicitSize: record.pressedSize,
540
+ source: record.pressedSource
541
+ )
542
+ button.image = normalImage.image
543
+ button.pressedImage = pressedImage.image
544
+ if let size = normalImage.size {
545
+ button.imageSize = size
546
+ }
547
+ if let size = pressedImage.size {
548
+ button.pressedImageSize = size
549
+ }
550
+ if let child = record.child {
551
+ button.child = try component(child, images: images, ownerId: ownerId)
552
+ }
553
+ result = button
554
+ case "animatedImage":
555
+ guard record.platform == "ios" else {
556
+ throw exception("Animated GUI images must specify platform: ios.")
557
+ }
558
+ let animated = GuiAnimatedImage(record.id)
559
+ let nativeImages = try record.sources.map { source in
560
+ try preparedImage(
561
+ requiredImage(source, images: images),
562
+ explicitSize: record.size,
563
+ source: source
564
+ )
565
+ }
566
+ animated.addImages(nativeImages.map(\.image))
567
+ animated.duration = try UInt(positiveInt(
568
+ record.duration ?? 0,
569
+ name: "animated-image duration"
570
+ ))
571
+ animated.playCount = try UInt(nonnegativeInt(
572
+ record.repeat ?? 1,
573
+ name: "animated-image repeat"
574
+ ))
575
+ if let size = nativeImages.first?.size {
576
+ animated.imageSize = size
577
+ }
578
+ if let child = record.child {
579
+ animated.child = try component(child, images: images, ownerId: ownerId)
580
+ }
581
+ result = animated
582
+ default:
583
+ throw exception("Unknown GUI component type: \(record.type).")
584
+ }
585
+ result.padding = try padding(record.padding)
586
+ result.origin = try alignment(record.origin ?? "topLeft")
587
+ result.align = try alignment(record.align ?? "topLeft")
588
+ return result
589
+ }
590
+
591
+ private func image(
592
+ _ record: MapGuiNodeRecord,
593
+ images: [String: UIImage],
594
+ ownerId: String
595
+ ) throws -> GuiImage {
596
+ let image = GuiImage(record.id)
597
+ let nativeImage = try preparedImage(
598
+ requiredImage(record.source, images: images),
599
+ explicitSize: record.size,
600
+ source: record.source
601
+ )
602
+ image.image = nativeImage.image
603
+ if let size = nativeImage.size {
604
+ image.imageSize = size
605
+ }
606
+ if let stretch = record.stretch {
607
+ image.imageStretch = try GuiEdgeInsets(
608
+ top: UInt(nonnegativePixelInt(stretch.top, name: "image stretch")),
609
+ left: UInt(nonnegativePixelInt(stretch.left, name: "image stretch")),
610
+ bottom: UInt(nonnegativePixelInt(stretch.bottom, name: "image stretch")),
611
+ right: UInt(nonnegativePixelInt(stretch.right, name: "image stretch"))
612
+ )
613
+ }
614
+ if let child = record.child {
615
+ image.child = try component(child, images: images, ownerId: ownerId)
616
+ }
617
+ image.padding = try padding(record.padding)
618
+ image.origin = try alignment(record.origin ?? "topLeft")
619
+ image.align = try alignment(record.align ?? "topLeft")
620
+ return image
621
+ }
622
+
623
+ private func loadImages(
624
+ _ scene: MapGuiSceneRecord,
625
+ generation: Int,
626
+ completion: @escaping (Result<[String: UIImage], Error>) -> Void
627
+ ) {
628
+ var sources: [String: MapGuiImageSourceRecord] = [:]
629
+ for infoWindow in scene.infoWindows {
630
+ collectImages(infoWindow.body, into: &sources)
631
+ if let tail = infoWindow.tail {
632
+ collectImages(tail, into: &sources)
633
+ }
634
+ }
635
+ scene.widgets.forEach { collectImages($0.root, into: &sources) }
636
+ guard !sources.isEmpty else {
637
+ completion(.success([:]))
638
+ return
639
+ }
640
+ guard let imageLoader else {
641
+ completion(.failure(exception("The map image loader is unavailable.")))
642
+ return
643
+ }
644
+ var loaded: [String: UIImage] = [:]
645
+ var remaining = sources.count
646
+ var firstError: Error?
647
+ for source in sources.values {
648
+ let task = imageLoader.load(source.uri) { [weak self] result in
649
+ guard let self, !self.isDisposed, generation == applyGeneration else { return }
650
+ if case let .success(image) = result {
651
+ loaded[source.uri] = image
652
+ }
653
+ if case let .failure(error) = result, firstError == nil {
654
+ firstError = error
655
+ }
656
+ remaining -= 1
657
+ if remaining == 0 {
658
+ imageTasks.removeAll()
659
+ if let firstError {
660
+ completion(.failure(firstError))
661
+ } else {
662
+ completion(.success(loaded))
663
+ }
664
+ }
665
+ }
666
+ imageTasks.append(task)
667
+ }
668
+ }
669
+
670
+ private func collectImages(
671
+ _ node: MapGuiNodeRecord,
672
+ into sources: inout [String: MapGuiImageSourceRecord]
673
+ ) {
674
+ [node.source, node.pressedSource].compactMap { $0 }.forEach { sources[$0.uri] = $0 }
675
+ node.sources.forEach { sources[$0.uri] = $0 }
676
+ node.children.forEach { collectImages($0, into: &sources) }
677
+ if let child = node.child {
678
+ collectImages(child, into: &sources)
679
+ }
680
+ }
681
+
682
+ private func validate(_ scene: MapGuiSceneRecord) throws {
683
+ try unique(scene.infoWindows.map(\.id), name: "info-window")
684
+ try unique(scene.widgets.map(\.id), name: "widget")
685
+ try unique(scene.animations.map(\.id), name: "GUI animation")
686
+ let infoWindowIds = Set(scene.infoWindows.map(\.id))
687
+ for infoWindow in scene.infoWindows {
688
+ try nonempty(infoWindow.id, name: "info-window ID")
689
+ _ = try mapPoint(infoWindow.position)
690
+ _ = try pixelPoint(infoWindow.bodyOffset)
691
+ _ = try pixelPoint(infoWindow.tailOffset)
692
+ var componentIds = Set<String>()
693
+ try validate(infoWindow.body, ids: &componentIds)
694
+ if let tail = infoWindow.tail {
695
+ guard tail.type == "image" else { throw exception("Info-window tails must be images.") }
696
+ try validate(tail, ids: &componentIds)
697
+ }
698
+ if let duration = infoWindow.autoMove?.duration, duration != 0 {
699
+ throw KakaoMapException(
700
+ mapGuiUnsupported,
701
+ "iOS does not expose an auto-move duration for info windows."
702
+ )
703
+ }
704
+ }
705
+ for widget in scene.widgets {
706
+ try nonempty(widget.id, name: "widget ID")
707
+ guard widget.root.type == "layout" else { throw exception("Widget roots must be layouts.") }
708
+ try validatePoint(widget.position.x, widget.position.y, name: "widget position")
709
+ _ = try alignment(widget.position.gravity)
710
+ guard widget.rotation.isFinite else { throw exception("Widget rotation must be finite.") }
711
+ try validateIosLayout(widget.ios)
712
+ var componentIds = Set<String>()
713
+ try validate(widget.root, ids: &componentIds)
714
+ }
715
+ for animation in scene.animations {
716
+ guard animation.platform == "ios"
717
+ else { throw exception("Info-window animations require platform: ios.") }
718
+ try nonempty(animation.id, name: "GUI animation ID")
719
+ _ = try pixelFloat(animation.pixelHeight, name: "drop height")
720
+ _ = try nonnegativeInt(animation.repeat, name: "animation repeat")
721
+ _ = try positiveInt(animation.duration, name: "animation duration")
722
+ guard !animation.infoWindowIds.isEmpty else {
723
+ throw exception("Animation \(animation.id) requires an info window.")
724
+ }
725
+ try unique(animation.infoWindowIds, name: "animation target")
726
+ _ = try interpolation(animation.interpolation, duration: animation.duration)
727
+ for id in animation.infoWindowIds where !infoWindowIds.contains(id) {
728
+ throw exception("Animation \(animation.id) references unknown info window \(id).")
729
+ }
730
+ }
731
+ }
732
+
733
+ private func validate(_ node: MapGuiNodeRecord, ids: inout Set<String>) throws {
734
+ try nonempty(node.id, name: "GUI component ID")
735
+ guard ids.insert(node.id).inserted
736
+ else { throw exception("Duplicate GUI component ID: \(node.id).") }
737
+ if let padding = node.padding {
738
+ try [padding.top, padding.right, padding.bottom, padding.left].forEach {
739
+ _ = try nonnegativePixelInt($0, name: "GUI padding")
740
+ }
741
+ }
742
+ if let size = node.size {
743
+ _ = try guiSize(size)
744
+ }
745
+ if let size = node.pressedSize {
746
+ _ = try guiSize(size)
747
+ }
748
+ if let stretch = node.stretch {
749
+ try [stretch.top, stretch.right, stretch.bottom, stretch.left].forEach {
750
+ _ = try nonnegativePixelInt($0, name: "image stretch")
751
+ }
752
+ }
753
+ _ = try alignment(node.origin ?? "topLeft")
754
+ _ = try alignment(node.align ?? "topLeft")
755
+ if node.clickable, node.type != "button" {
756
+ throw KakaoMapException(
757
+ mapGuiUnsupported,
758
+ "iOS SDK 2.12.19 only emits component taps from button nodes."
759
+ )
760
+ }
761
+ switch node.type {
762
+ case "layout":
763
+ guard ["horizontal", "vertical"].contains(node.orientation ?? "") else {
764
+ throw exception("GUI layout orientation must be horizontal or vertical.")
765
+ }
766
+ try validateIosLayout(node.ios)
767
+ try node.children.forEach { try validate($0, ids: &ids) }
768
+ case "image":
769
+ try validateSource(node.source)
770
+ if let child = node.child {
771
+ try validate(child, ids: &ids)
772
+ }
773
+ case "text":
774
+ guard node.text != nil else { throw exception("GUI text is required.") }
775
+ _ = try positivePixelInt(node.textSize ?? 0, name: "text size")
776
+ _ = try color(node.color ?? "")
777
+ if let stroke = node.stroke {
778
+ _ = try color(stroke.color)
779
+ _ = try nonnegativePixelInt(stroke.width, name: "stroke width")
780
+ }
781
+ case "button":
782
+ guard node.platform == "ios" else { throw exception("GUI buttons require platform: ios.") }
783
+ try validateSource(node.source)
784
+ try validateSource(node.pressedSource)
785
+ if let child = node.child {
786
+ try validate(child, ids: &ids)
787
+ }
788
+ case "animatedImage":
789
+ guard node.platform == "ios", !node.sources.isEmpty else {
790
+ throw exception("Animated GUI images require platform: ios and at least one image.")
791
+ }
792
+ try node.sources.forEach(validateSource)
793
+ _ = try positiveInt(node.duration ?? 0, name: "animated-image duration")
794
+ _ = try nonnegativeInt(node.repeat ?? 1, name: "animated-image repeat")
795
+ if let child = node.child {
796
+ try validate(child, ids: &ids)
797
+ }
798
+ default: throw exception("Unknown GUI component type: \(node.type).")
799
+ }
800
+ }
801
+
802
+ private func validateIosLayout(_ value: MapGuiIosLayoutRecord?) throws {
803
+ guard let value else { return }
804
+ if let backgroundColor = value.backgroundColor {
805
+ _ = try color(backgroundColor)
806
+ }
807
+ if let splitLine = value.splitLine {
808
+ _ = try color(splitLine.color)
809
+ _ = try nonnegativePixelInt(splitLine.width, name: "split-line width")
810
+ }
811
+ }
812
+
813
+ private func validateSource(_ source: MapGuiImageSourceRecord?) throws {
814
+ guard let source, !source.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
815
+ throw exception("GUI image URI must not be empty.")
816
+ }
817
+ if let width = source.width {
818
+ _ = try positivePixelInt(width, name: "image source width")
819
+ }
820
+ if let height = source.height {
821
+ _ = try positivePixelInt(height, name: "image source height")
822
+ }
823
+ }
824
+
825
+ private func requiredImage(
826
+ _ source: MapGuiImageSourceRecord?,
827
+ images: [String: UIImage]
828
+ ) throws -> UIImage {
829
+ try validateSource(source)
830
+ guard let source, let image = images[source.uri] else {
831
+ throw exception("GUI image was not loaded.")
832
+ }
833
+ return image
834
+ }
835
+
836
+ private func requireInfoWindow(_ id: String) throws -> InfoWindow {
837
+ guard !isDisposed, map != nil else {
838
+ throw KakaoMapException(mapGuiNotFound, "The GUI manager was disposed.")
839
+ }
840
+ guard let infoWindow = infoWindows[id] else {
841
+ throw KakaoMapException(mapGuiNotFound, "Info window \(id) was not found.")
842
+ }
843
+ return infoWindow
844
+ }
845
+
846
+ private func startAutoImages(_ node: MapGuiNodeRecord, owner: GuiBase) {
847
+ if node.type == "animatedImage", node.autoStart {
848
+ (owner.getChild(node.id) as? GuiAnimatedImage)?.start()
849
+ }
850
+ node.children.forEach { startAutoImages($0, owner: owner) }
851
+ if let child = node.child {
852
+ startAutoImages(child, owner: owner)
853
+ }
854
+ }
855
+
856
+ private func requireWidget(_ id: String) throws -> SpriteGui {
857
+ guard !isDisposed, map != nil else {
858
+ throw KakaoMapException(mapGuiNotFound, "The GUI manager was disposed.")
859
+ }
860
+ guard let widget = widgets[id] else {
861
+ throw KakaoMapException(mapGuiNotFound, "Widget \(id) was not found.")
862
+ }
863
+ return widget
864
+ }
865
+
866
+ private func requireAnimation(_ id: String) throws -> InfoWindowAnimator {
867
+ guard !isDisposed, map != nil else {
868
+ throw KakaoMapException(mapGuiNotFound, "The GUI manager was disposed.")
869
+ }
870
+ guard let animation = animations[id] else {
871
+ throw KakaoMapException(mapGuiNotFound, "GUI animation \(id) was not found.")
872
+ }
873
+ return animation
874
+ }
875
+
876
+ private func mapPoint(_ value: MapGuiCoordinateRecord) throws -> MapPoint {
877
+ guard value.latitude.isFinite, value.longitude.isFinite,
878
+ (-90 ... 90).contains(value.latitude), (-180 ... 180).contains(value.longitude)
879
+ else { throw exception("Invalid map coordinate.") }
880
+ return MapPoint(longitude: value.longitude, latitude: value.latitude)
881
+ }
882
+
883
+ private func pixelPoint(_ value: MapGuiPointRecord?) throws -> CGPoint {
884
+ guard let value else { return .zero }
885
+ return try CGPoint(
886
+ x: pixelDouble(value.x, name: "offset x"),
887
+ y: pixelDouble(value.y, name: "offset y")
888
+ )
889
+ }
890
+
891
+ private func guiSize(_ value: MapGuiSizeRecord) throws -> GuiSize {
892
+ try GuiSize(
893
+ width: UInt(positivePixelInt(value.width, name: "image width")),
894
+ height: UInt(positivePixelInt(value.height, name: "image height"))
895
+ )
896
+ }
897
+
898
+ private func preparedImage(
899
+ _ image: UIImage,
900
+ explicitSize explicit: MapGuiSizeRecord?,
901
+ source: MapGuiImageSourceRecord?
902
+ ) throws -> (image: UIImage, size: GuiSize?) {
903
+ guard let logicalSize = try logicalImageSize(explicit, source: source, image: image) else {
904
+ return (image, nil)
905
+ }
906
+ let rendered = UIGraphicsImageRenderer(
907
+ size: logicalSize,
908
+ format: MapUnits.imageRendererFormat
909
+ ).image { _ in
910
+ image.draw(in: CGRect(origin: .zero, size: logicalSize))
911
+ }
912
+ return try (
913
+ rendered,
914
+ GuiSize(
915
+ width: UInt(positivePixelInt(Double(logicalSize.width), name: "image width")),
916
+ height: UInt(positivePixelInt(Double(logicalSize.height), name: "image height"))
917
+ )
918
+ )
919
+ }
920
+
921
+ private func logicalImageSize(
922
+ _ explicit: MapGuiSizeRecord?,
923
+ source: MapGuiImageSourceRecord?,
924
+ image: UIImage
925
+ ) throws -> CGSize? {
926
+ if let explicit {
927
+ return try CGSize(
928
+ width: CGFloat(positiveInt(explicit.width, name: "image width")),
929
+ height: CGFloat(positiveInt(explicit.height, name: "image height"))
930
+ )
931
+ }
932
+ guard source?.width != nil || source?.height != nil else { return nil }
933
+ let aspectRatio = image.size.width / image.size.height
934
+ let width = source?.width ?? ((source?.height ?? 0) * aspectRatio)
935
+ let height = source?.height ?? ((source?.width ?? 0) / aspectRatio)
936
+ return try CGSize(
937
+ width: CGFloat(positiveInt(width, name: "image source width")),
938
+ height: CGFloat(positiveInt(height, name: "image source height"))
939
+ )
940
+ }
941
+
942
+ private func padding(_ value: MapGuiPaddingRecord?) throws -> GuiPadding {
943
+ guard let value else { return GuiPadding(left: 0, right: 0, top: 0, bottom: 0) }
944
+ return try GuiPadding(
945
+ left: Int32(nonnegativePixelInt(value.left, name: "GUI padding left")),
946
+ right: Int32(nonnegativePixelInt(value.right, name: "GUI padding right")),
947
+ top: Int32(nonnegativePixelInt(value.top, name: "GUI padding top")),
948
+ bottom: Int32(nonnegativePixelInt(value.bottom, name: "GUI padding bottom"))
949
+ )
950
+ }
951
+
952
+ private func alignment(_ value: String) throws -> GuiAlignment {
953
+ let vertical: VerticalAlign
954
+ let horizontal: HorizontalAlign
955
+ switch value {
956
+ case "topLeft": (vertical, horizontal) = (.top, .left)
957
+ case "topCenter": (vertical, horizontal) = (.top, .center)
958
+ case "topRight": (vertical, horizontal) = (.top, .right)
959
+ case "centerLeft": (vertical, horizontal) = (.middle, .left)
960
+ case "center": (vertical, horizontal) = (.middle, .center)
961
+ case "centerRight": (vertical, horizontal) = (.middle, .right)
962
+ case "bottomLeft": (vertical, horizontal) = (.bottom, .left)
963
+ case "bottomCenter": (vertical, horizontal) = (.bottom, .center)
964
+ case "bottomRight": (vertical, horizontal) = (.bottom, .right)
965
+ default: throw exception("Unknown GUI alignment: \(value).")
966
+ }
967
+ return GuiAlignment(vAlign: vertical, hAlign: horizontal)
968
+ }
969
+
970
+ private func alignmentName(_ value: GuiAlignment) -> String {
971
+ switch (value.vAlign, value.hAlign) {
972
+ case (.top, .left): "topLeft"
973
+ case (.top, .center): "topCenter"
974
+ case (.top, .right): "topRight"
975
+ case (.middle, .left): "centerLeft"
976
+ case (.middle, .center): "center"
977
+ case (.middle, .right): "centerRight"
978
+ case (.bottom, .left): "bottomLeft"
979
+ case (.bottom, .center): "bottomCenter"
980
+ case (.bottom, .right): "bottomRight"
981
+ default: "topLeft"
982
+ }
983
+ }
984
+
985
+ private func interpolation(_ value: String, duration: Double) throws -> AnimationInterpolation {
986
+ let method: InterpolationMethodType
987
+ switch value {
988
+ case "linear": method = .linear
989
+ case "cubicIn": method = .cubicIn
990
+ case "cubicOut": method = .cubicOut
991
+ case "cubicInOut": method = .cubicInOut
992
+ default: throw exception("Unknown animation interpolation: \(value).")
993
+ }
994
+ return try AnimationInterpolation(
995
+ duration: UInt32(positiveInt(duration, name: "animation duration")),
996
+ method: method
997
+ )
998
+ }
999
+
1000
+ private func animationStateName(_ value: KakaoMapsSDK.AnimationState) -> String {
1001
+ switch value {
1002
+ case .start: "started"
1003
+ case .pause: "paused"
1004
+ case .resume: "resumed"
1005
+ case .stop: "stopped"
1006
+ default: "unknown"
1007
+ }
1008
+ }
1009
+
1010
+ private func color(_ value: String) throws -> UIColor {
1011
+ guard value.hasPrefix("#") else {
1012
+ throw exception("Colors must use #RRGGBB or #RRGGBBAA.")
1013
+ }
1014
+ let hex = String(value.dropFirst())
1015
+ guard [6, 8].contains(hex.count), let number = UInt64(hex, radix: 16) else {
1016
+ throw exception("Colors must use #RRGGBB or #RRGGBBAA.")
1017
+ }
1018
+ let alpha = hex.count == 8 ? CGFloat(number & 0xFF) / 255 : 1
1019
+ let rgb = hex.count == 8 ? number >> 8 : number
1020
+ return UIColor(
1021
+ red: CGFloat((rgb >> 16) & 0xFF) / 255,
1022
+ green: CGFloat((rgb >> 8) & 0xFF) / 255,
1023
+ blue: CGFloat(rgb & 0xFF) / 255,
1024
+ alpha: alpha
1025
+ )
1026
+ }
1027
+
1028
+ private func validateDuration(_ value: Double) throws {
1029
+ guard value.isFinite, value >= 0, value <= Double(UInt32.max), value.rounded() == value else {
1030
+ throw exception("Duration must be a nonnegative integer in milliseconds.")
1031
+ }
1032
+ }
1033
+
1034
+ private func validatePoint(_ x: Double, _ y: Double, name: String) throws {
1035
+ guard x.isFinite, y.isFinite else { throw exception("\(name) must be finite.") }
1036
+ }
1037
+
1038
+ private func positiveInt(_ value: Double, name: String) throws -> Int {
1039
+ guard value.isFinite, value > 0, value <= Double(Int32.max), value.rounded() == value else {
1040
+ throw exception("\(name) must be a positive integer.")
1041
+ }
1042
+ return Int(value)
1043
+ }
1044
+
1045
+ private func nonnegativeInt(_ value: Double, name: String) throws -> Int {
1046
+ guard value.isFinite, value >= 0, value <= Double(Int32.max), value.rounded() == value else {
1047
+ throw exception("\(name) must be a nonnegative integer.")
1048
+ }
1049
+ return Int(value)
1050
+ }
1051
+
1052
+ private func pixelFloat(_ value: Double, name: String) throws -> Float {
1053
+ let pixels = try pixelDouble(value, name: name)
1054
+ guard abs(pixels) <= Double(Float.greatestFiniteMagnitude) else {
1055
+ throw exception("\(name) must fit a 32-bit floating-point pixel value.")
1056
+ }
1057
+ return Float(pixels)
1058
+ }
1059
+
1060
+ private func pixelDouble(_ value: Double, name: String) throws -> Double {
1061
+ let pixels = MapUnits.pixels(value)
1062
+ guard pixels.isFinite else { throw exception("\(name) must be finite after pixel conversion.") }
1063
+ return pixels
1064
+ }
1065
+
1066
+ private func positivePixelInt(_ value: Double, name: String) throws -> Int {
1067
+ _ = try positiveInt(value, name: name)
1068
+ let result = try pixelInteger(value, name: name)
1069
+ guard result > 0 else { throw exception("\(name) is too small after pixel conversion.") }
1070
+ return result
1071
+ }
1072
+
1073
+ private func nonnegativePixelInt(_ value: Double, name: String) throws -> Int {
1074
+ _ = try nonnegativeInt(value, name: name)
1075
+ return try pixelInteger(value, name: name)
1076
+ }
1077
+
1078
+ private func pixelInteger(_ value: Double, name: String = "pixel value") throws -> Int {
1079
+ let pixels = MapUnits.pixels(value).rounded()
1080
+ guard pixels.isFinite, pixels >= 0, pixels <= Double(Int32.max) else {
1081
+ throw exception("\(name) is out of range after pixel conversion.")
1082
+ }
1083
+ return Int(pixels)
1084
+ }
1085
+
1086
+ private func unique(_ values: [String], name: String) throws {
1087
+ guard Set(values).count == values.count else { throw exception("Duplicate \(name) ID.") }
1088
+ }
1089
+
1090
+ private func nonempty(_ value: String, name: String) throws {
1091
+ guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1092
+ throw exception("\(name) must not be empty.")
1093
+ }
1094
+ }
1095
+
1096
+ private func exception(_ message: String) -> KakaoMapException {
1097
+ KakaoMapException(mapGuiInvalidArgument, message)
1098
+ }
1099
+ }