@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,1676 @@
1
+ import ExpoModulesCore
2
+ import KakaoMapsSDK
3
+ import UIKit
4
+
5
+ let labelInvalidArgument = "ERR_KAKAO_MAP_LABEL_INVALID_ARGUMENT"
6
+ let labelNotFound = "ERR_KAKAO_MAP_LABEL_NOT_FOUND"
7
+ let labelUnsupported = "ERR_KAKAO_MAP_LABEL_UNSUPPORTED"
8
+ let labelApplyCancelled = "ERR_KAKAO_MAP_LABEL_APPLY_CANCELLED"
9
+ private let defaultLabelLayerZOrder = 1000
10
+
11
+ struct MapLabelPoint: Record {
12
+ @Field var latitude: Double = .nan
13
+ @Field var longitude: Double = .nan
14
+ }
15
+
16
+ struct MapLabelVector: Record {
17
+ @Field var x: Double = .nan
18
+ @Field var y: Double = .nan
19
+ }
20
+
21
+ struct MapLabelImage: Record {
22
+ @Field var uri = ""
23
+ @Field var width: Double?
24
+ @Field var height: Double?
25
+ }
26
+
27
+ struct MapLabelTextStyleRecord: Record {
28
+ @Field var fontFamily: String?
29
+ @Field var fontSize: Double = .nan
30
+ @Field var color = ""
31
+ @Field var strokeColor: String?
32
+ @Field var strokeWidth = 0.0
33
+ @Field var characterSpacing = 0.0
34
+ @Field var lineSpacing = 1.0
35
+ @Field var aspectRatio = 1.0
36
+ }
37
+
38
+ struct MapLabelTransitionRecord: Record {
39
+ @Field var entrance = "none"
40
+ @Field var exit = "none"
41
+ @Field var enableEntrance = true
42
+ @Field var enableExit = true
43
+ }
44
+
45
+ struct MapLabelBadgeStyleRecord: Record {
46
+ @Field var id = ""
47
+ @Field var image = MapLabelImage()
48
+ @Field var offset: MapLabelVector?
49
+ @Field var zOrder = 0
50
+ }
51
+
52
+ struct MapLabelPerLevelStyleRecord: Record {
53
+ @Field var level = 0
54
+ @Field var image: MapLabelImage?
55
+ @Field var anchor: MapLabelVector?
56
+ @Field var padding = 0.0
57
+ @Field var iconTransition: MapLabelTransitionRecord?
58
+ @Field var textTransition: MapLabelTransitionRecord?
59
+ @Field var textLayout = "center"
60
+ @Field var textLines: [MapLabelTextStyleRecord] = []
61
+ @Field var badges: [MapLabelBadgeStyleRecord] = []
62
+ }
63
+
64
+ struct MapLabelStyleRecord: Record {
65
+ @Field var id = ""
66
+ @Field var levels: [MapLabelPerLevelStyleRecord] = []
67
+ }
68
+
69
+ struct MapPathTextLevelStyleRecord: Record {
70
+ @Field var level = 0
71
+ @Field var text = MapLabelTextStyleRecord()
72
+ }
73
+
74
+ struct MapPathTextStyleRecord: Record {
75
+ @Field var id = ""
76
+ @Field var levels: [MapPathTextLevelStyleRecord] = []
77
+ }
78
+
79
+ struct MapLabelLayerRecord: Record {
80
+ @Field var id = ""
81
+ @Field var visible = true
82
+ @Field var clickable = true
83
+ @Field var zOrder = defaultLabelLayerZOrder
84
+ @Field var competition = "none"
85
+ @Field var competitionUnit = "iconAndText"
86
+ @Field var ordering = "rank"
87
+ @Field var competitionExceptions: [String] = []
88
+ }
89
+
90
+ struct MapLodLabelLayerRecord: Record {
91
+ @Field var id = ""
92
+ @Field var visible = true
93
+ @Field var clickable = true
94
+ @Field var zOrder = defaultLabelLayerZOrder
95
+ @Field var competition = "none"
96
+ @Field var competitionUnit = "iconAndText"
97
+ @Field var ordering = "rank"
98
+ @Field var competitionExceptions: [String] = []
99
+ @Field var radius = 20.0
100
+ }
101
+
102
+ struct MapLabelTextRecord: Record {
103
+ @Field var value = ""
104
+ @Field var styleIndex = 0
105
+ }
106
+
107
+ struct MapLabelBadgeRecord: Record {
108
+ @Field var id = ""
109
+ @Field var image = MapLabelImage()
110
+ @Field var offset: MapLabelVector?
111
+ @Field var zOrder = 0
112
+ @Field var visible = true
113
+ }
114
+
115
+ struct MapLabelRecord: Record {
116
+ @Field var id = ""
117
+ @Field var layerId = ""
118
+ @Field var styleId = ""
119
+ @Field var coordinate = MapLabelPoint()
120
+ @Field var text: [MapLabelTextRecord] = []
121
+ @Field var rank = 0.0
122
+ @Field var clickable = false
123
+ @Field var visible = true
124
+ @Field var transform = "default"
125
+ @Field var pixelOffset: MapLabelVector?
126
+ @Field var orientation = 0.0
127
+ @Field var scale: MapLabelVector?
128
+ @Field var badges: [MapLabelBadgeRecord] = []
129
+ }
130
+
131
+ struct MapLodLabelRecord: Record {
132
+ @Field var id = ""
133
+ @Field var layerId = ""
134
+ @Field var styleId = ""
135
+ @Field var coordinate = MapLabelPoint()
136
+ @Field var text: [MapLabelTextRecord] = []
137
+ @Field var rank = 0.0
138
+ @Field var clickable = false
139
+ @Field var visible = true
140
+ @Field var transform = "default"
141
+ @Field var badges: [MapLabelBadgeRecord] = []
142
+ }
143
+
144
+ struct MapPathLabelRecord: Record {
145
+ @Field var id = ""
146
+ @Field var layerId = ""
147
+ @Field var styleId = ""
148
+ @Field var points: [MapLabelPoint] = []
149
+ @Field var text = ""
150
+ @Field var rank = 0.0
151
+ @Field var visible = true
152
+ }
153
+
154
+ struct MapLabelAnimationKeyframeRecord: Record {
155
+ @Field var duration = 0.0
156
+ @Field var interpolation = "linear"
157
+ @Field var alpha: Double?
158
+ @Field var scale: MapLabelVector?
159
+ @Field var translation: MapLabelVector?
160
+ @Field var rotation: Double?
161
+ }
162
+
163
+ struct MapLabelAnimationRecord: Record {
164
+ @Field var id = ""
165
+ @Field var labelIds: [String] = []
166
+ @Field var effect = ""
167
+ @Field var keyframes: [MapLabelAnimationKeyframeRecord] = []
168
+ @Field var repeatCount = 1
169
+ @Field var hideAtStop = false
170
+ @Field var removeAtStop = false
171
+ @Field var resetToInitialState = false
172
+ }
173
+
174
+ struct MapLabelSceneRecord: Record {
175
+ @Field var styles: [MapLabelStyleRecord] = []
176
+ @Field var pathTextStyles: [MapPathTextStyleRecord] = []
177
+ @Field var layers: [MapLabelLayerRecord] = []
178
+ @Field var lodLayers: [MapLodLabelLayerRecord] = []
179
+ @Field var labels: [MapLabelRecord] = []
180
+ @Field var lodLabels: [MapLodLabelRecord] = []
181
+ @Field var pathLabels: [MapPathLabelRecord] = []
182
+ @Field var animations: [MapLabelAnimationRecord] = []
183
+ }
184
+
185
+ struct MapLabelCommandRecord: Record {
186
+ @Field var type = ""
187
+ @Field var id: String?
188
+ @Field var sourceId: String?
189
+ @Field var targetId: String?
190
+ @Field var badgeId: String?
191
+ @Field var style = false
192
+ @Field var visible: Bool?
193
+ @Field var enabled: Bool?
194
+ @Field var coordinate: MapLabelPoint?
195
+ @Field var points: [MapLabelPoint] = []
196
+ @Field var duration: Double?
197
+ @Field var cornerRadius = 0.0
198
+ @Field var jumpThreshold = 0.0
199
+ @Field var rotate = false
200
+ @Field var autoMove = false
201
+ @Field var baseRotation = 0.0
202
+ @Field var rotation: Double?
203
+ @Field var scale: MapLabelVector?
204
+ }
205
+
206
+ final class MapLabels {
207
+ private struct ActiveAnimationRun {
208
+ let nativeID: String
209
+ let epoch: Int
210
+ let definition: MapLabelAnimationRecord
211
+ }
212
+
213
+ private let map: KakaoMap
214
+ private let nativeNamespace = UUID().uuidString
215
+ .replacingOccurrences(of: "-", with: "").lowercased()
216
+ private let manager: LabelManager
217
+ private let trackingManager: TrackingManager
218
+ private let imageLoader: MapImageLoader
219
+ private let emitClick: ([String: Any]) -> Void
220
+ private let emitAnimationStopped: ([String: Any]) -> Void
221
+ private let resolveShape: (String) -> (any KakaoMapsSDK.Shape)?
222
+ private var labels: [String: Poi] = [:]
223
+ private var labelsWithReleasedSharing: Set<String> = []
224
+ private var lodLabels: [String: LodPoi] = [:]
225
+ private var coordinates: [String: MapLabelPoint] = [:]
226
+ private var pathLabels: [String: WaveText] = [:]
227
+ private var labelLayers: [String: KakaoMapsSDK.LabelLayer] = [:]
228
+ private var lodLabelLayers: [String: KakaoMapsSDK.LodLabelLayer] = [:]
229
+ private var publicLayerIDsByNative: [String: String] = [:]
230
+ private var eventHandlers: [DisposableEventHandler] = []
231
+ private var styleIds: Set<String> = []
232
+ private var waveTextStyleFingerprints: [String: String] = [:]
233
+ private var nativeWaveTextStyleIDs: [String: String] = [:]
234
+ private var nextNativeObjectID = 0
235
+ private var animationDefinitions: [String: MapLabelAnimationRecord] = [:]
236
+ private var activeAnimationRuns: [String: ActiveAnimationRun] = [:]
237
+ private var nextAnimationEpoch = 0
238
+ private var imageRequests: [MapImageLoadToken] = []
239
+ private var pendingApplyCompletion: ((Result<Void, Error>) -> Void)?
240
+ private var generation = 0
241
+ private var isDisposed = false
242
+
243
+ init(
244
+ map: KakaoMap,
245
+ imageLoader: MapImageLoader,
246
+ emitClick: @escaping ([String: Any]) -> Void,
247
+ emitAnimationStopped: @escaping ([String: Any]) -> Void,
248
+ resolveShape: @escaping (String) -> (any KakaoMapsSDK.Shape)?
249
+ ) {
250
+ self.map = map
251
+ manager = map.getLabelManager()
252
+ trackingManager = map.getTrackingManager()
253
+ self.imageLoader = imageLoader
254
+ self.emitClick = emitClick
255
+ self.emitAnimationStopped = emitAnimationStopped
256
+ self.resolveShape = resolveShape
257
+ }
258
+
259
+ func apply(
260
+ _ scene: MapLabelSceneRecord,
261
+ completion: @escaping (Result<Void, Error>) -> Void
262
+ ) throws {
263
+ try requireActive()
264
+ try validate(scene)
265
+ cancelPendingApply("A newer label scene replaced this apply operation.")
266
+ pendingApplyCompletion = completion
267
+ generation += 1
268
+ let currentGeneration = generation
269
+ cancelImageRequests()
270
+ loadImages(scene.imageUris, generation: currentGeneration) { [weak self] result in
271
+ guard let self, !isDisposed, currentGeneration == generation else { return }
272
+ do {
273
+ let images = try result.get()
274
+ try rebuild(scene, images: images)
275
+ finishApply(.success(()))
276
+ } catch {
277
+ finishApply(.failure(error))
278
+ }
279
+ }
280
+ }
281
+
282
+ func command(_ command: MapLabelCommandRecord) throws {
283
+ try requireActive()
284
+ let duration = try (command.duration ?? 0).duration()
285
+ switch command.type {
286
+ case "show":
287
+ let visible = try command.visible.required("visible")
288
+ guard duration == 0 else {
289
+ throw labelError(labelUnsupported, "iOS label auto-move does not expose a duration.")
290
+ }
291
+ try show(command.requiredID(), visible: visible, autoMove: command.autoMove)
292
+ case "move":
293
+ let id = try command.requiredID()
294
+ let poi = try label(id)
295
+ let point = try command.coordinate.required("coordinate").mapPoint
296
+ if duration == 0 {
297
+ poi.position = point
298
+ } else if labelsWithReleasedSharing.contains(id) {
299
+ // Kakao Maps 2.12.19 ignores moveAt on former sharing followers.
300
+ poi.moveOnPath(
301
+ [poi.position, point],
302
+ duration: UInt(duration),
303
+ cornerRadius: 0,
304
+ jumpThreshold: 0
305
+ )
306
+ } else {
307
+ poi.moveAt(point, duration: UInt(duration))
308
+ }
309
+ case "movePath":
310
+ _ = try command.duration.required("duration")
311
+ guard command.points.count >= 2 else {
312
+ throw labelError(labelInvalidArgument, "movePath requires at least two points.")
313
+ }
314
+ let points = try command.points.map { try $0.mapPoint }
315
+ guard points.count >= 2 else { throw labelError(
316
+ labelInvalidArgument,
317
+ "A path needs at least two points."
318
+ ) }
319
+ guard command.cornerRadius >= 0, command.jumpThreshold >= 0 else {
320
+ throw labelError(
321
+ labelInvalidArgument,
322
+ "cornerRadius and jumpThreshold must be non-negative."
323
+ )
324
+ }
325
+ let poi = try label(command.requiredID())
326
+ if command.rotate {
327
+ try poi.moveAndRotateOnPath(
328
+ points,
329
+ baseRadian: command.baseRotation.finiteFloat("baseRotation") * .pi / 180,
330
+ duration: UInt(duration),
331
+ cornerRadius: command.cornerRadius.finiteFloat("cornerRadius"),
332
+ jumpThreshold: command.jumpThreshold.finiteFloat("jumpThreshold")
333
+ )
334
+ } else {
335
+ try poi.moveOnPath(
336
+ points,
337
+ duration: UInt(duration),
338
+ cornerRadius: command.cornerRadius.finiteFloat("cornerRadius"),
339
+ jumpThreshold: command.jumpThreshold.finiteFloat("jumpThreshold")
340
+ )
341
+ }
342
+ case "rotate":
343
+ let poi = try label(command.requiredID())
344
+ let rotation = try Double(command.rotation.required("rotation").finiteFloat("rotation")) *
345
+ .pi / 180
346
+ if duration == 0 {
347
+ poi.orientation = rotation
348
+ } else {
349
+ poi.rotateAt(rotation, duration: UInt(duration))
350
+ }
351
+ case "scale":
352
+ let scale = try command.scale.required("scale")
353
+ try scale.validateScale()
354
+ throw labelError(
355
+ labelUnsupported,
356
+ "The iOS Kakao Maps SDK has no direct POI scale command; use a scale animation."
357
+ )
358
+ case "sharePosition":
359
+ try share(command, transform: false)
360
+ case "shareTransform":
361
+ try share(command, transform: true)
362
+ case "shareShapeTransform":
363
+ let isEnabled = try command.enabled.required("enabled")
364
+ let source = try label(command.sourceId.required("sourceId"))
365
+ let targetID = try command.targetId.required("targetId")
366
+ guard let target = resolveShape(targetID) else { throw notFound("shape", targetID) }
367
+ isEnabled ? source.shareTransformWithShape(target) : source
368
+ .removeShareTransformWithShape(target)
369
+ case "setBadgeVisibility":
370
+ try setBadgeVisibility(command)
371
+ case "startAnimation":
372
+ let id = try command.requiredID()
373
+ try startAnimation(id)
374
+ case "stopAnimation":
375
+ let id = try command.requiredID()
376
+ guard animationDefinitions[id] != nil else {
377
+ throw labelError(labelNotFound, "Animation not found: \(id)")
378
+ }
379
+ if let run = activeAnimationRuns[id] {
380
+ finishAnimationRun(id: id, run: run, finished: false, stopNative: true)
381
+ }
382
+ case "startTracking":
383
+ try trackingManager.startTrackingPoi(label(command.requiredID()))
384
+ trackingManager.isTrackingRoll = command.rotate
385
+ case "stopTracking":
386
+ trackingManager.stopTracking()
387
+ default:
388
+ throw labelError(labelInvalidArgument, "Unknown label command: \(command.type)")
389
+ }
390
+ }
391
+
392
+ func capabilities() -> [String: Any] {
393
+ [
394
+ "pathText": "waveText",
395
+ "pathTextRank": true,
396
+ "pathTextStyleAddition": true,
397
+ "pathTextStyleDefinitionUpdate": false,
398
+ "pathTextStyleRegistrationRemoval": false,
399
+ "lodPositionMutation": false,
400
+ "lodAutoMove": true,
401
+ "autoMoveDuration": false,
402
+ "labelScaleCommand": false,
403
+ "sharedShapeTransform": true,
404
+ "sharedShapeTarget": "shape",
405
+ "competitionExceptions": false,
406
+ "supportedTransforms": [
407
+ "absoluteRotation", "default", "absoluteRotationKeepUpright",
408
+ "absoluteRotationDecal", "decal"
409
+ ],
410
+ "keyframeRepeatCount": false,
411
+ "trackingStateSource": "native",
412
+ "remoteImages": true
413
+ ]
414
+ }
415
+
416
+ func state() -> [String: Any] {
417
+ guard !isDisposed else {
418
+ return [
419
+ "labels": [],
420
+ "animationIds": [],
421
+ "tracking": ["active": false, "source": "native"]
422
+ ]
423
+ }
424
+ var entries: [[String: Any]] = []
425
+ let liveLabelIDs = Set(labels.compactMap { id, cached -> String? in
426
+ guard let native = labelLayers[cached.layerID]?.getPoi(poiID: id) else { return nil }
427
+ let position = native.position.wgsCoord
428
+ entries.append([
429
+ "id": id,
430
+ "kind": "label",
431
+ "visible": native.isShow,
432
+ "coordinate": [
433
+ "latitude": position.latitude,
434
+ "longitude": position.longitude
435
+ ]
436
+ ])
437
+ return id
438
+ })
439
+ let liveLodLabelIDs = Set(lodLabels.compactMap { id, cached -> String? in
440
+ guard let native = lodLabelLayers[cached.layerID]?.getLodPoi(poiID: id) else { return nil }
441
+ entries.append(["id": id, "kind": "lodLabel", "visible": native.isShow])
442
+ return id
443
+ })
444
+ let livePathLabelIDs = Set(pathLabels.compactMap { id, cached -> String? in
445
+ guard let native = labelLayers[cached.layerID]?.getWaveText(waveTextID: id)
446
+ else { return nil }
447
+ entries.append(["id": id, "kind": "pathLabel", "visible": native.isShow])
448
+ return id
449
+ })
450
+ labels = labels.filter { liveLabelIDs.contains($0.key) }
451
+ lodLabels = lodLabels.filter { liveLodLabelIDs.contains($0.key) }
452
+ pathLabels = pathLabels.filter { livePathLabelIDs.contains($0.key) }
453
+ coordinates = coordinates
454
+ .filter { liveLabelIDs.contains($0.key) || liveLodLabelIDs.contains($0.key) }
455
+ return [
456
+ "labels": entries.sorted { ($0["id"] as? String ?? "") < ($1["id"] as? String ?? "") },
457
+ "animationIds": animationDefinitions.keys.sorted(),
458
+ "tracking": ["active": trackingManager.isTracking, "source": "native"]
459
+ ]
460
+ }
461
+
462
+ func handlesPoiTap(layerId: String, id: String) -> Bool {
463
+ guard !isDisposed, let cached = labels[id], cached.layerID == layerId else { return false }
464
+ return labelLayers[layerId]?.getPoi(poiID: id) != nil
465
+ }
466
+
467
+ func dispose() {
468
+ guard !isDisposed else { return }
469
+ isDisposed = true
470
+ generation += 1
471
+ clearAnimationRuns()
472
+ cancelImageRequests()
473
+ cancelPendingApply("The map view was destroyed.")
474
+ trackingManager.stopTracking()
475
+ clearHandlers()
476
+ clearLayersAndStyles()
477
+ labels.removeAll()
478
+ lodLabels.removeAll()
479
+ coordinates.removeAll()
480
+ pathLabels.removeAll()
481
+ labelLayers.removeAll()
482
+ lodLabelLayers.removeAll()
483
+ publicLayerIDsByNative.removeAll()
484
+ animationDefinitions.removeAll()
485
+ waveTextStyleFingerprints.removeAll()
486
+ nativeWaveTextStyleIDs.removeAll()
487
+ }
488
+
489
+ private func rebuild(_ scene: MapLabelSceneRecord, images: [String: UIImage]) throws {
490
+ let nativePoiStyles = try scene.styles.map { style in
491
+ let nativeID = makeNativeID("poi_style")
492
+ return try (publicID: style.id, native: style.native(images, styleID: nativeID))
493
+ }
494
+ var nextWaveTextStyleFingerprints = waveTextStyleFingerprints
495
+ var nativeWaveTextStyles: [WaveTextStyle] = []
496
+ for style in scene.pathTextStyles {
497
+ let fingerprint = style.fingerprint
498
+ if let registered = waveTextStyleFingerprints[style.id] {
499
+ guard registered == fingerprint else {
500
+ throw labelError(
501
+ labelUnsupported,
502
+ "iOS Kakao Maps SDK cannot replace the registered WaveText style: \(style.id)"
503
+ )
504
+ }
505
+ } else {
506
+ let nativeID = makeNativeID("wave_style")
507
+ nativeWaveTextStyleIDs[style.id] = nativeID
508
+ try nativeWaveTextStyles.append(style.native(styleID: nativeID))
509
+ nextWaveTextStyleFingerprints[style.id] = fingerprint
510
+ }
511
+ }
512
+ let labelLayerOptions = try scene.layers.map { layer in
513
+ try layer.options(layerID: makeNativeID("layer"))
514
+ }
515
+ let lodLabelLayerOptions = try scene.lodLayers.map { layer in
516
+ try layer.options(layerID: makeNativeID("lod_layer"))
517
+ }
518
+ clearAnimationRuns()
519
+ trackingManager.stopTracking()
520
+ clearHandlers()
521
+ clearLayersAndStyles()
522
+ labels.removeAll()
523
+ lodLabels.removeAll()
524
+ coordinates.removeAll()
525
+ pathLabels.removeAll()
526
+ labelLayers.removeAll()
527
+ lodLabelLayers.removeAll()
528
+ publicLayerIDsByNative.removeAll()
529
+ animationDefinitions.removeAll()
530
+
531
+ var poiStyleIDs: [String: String] = [:]
532
+ for style in nativePoiStyles {
533
+ manager.addPoiStyle(style.native)
534
+ styleIds.insert(style.native.styleID)
535
+ poiStyleIDs[style.publicID] = style.native.styleID
536
+ }
537
+ for style in nativeWaveTextStyles {
538
+ manager.addWaveTextStyle(style)
539
+ }
540
+ waveTextStyleFingerprints = nextWaveTextStyleFingerprints
541
+
542
+ var layers: [String: LabelLayer] = [:]
543
+ for (layer, options) in zip(scene.layers, labelLayerOptions) {
544
+ guard let native = manager.addLabelLayer(option: options) else {
545
+ throw labelError(labelInvalidArgument, "Could not create label layer \(layer.id).")
546
+ }
547
+ native.visible = layer.visible
548
+ native.setClickable(layer.clickable)
549
+ layers[layer.id] = native
550
+ labelLayers[native.layerID] = native
551
+ publicLayerIDsByNative[native.layerID] = layer.id
552
+ }
553
+ var lodLayers: [String: LodLabelLayer] = [:]
554
+ for (layer, options) in zip(scene.lodLayers, lodLabelLayerOptions) {
555
+ guard let native = manager.addLodLabelLayer(option: options) else {
556
+ throw labelError(labelInvalidArgument, "Could not create LOD label layer \(layer.id).")
557
+ }
558
+ native.visible = layer.visible
559
+ native.setClickable(layer.clickable)
560
+ lodLayers[layer.id] = native
561
+ lodLabelLayers[native.layerID] = native
562
+ publicLayerIDsByNative[native.layerID] = layer.id
563
+ }
564
+
565
+ for item in scene.labels {
566
+ guard let layer = layers[item.layerId] else { throw notFound("layer", item.layerId) }
567
+ guard let styleID = poiStyleIDs[item.styleId] else { throw notFound("style", item.styleId) }
568
+ let option = try item.options(styleID: styleID)
569
+ guard let poi = try layer.addPoi(option: option, at: item.coordinate.mapPoint) else {
570
+ throw labelError(labelInvalidArgument, "Could not create label \(item.id).")
571
+ }
572
+ poi.pixelOffset = try item.pixelOffset?.pixelPoint ?? .zero
573
+ poi.orientation = item.orientation * .pi / 180
574
+ for badge in item.badges {
575
+ let native = try badge.native(images)
576
+ poi.addBadge(native)
577
+ if !badge.visible {
578
+ poi.hideBadge(badgeID: badge.id)
579
+ }
580
+ }
581
+ item.visible ? poi.show() : poi.hide()
582
+ eventHandlers.append(poi.addPoiTappedEventHandler(target: self) { target in
583
+ { param in target.emit(param.poiItem, kind: "label") }
584
+ })
585
+ labels[item.id] = poi
586
+ coordinates[item.id] = item.coordinate
587
+ }
588
+ for item in scene.lodLabels {
589
+ guard let layer = lodLayers[item.layerId] else { throw notFound("LOD layer", item.layerId) }
590
+ guard let styleID = poiStyleIDs[item.styleId] else { throw notFound("style", item.styleId) }
591
+ let option = try item.options(styleID: styleID)
592
+ guard let poi = try layer.addLodPoi(option: option, at: item.coordinate.mapPoint)
593
+ else {
594
+ throw labelError(labelInvalidArgument, "Could not create LOD label \(item.id).")
595
+ }
596
+ for badge in item.badges {
597
+ let native = try badge.native(images)
598
+ poi.addBadge(native)
599
+ if !badge.visible {
600
+ poi.hideBadge(badgeID: badge.id)
601
+ }
602
+ }
603
+ item.visible ? poi.show() : poi.hide()
604
+ eventHandlers.append(poi.addPoiTappedEventHandler(target: self) { target in
605
+ { param in target.emit(param.poiItem, kind: "lodLabel") }
606
+ })
607
+ lodLabels[item.id] = poi
608
+ coordinates[item.id] = item.coordinate
609
+ }
610
+ for item in scene.pathLabels {
611
+ guard let layer = layers[item.layerId] else { throw notFound("layer", item.layerId) }
612
+ guard let styleID = nativeWaveTextStyleIDs[item.styleId] else {
613
+ throw notFound("path text style", item.styleId)
614
+ }
615
+ let option = WaveTextOptions(styleID: styleID, waveTextID: item.id)
616
+ option.points = try item.points.map { try $0.mapPoint }
617
+ option.text = item.text
618
+ option.rank = try UInt(item.rank.nonnegativeInteger("rank"))
619
+ guard let text = layer.addWaveText(option) else {
620
+ throw labelError(labelInvalidArgument, "Could not create path label \(item.id).")
621
+ }
622
+ item.visible ? text.show() : text.hide()
623
+ pathLabels[item.id] = text
624
+ }
625
+ for animation in scene.animations {
626
+ try addAnimation(animation)
627
+ }
628
+ }
629
+
630
+ private func addAnimation(_ animation: MapLabelAnimationRecord) throws {
631
+ animationDefinitions[animation.id] = animation
632
+ }
633
+
634
+ private func startAnimation(_ id: String) throws {
635
+ guard var definition = animationDefinitions[id] else {
636
+ throw labelError(labelNotFound, "Animation not found: \(id)")
637
+ }
638
+ if let run = activeAnimationRuns[id] {
639
+ finishAnimationRun(id: id, run: run, finished: false, stopNative: true)
640
+ guard let currentDefinition = animationDefinitions[id] else {
641
+ throw labelError(labelNotFound, "Animation not found: \(id)")
642
+ }
643
+ definition = currentDefinition
644
+ }
645
+ let targets = try definition.labelIds.map(label)
646
+ nextAnimationEpoch += 1
647
+ let epoch = nextAnimationEpoch
648
+ let nativeID = makeNativeID("animation")
649
+ let effect = try definition.effectObject()
650
+ guard let animator = manager.addPoiAnimator(
651
+ animatorID: nativeID,
652
+ effect: effect
653
+ ) else {
654
+ throw labelError(labelInvalidArgument, "Could not create animation \(id).")
655
+ }
656
+ do {
657
+ try animator.addPois(targets)
658
+ } catch {
659
+ manager.removePoiAnimator(animatorID: nativeID)
660
+ throw error
661
+ }
662
+ let run = ActiveAnimationRun(nativeID: nativeID, epoch: epoch, definition: definition)
663
+ activeAnimationRuns[id] = run
664
+ animator.setStopCallback { [weak self] _ in
665
+ guard let self, !isDisposed, let active = activeAnimationRuns[id],
666
+ active.nativeID == nativeID, active.epoch == epoch
667
+ else { return }
668
+ finishAnimationRun(id: id, run: active, finished: true, stopNative: false)
669
+ }
670
+ animator.start()
671
+ }
672
+
673
+ private func finishAnimationRun(
674
+ id: String,
675
+ run: ActiveAnimationRun,
676
+ finished: Bool,
677
+ stopNative: Bool
678
+ ) {
679
+ guard let active = activeAnimationRuns[id], active.nativeID == run.nativeID,
680
+ active.epoch == run.epoch
681
+ else { return }
682
+ activeAnimationRuns.removeValue(forKey: id)
683
+ if stopNative {
684
+ manager.getPoiAnimator(animatorID: run.nativeID)?.stop()
685
+ }
686
+ manager.removePoiAnimator(animatorID: run.nativeID)
687
+ if run.definition.removeAtStop {
688
+ removeAnimationTargets(run.definition.labelIds)
689
+ }
690
+ emitAnimationStopped(["id": id, "finished": finished])
691
+ }
692
+
693
+ private func clearAnimationRuns() {
694
+ let runs = Array(activeAnimationRuns.values)
695
+ activeAnimationRuns.removeAll()
696
+ for run in runs {
697
+ manager.getPoiAnimator(animatorID: run.nativeID)?.stop()
698
+ manager.removePoiAnimator(animatorID: run.nativeID)
699
+ }
700
+ }
701
+
702
+ private func removeAnimationTargets(_ ids: [String]) {
703
+ var removedIDs = Set(ids)
704
+ guard !removedIDs.isEmpty else { return }
705
+ var canceledRuns: [(id: String, run: ActiveAnimationRun)] = []
706
+ while true {
707
+ let invalidDefinitionIDs = animationDefinitions.compactMap { id, definition in
708
+ definition.labelIds.contains(where: removedIDs.contains) ? id : nil
709
+ }
710
+ guard !invalidDefinitionIDs.isEmpty else { break }
711
+ var didAddRemovedID = false
712
+ for id in invalidDefinitionIDs {
713
+ animationDefinitions.removeValue(forKey: id)
714
+ if let run = activeAnimationRuns.removeValue(forKey: id) {
715
+ canceledRuns.append((id, run))
716
+ if run.definition.removeAtStop {
717
+ for targetID in run.definition.labelIds where removedIDs.insert(targetID).inserted {
718
+ didAddRemovedID = true
719
+ }
720
+ }
721
+ }
722
+ }
723
+ if !didAddRemovedID {
724
+ break
725
+ }
726
+ }
727
+ for canceled in canceledRuns {
728
+ manager.getPoiAnimator(animatorID: canceled.run.nativeID)?.stop()
729
+ manager.removePoiAnimator(animatorID: canceled.run.nativeID)
730
+ emitAnimationStopped(["id": canceled.id, "finished": false])
731
+ }
732
+ for id in removedIDs {
733
+ labels.removeValue(forKey: id)
734
+ coordinates.removeValue(forKey: id)
735
+ }
736
+ trackingManager.stopTracking()
737
+ }
738
+
739
+ private func show(_ id: String, visible: Bool, autoMove: Bool) throws {
740
+ if let poi = labels[id] {
741
+ if visible, autoMove {
742
+ poi.showWithAutoMove()
743
+ } else {
744
+ visible ? poi.show() : poi.hide()
745
+ }
746
+ return
747
+ }
748
+ if let poi = lodLabels[id] {
749
+ if visible, autoMove {
750
+ poi.showWithAutoMove()
751
+ } else {
752
+ visible ? poi.show() : poi.hide()
753
+ }
754
+ return
755
+ }
756
+ if let text = pathLabels[id] {
757
+ if autoMove {
758
+ throw labelError(labelUnsupported, "Path labels do not expose auto-move.")
759
+ }
760
+ visible ? text.show() : text.hide(); return
761
+ }
762
+ throw notFound("label", id)
763
+ }
764
+
765
+ private func share(_ command: MapLabelCommandRecord, transform: Bool) throws {
766
+ let isEnabled = try command.enabled.required("enabled")
767
+ let source = try label(command.sourceId.required("sourceId"))
768
+ let targetID = try command.targetId.required("targetId")
769
+ let target = try label(targetID)
770
+ if transform {
771
+ isEnabled ? source.shareTransformWithPoi(target) : source
772
+ .removeShareTransformWithPoi(target)
773
+ } else {
774
+ isEnabled ? source.sharePositionWithPoi(target) : source
775
+ .removeSharePositionWithPoi(target)
776
+ }
777
+ if !isEnabled {
778
+ labelsWithReleasedSharing.insert(targetID)
779
+ }
780
+ }
781
+
782
+ private func setBadgeVisibility(_ command: MapLabelCommandRecord) throws {
783
+ let isVisible = try command.visible.required("visible")
784
+ let id = try command.requiredID()
785
+ let badgeID = try command.badgeId.required("badgeId")
786
+ if let poi = labels[id] {
787
+ if command.style {
788
+ isVisible ? poi.showStyleBadge(badgeID: badgeID) : poi
789
+ .hideStyleBadge(badgeID: badgeID)
790
+ } else {
791
+ isVisible ? poi.showBadge(badgeID: badgeID) : poi.hideBadge(badgeID: badgeID)
792
+ }
793
+ return
794
+ }
795
+ if let poi = lodLabels[id] {
796
+ if command.style {
797
+ isVisible ? poi.showStyleBadge(badgeID: badgeID) : poi
798
+ .hideStyleBadge(badgeID: badgeID)
799
+ } else {
800
+ isVisible ? poi.showBadge(badgeID: badgeID) : poi.hideBadge(badgeID: badgeID)
801
+ }
802
+ return
803
+ }
804
+ throw notFound("label", id)
805
+ }
806
+
807
+ private func label(_ id: String) throws -> Poi {
808
+ if let label = labels[id] {
809
+ return label
810
+ }
811
+ if lodLabels[id] != nil {
812
+ throw labelError(
813
+ labelUnsupported,
814
+ "LOD label \(id) cannot be moved, transformed, animated, or tracked."
815
+ )
816
+ }
817
+ throw notFound("label", id)
818
+ }
819
+
820
+ private func emit(_ label: any KakaoMapsSDK.Label, kind: String) {
821
+ guard !isDisposed, let coordinate = coordinates[label.itemID] else { return }
822
+ if let poi = label as? Poi {
823
+ guard let current = labels[label.itemID],
824
+ let native = labelLayers[label.layerID]?.getPoi(poiID: label.itemID),
825
+ current === poi, native === poi
826
+ else { return }
827
+ } else if let poi = label as? LodPoi {
828
+ guard let current = lodLabels[label.itemID],
829
+ let native = lodLabelLayers[label.layerID]?.getLodPoi(poiID: label.itemID),
830
+ current === poi, native === poi
831
+ else { return }
832
+ } else {
833
+ return
834
+ }
835
+ let latitude: Double
836
+ let longitude: Double
837
+ if let poi = label as? Poi {
838
+ latitude = poi.position.wgsCoord.latitude
839
+ longitude = poi.position.wgsCoord.longitude
840
+ } else {
841
+ latitude = coordinate.latitude
842
+ longitude = coordinate.longitude
843
+ }
844
+ emitClick([
845
+ "id": label.itemID,
846
+ "layerId": publicLayerIDsByNative[label.layerID] ?? label.layerID,
847
+ "kind": kind,
848
+ "coordinate": [
849
+ "latitude": latitude,
850
+ "longitude": longitude
851
+ ]
852
+ ])
853
+ }
854
+
855
+ private func clearHandlers() {
856
+ eventHandlers.forEach { $0.dispose() }
857
+ eventHandlers.removeAll()
858
+ }
859
+
860
+ private func clearLayersAndStyles() {
861
+ labelsWithReleasedSharing.removeAll()
862
+ labelLayers.keys.forEach(manager.removeLabelLayer)
863
+ lodLabelLayers.keys.forEach(manager.removeLodLabelLayer)
864
+ styleIds.forEach(manager.removePoiStyle)
865
+ styleIds.removeAll()
866
+ }
867
+
868
+ private func makeNativeID(_ kind: String) -> String {
869
+ nextNativeObjectID += 1
870
+ return "__rnk_\(nativeNamespace)_\(kind)_\(nextNativeObjectID)"
871
+ }
872
+
873
+ private func requireActive() throws {
874
+ if isDisposed {
875
+ throw labelError("ERR_KAKAO_MAP_VIEW_DESTROYED", "The map view was destroyed.")
876
+ }
877
+ }
878
+
879
+ private func loadImages(
880
+ _ uris: Set<String>,
881
+ generation currentGeneration: Int,
882
+ completion: @escaping (Result<[String: UIImage], Error>) -> Void
883
+ ) {
884
+ guard !uris.isEmpty else {
885
+ completion(.success([:]))
886
+ return
887
+ }
888
+ var images: [String: UIImage] = [:]
889
+ var remaining = uris.count
890
+ var didFinish = false
891
+ for uri in uris {
892
+ let token = imageLoader.load(uri) { [weak self] result in
893
+ guard let self, !isDisposed, currentGeneration == generation, !didFinish else { return }
894
+ switch result {
895
+ case let .success(image):
896
+ images[uri] = image
897
+ remaining -= 1
898
+ if remaining == 0 {
899
+ didFinish = true
900
+ imageRequests.removeAll()
901
+ completion(.success(images))
902
+ }
903
+ case let .failure(error):
904
+ didFinish = true
905
+ cancelImageRequests()
906
+ completion(.failure(error))
907
+ }
908
+ }
909
+ imageRequests.append(token)
910
+ }
911
+ }
912
+
913
+ private func cancelImageRequests() {
914
+ imageRequests.forEach { $0.cancel() }
915
+ imageRequests.removeAll()
916
+ }
917
+
918
+ private func finishApply(_ result: Result<Void, Error>) {
919
+ guard let completion = pendingApplyCompletion else { return }
920
+ pendingApplyCompletion = nil
921
+ completion(result)
922
+ }
923
+
924
+ private func cancelPendingApply(_ message: String) {
925
+ finishApply(.failure(labelError(labelApplyCancelled, message)))
926
+ }
927
+ }
928
+
929
+ private func validate(_ scene: MapLabelSceneRecord) throws {
930
+ try unique("style", scene.styles.map(\.id))
931
+ try unique("path text style", scene.pathTextStyles.map(\.id))
932
+ try unique("layer", scene.layers.map(\.id) + scene.lodLayers.map(\.id))
933
+ try unique(
934
+ "label",
935
+ scene.labels.map(\.id) + scene.lodLabels.map(\.id) + scene.pathLabels.map(\.id)
936
+ )
937
+ try unique("animation", scene.animations.map(\.id))
938
+ let styles = Dictionary(uniqueKeysWithValues: scene.styles.map { ($0.id, $0) })
939
+ let layerIDs = Set(scene.layers.map(\.id))
940
+ let lodLayerIDs = Set(scene.lodLayers.map(\.id))
941
+ let regularLabelIDs = Set(scene.labels.map(\.id))
942
+ let allIDs = scene.styles.map(\.id) + scene.pathTextStyles.map(\.id) + Array(layerIDs) +
943
+ Array(lodLayerIDs) + Array(regularLabelIDs) + scene.lodLabels.map(\.id) +
944
+ scene.pathLabels.map(\.id) + scene.animations.map(\.id)
945
+ if allIDs.contains(where: \.isEmpty) {
946
+ throw labelError(labelInvalidArgument, "IDs must not be empty.")
947
+ }
948
+ for style in scene.styles {
949
+ guard !style.levels.isEmpty else { throw labelError(
950
+ labelInvalidArgument,
951
+ "Style \(style.id) needs at least one level."
952
+ ) }
953
+ try unique("style level", style.levels.map { String($0.level) })
954
+ for level in style.levels {
955
+ guard level.padding >= 0 else {
956
+ throw labelError(labelInvalidArgument, "Style padding must be non-negative.")
957
+ }
958
+ try unique("style badge", level.badges.map(\.id))
959
+ try level.image?.validate()
960
+ try level.badges.forEach { try $0.image.validate() }
961
+ }
962
+ }
963
+ for style in scene.pathTextStyles {
964
+ guard !style.levels.isEmpty else {
965
+ throw labelError(labelInvalidArgument, "Path style \(style.id) needs at least one level.")
966
+ }
967
+ try unique("path style level", style.levels.map { String($0.level) })
968
+ }
969
+ if scene.layers.contains(where: { !$0.competitionExceptions.isEmpty }) ||
970
+ scene.lodLayers.contains(where: { !$0.competitionExceptions.isEmpty })
971
+ {
972
+ throw labelError(
973
+ labelUnsupported,
974
+ "iOS Kakao Maps SDK does not expose layer competition exceptions."
975
+ )
976
+ }
977
+ for layer in scene.lodLayers where layer.radius < 0 {
978
+ throw labelError(labelInvalidArgument, "LOD radius must be non-negative.")
979
+ }
980
+ for item in scene.labels {
981
+ _ = try item.coordinate.mapPoint
982
+ guard layerIDs.contains(item.layerId) else { throw notFound("layer", item.layerId) }
983
+ guard let style = styles[item.styleId] else { throw notFound("style", item.styleId) }
984
+ try validateTextIndices(item.text, style: style, labelID: item.id)
985
+ _ = try item.rank.integer("rank")
986
+ _ = try transformType(item.transform)
987
+ _ = try item.pixelOffset?.point
988
+ _ = try item.orientation.finiteFloat("orientation")
989
+ try item.scale?.validateScale()
990
+ try unique("label badge", item.badges.map(\.id))
991
+ try item.badges.forEach {
992
+ try $0.image.validate()
993
+ _ = try $0.offset?.point
994
+ }
995
+ }
996
+ for item in scene.lodLabels {
997
+ _ = try item.coordinate.mapPoint
998
+ guard lodLayerIDs.contains(item.layerId) else { throw notFound("LOD layer", item.layerId) }
999
+ guard let style = styles[item.styleId] else { throw notFound("style", item.styleId) }
1000
+ try validateTextIndices(item.text, style: style, labelID: item.id)
1001
+ _ = try item.rank.integer("rank")
1002
+ _ = try transformType(item.transform)
1003
+ try unique("label badge", item.badges.map(\.id))
1004
+ try item.badges.forEach {
1005
+ try $0.image.validate()
1006
+ _ = try $0.offset?.point
1007
+ }
1008
+ }
1009
+ for item in scene.pathLabels {
1010
+ guard item.points.count >= 2 else {
1011
+ throw labelError(labelInvalidArgument, "Path label \(item.id) needs at least two points.")
1012
+ }
1013
+ _ = try item.points.map { try $0.mapPoint }
1014
+ guard layerIDs.contains(item.layerId) else { throw notFound("layer", item.layerId) }
1015
+ guard scene.pathTextStyles.contains(where: { $0.id == item.styleId }) else {
1016
+ throw notFound("path text style", item.styleId)
1017
+ }
1018
+ _ = try item.rank.nonnegativeInteger("rank")
1019
+ }
1020
+ for animation in scene.animations {
1021
+ guard !animation.labelIds.isEmpty, !animation.keyframes.isEmpty else {
1022
+ throw labelError(labelInvalidArgument, "Animation \(animation.id) cannot be empty.")
1023
+ }
1024
+ try unique("animation label", animation.labelIds)
1025
+ for id in animation.labelIds where !regularLabelIDs.contains(id) {
1026
+ throw notFound("animatable label", id)
1027
+ }
1028
+ guard animation.repeatCount >= 0 else {
1029
+ throw labelError(labelInvalidArgument, "Animation repeatCount must be non-negative.")
1030
+ }
1031
+ guard ["drop", "alpha", "scale", "scaleAlpha", "transform"].contains(animation.effect) else {
1032
+ throw labelError(labelInvalidArgument, "Unknown label animation effect: \(animation.effect)")
1033
+ }
1034
+ if animation.effect != "drop", animation.repeatCount != 1 {
1035
+ throw labelError(
1036
+ labelUnsupported,
1037
+ "Keyframe label animations do not expose repeatCount on iOS."
1038
+ )
1039
+ }
1040
+ if animation.effect == "drop", animation.keyframes.count != 1 {
1041
+ throw labelError(labelInvalidArgument, "Drop animation requires exactly one keyframe.")
1042
+ }
1043
+ for frame in animation.keyframes {
1044
+ _ = try frame.duration.duration()
1045
+ _ = try interpolation(frame.interpolation, duration: frame.duration)
1046
+ _ = try frame.alpha?.unitFloat("alpha")
1047
+ try frame.scale?.validateScale()
1048
+ _ = try frame.translation?.point
1049
+ _ = try frame.rotation?.finiteFloat("rotation")
1050
+ if ["scale", "scaleAlpha"].contains(animation.effect), frame.scale == nil {
1051
+ throw labelError(
1052
+ labelInvalidArgument,
1053
+ "\(animation.effect) animation requires scale in every keyframe."
1054
+ )
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ private func validateTextIndices(
1061
+ _ texts: [MapLabelTextRecord],
1062
+ style: MapLabelStyleRecord,
1063
+ labelID: String
1064
+ ) throws {
1065
+ for text in texts where text.styleIndex < 0 || style.levels.contains(where: {
1066
+ text.styleIndex >= $0.textLines.count
1067
+ }) {
1068
+ throw labelError(
1069
+ labelInvalidArgument,
1070
+ "Label \(labelID) has a text style index that is unavailable at one or more levels."
1071
+ )
1072
+ }
1073
+ }
1074
+
1075
+ private extension MapLabelSceneRecord {
1076
+ var imageUris: Set<String> {
1077
+ var result = Set<String>()
1078
+ for level in styles.flatMap(\.levels) {
1079
+ if let image = level.image {
1080
+ result.insert(image.uri)
1081
+ }
1082
+ level.badges.forEach { result.insert($0.image.uri) }
1083
+ }
1084
+ (labels.flatMap(\.badges) + lodLabels.flatMap(\.badges)).forEach { result.insert($0.image.uri) }
1085
+ return result
1086
+ }
1087
+ }
1088
+
1089
+ private extension MapLabelStyleRecord {
1090
+ func native(_ images: [String: UIImage], styleID: String) throws -> PoiStyle {
1091
+ let styles = try levels.sorted { $0.level < $1.level }.map { level -> PerLevelPoiStyle in
1092
+ let icon: PoiIconStyle? = try level.image.map { image in
1093
+ guard let native = images[image.uri] else { throw labelError(
1094
+ labelInvalidArgument,
1095
+ "Image was not loaded: \(image.uri)"
1096
+ ) }
1097
+ let transition = level.iconTransition ?? MapLabelTransitionRecord()
1098
+ return try PoiIconStyle(
1099
+ symbol: image.resized(native),
1100
+ anchorPoint: level.anchor?.point ?? CGPoint(x: 0.5, y: 0.5),
1101
+ transition: transition.native(),
1102
+ enableEntranceTransition: transition.enableEntrance,
1103
+ enableExitTransition: transition.enableExit,
1104
+ badges: level.badges.map { try $0.native(images) }
1105
+ )
1106
+ }
1107
+ let text: PoiTextStyle? = try level.textLines.isEmpty ? nil : {
1108
+ let transition = level.textTransition ?? MapLabelTransitionRecord()
1109
+ let result = try PoiTextStyle(
1110
+ transition: transition.native(),
1111
+ enableEntranceTransition: transition.enableEntrance,
1112
+ enableExitTransition: transition.enableExit,
1113
+ textLineStyles: level.textLines.map { try PoiTextLineStyle(textStyle: $0.native()) }
1114
+ )
1115
+ result.textLayouts = try Array(
1116
+ repeating: textLayout(level.textLayout),
1117
+ count: level.textLines.count
1118
+ )
1119
+ return result
1120
+ }()
1121
+ if let icon, let text {
1122
+ return try PerLevelPoiStyle(
1123
+ iconStyle: icon,
1124
+ textStyle: text,
1125
+ padding: level.padding.pixelFloat("padding"),
1126
+ level: level.level
1127
+ )
1128
+ }
1129
+ if let icon {
1130
+ return try PerLevelPoiStyle(
1131
+ iconStyle: icon,
1132
+ padding: level.padding.pixelFloat("padding"),
1133
+ level: level.level
1134
+ )
1135
+ }
1136
+ if let text {
1137
+ return try PerLevelPoiStyle(
1138
+ textStyle: text,
1139
+ padding: level.padding.pixelFloat("padding"),
1140
+ level: level.level
1141
+ )
1142
+ }
1143
+ throw labelError(
1144
+ labelInvalidArgument,
1145
+ "Style \(id) level \(level.level) needs an image or text style."
1146
+ )
1147
+ }
1148
+ return PoiStyle(styleID: styleID, styles: styles)
1149
+ }
1150
+ }
1151
+
1152
+ private extension MapPathTextStyleRecord {
1153
+ var fingerprint: String {
1154
+ levels.sorted { $0.level < $1.level }.map { level in
1155
+ let text = level.text
1156
+ return [
1157
+ String(level.level),
1158
+ fingerprintPart(text.fontFamily),
1159
+ String(text.fontSize.bitPattern),
1160
+ fingerprintPart(text.color),
1161
+ fingerprintPart(text.strokeColor),
1162
+ String(text.strokeWidth.bitPattern),
1163
+ String(text.characterSpacing.bitPattern),
1164
+ String(text.lineSpacing.bitPattern),
1165
+ String(text.aspectRatio.bitPattern)
1166
+ ].joined(separator: "|")
1167
+ }.joined(separator: ";")
1168
+ }
1169
+
1170
+ func native(styleID: String) throws -> WaveTextStyle {
1171
+ try WaveTextStyle(styleID: styleID, styles: levels.sorted { $0.level < $1.level }.map {
1172
+ try PerLevelWaveTextStyle(textStyle: $0.text.native(), level: $0.level)
1173
+ })
1174
+ }
1175
+ }
1176
+
1177
+ private func fingerprintPart(_ value: String?) -> String {
1178
+ guard let value else { return "-" }
1179
+ return "\(value.utf8.count):\(value)"
1180
+ }
1181
+
1182
+ private extension MapLabelTextStyleRecord {
1183
+ func native() throws -> TextStyle {
1184
+ let nativeLineSpace = try lineSpacing.finiteFloat("lineSpacing")
1185
+ guard nativeLineSpace >= 1 else {
1186
+ throw labelError(labelInvalidArgument, "lineSpacing must be at least 1.")
1187
+ }
1188
+ return try TextStyle(
1189
+ fontSize: UInt(fontSize.positivePixelInteger("fontSize")),
1190
+ fontColor: UIColor(hex: color),
1191
+ strokeThickness: UInt(strokeWidth.nonnegativePixelInteger("strokeWidth")),
1192
+ strokeColor: UIColor(hex: strokeColor ?? "#00000000"),
1193
+ font: fontFamily ?? "",
1194
+ charSpace: characterSpacing.pixelInteger("characterSpacing"),
1195
+ lineSpace: nativeLineSpace,
1196
+ aspectRatio: aspectRatio.finiteFloat("aspectRatio")
1197
+ )
1198
+ }
1199
+ }
1200
+
1201
+ private extension MapLabelTransitionRecord {
1202
+ func native() throws -> PoiTransition {
1203
+ try PoiTransition(
1204
+ entrance: transition(entrance),
1205
+ exit: transition(exit)
1206
+ )
1207
+ }
1208
+ }
1209
+
1210
+ private extension MapLabelBadgeStyleRecord {
1211
+ func native(_ images: [String: UIImage]) throws -> PoiBadge {
1212
+ guard let native = images[image.uri] else { throw labelError(
1213
+ labelInvalidArgument,
1214
+ "Image was not loaded: \(image.uri)"
1215
+ ) }
1216
+ return try PoiBadge(
1217
+ badgeID: id,
1218
+ image: image.resized(native),
1219
+ offset: offset?.pixelPoint ?? .zero,
1220
+ zOrder: zOrder
1221
+ )
1222
+ }
1223
+ }
1224
+
1225
+ private extension MapLabelBadgeRecord {
1226
+ func native(_ images: [String: UIImage]) throws -> PoiBadge {
1227
+ guard let native = images[image.uri] else { throw labelError(
1228
+ labelInvalidArgument,
1229
+ "Image was not loaded: \(image.uri)"
1230
+ ) }
1231
+ return try PoiBadge(
1232
+ badgeID: id,
1233
+ image: image.resized(native),
1234
+ offset: offset?.pixelPoint ?? .zero,
1235
+ zOrder: zOrder
1236
+ )
1237
+ }
1238
+ }
1239
+
1240
+ private extension MapLabelLayerRecord {
1241
+ func options(layerID: String) throws -> LabelLayerOptions {
1242
+ if !competitionExceptions.isEmpty {
1243
+ throw labelError(
1244
+ labelUnsupported,
1245
+ "iOS Kakao Maps SDK does not expose layer competition exceptions."
1246
+ )
1247
+ }
1248
+ return try LabelLayerOptions(
1249
+ layerID: layerID,
1250
+ competitionType: competitionType(competition),
1251
+ competitionUnit: labelCompetitionUnit(competitionUnit),
1252
+ orderType: labelOrdering(ordering),
1253
+ zOrder: zOrder
1254
+ )
1255
+ }
1256
+ }
1257
+
1258
+ private extension MapLodLabelLayerRecord {
1259
+ func options(layerID: String) throws -> LodLabelLayerOptions {
1260
+ if !competitionExceptions.isEmpty {
1261
+ throw labelError(
1262
+ labelUnsupported,
1263
+ "iOS Kakao Maps SDK does not expose layer competition exceptions."
1264
+ )
1265
+ }
1266
+ return try LodLabelLayerOptions(
1267
+ layerID: layerID,
1268
+ competitionType: competitionType(competition),
1269
+ competitionUnit: labelCompetitionUnit(competitionUnit),
1270
+ orderType: labelOrdering(ordering),
1271
+ zOrder: zOrder,
1272
+ radius: radius.pixelFloat("radius")
1273
+ )
1274
+ }
1275
+ }
1276
+
1277
+ private extension MapLabelRecord {
1278
+ func options(styleID: String) throws -> PoiOptions {
1279
+ let option = PoiOptions(styleID: styleID, poiID: id)
1280
+ option.rank = try rank.integer("rank")
1281
+ option.clickable = clickable
1282
+ option.transformType = try transformType(transform)
1283
+ for line in text {
1284
+ try option.addText(PoiText(
1285
+ text: line.value,
1286
+ styleIndex: UInt(Double(line.styleIndex).nonnegativeInteger("styleIndex"))
1287
+ ))
1288
+ }
1289
+ return option
1290
+ }
1291
+ }
1292
+
1293
+ private extension MapLodLabelRecord {
1294
+ func options(styleID: String) throws -> PoiOptions {
1295
+ let option = PoiOptions(styleID: styleID, poiID: id)
1296
+ option.rank = try rank.integer("rank")
1297
+ option.clickable = clickable
1298
+ option.transformType = try transformType(transform)
1299
+ for line in text {
1300
+ try option.addText(PoiText(
1301
+ text: line.value,
1302
+ styleIndex: UInt(Double(line.styleIndex).nonnegativeInteger("styleIndex"))
1303
+ ))
1304
+ }
1305
+ return option
1306
+ }
1307
+ }
1308
+
1309
+ private extension MapLabelAnimationRecord {
1310
+ func effectObject() throws -> any PoiAnimationEffect {
1311
+ let effect: any PoiAnimationEffect
1312
+ switch self.effect {
1313
+ case "drop":
1314
+ let value = try DropAnimationEffect(pixelHeight: (keyframes.first?.translation?.y ?? 0)
1315
+ .pixelFloat("translation.y"))
1316
+ value.playCount = UInt(max(0, repeatCount))
1317
+ value.interpolation = try interpolation(
1318
+ keyframes.first?.interpolation ?? "linear",
1319
+ duration: keyframes.first?.duration ?? 0
1320
+ )
1321
+ effect = value
1322
+ case "alpha":
1323
+ let value = AlphaAnimationEffect()
1324
+ for frame in keyframes {
1325
+ try value.addKeyframe(AlphaAnimationKeyFrame(
1326
+ alpha: (frame.alpha ?? 1).unitFloat("alpha"),
1327
+ interpolation: interpolation(frame.interpolation, duration: frame.duration)
1328
+ ))
1329
+ }
1330
+ value.resetToInitialState = resetToInitialState
1331
+ effect = value
1332
+ case "scale":
1333
+ let value = ScaleAnimationEffect()
1334
+ for frame in keyframes {
1335
+ let scale = try frame.scale.required("scale")
1336
+ try value.addKeyframe(ScaleAnimationKeyFrame(
1337
+ scale: Vector2(x: scale.x.finiteDouble("scale.x"), y: scale.y.finiteDouble("scale.y")),
1338
+ interpolation: interpolation(frame.interpolation, duration: frame.duration)
1339
+ ))
1340
+ }
1341
+ value.resetToInitialState = resetToInitialState
1342
+ effect = value
1343
+ case "scaleAlpha":
1344
+ let value = ScaleAlphaAnimationEffect()
1345
+ for frame in keyframes {
1346
+ let scale = try frame.scale.required("scale")
1347
+ try value.addKeyframe(ScaleAlphaAnimationKeyFrame(
1348
+ scale: Vector2(x: scale.x.finiteDouble("scale.x"), y: scale.y.finiteDouble("scale.y")),
1349
+ alpha: (frame.alpha ?? 1).unitFloat("alpha"),
1350
+ interpolation: interpolation(frame.interpolation, duration: frame.duration)
1351
+ ))
1352
+ }
1353
+ value.resetToInitialState = resetToInitialState
1354
+ effect = value
1355
+ case "transform":
1356
+ let value = TransformAnimationEffect()
1357
+ for frame in keyframes {
1358
+ try value.addKeyframe(TransformAnimationKeyFrame(
1359
+ translation: Vector2(
1360
+ x: (frame.translation?.x ?? 0).pixelDouble("translation.x"),
1361
+ y: (frame.translation?.y ?? 0).pixelDouble("translation.y")
1362
+ ),
1363
+ rotation: ((frame.rotation ?? 0) * .pi / 180).finiteFloat("rotation"),
1364
+ scale: Vector2(
1365
+ x: (frame.scale?.x ?? 1).finiteDouble("scale.x"),
1366
+ y: (frame.scale?.y ?? 1).finiteDouble("scale.y")
1367
+ ),
1368
+ alpha: (frame.alpha ?? 1).unitFloat("alpha"),
1369
+ interpolation: interpolation(frame.interpolation, duration: frame.duration)
1370
+ ))
1371
+ }
1372
+ value.resetToInitialState = resetToInitialState
1373
+ effect = value
1374
+ default:
1375
+ throw labelError(labelInvalidArgument, "Unknown label animation effect: \(self.effect)")
1376
+ }
1377
+ effect.hideAtStop = hideAtStop
1378
+ effect.removeAtStop = removeAtStop
1379
+ return effect
1380
+ }
1381
+ }
1382
+
1383
+ private extension MapLabelPoint {
1384
+ var mapPoint: MapPoint {
1385
+ get throws {
1386
+ guard latitude.isFinite, longitude.isFinite, (-90 ... 90).contains(latitude),
1387
+ (-180 ... 180).contains(longitude)
1388
+ else {
1389
+ throw labelError(labelInvalidArgument, "Invalid coordinate.")
1390
+ }
1391
+ return MapPoint(longitude: longitude, latitude: latitude)
1392
+ }
1393
+ }
1394
+ }
1395
+
1396
+ private extension MapLabelVector {
1397
+ var point: CGPoint {
1398
+ get throws {
1399
+ _ = try x.finiteFloat("point.x")
1400
+ _ = try y.finiteFloat("point.y")
1401
+ return CGPoint(x: x, y: y)
1402
+ }
1403
+ }
1404
+
1405
+ var pixelPoint: CGPoint {
1406
+ get throws {
1407
+ try CGPoint(
1408
+ x: x.pixelDouble("point.x"),
1409
+ y: y.pixelDouble("point.y")
1410
+ )
1411
+ }
1412
+ }
1413
+
1414
+ func validateScale() throws {
1415
+ guard try x.finiteFloat("scale.x") >= 0, try y.finiteFloat("scale.y") >= 0 else {
1416
+ throw labelError(labelInvalidArgument, "Scale must be non-negative.")
1417
+ }
1418
+ }
1419
+ }
1420
+
1421
+ private extension MapLabelImage {
1422
+ func validate() throws {
1423
+ guard !uri.isEmpty else {
1424
+ throw labelError(labelInvalidArgument, "Image URI must not be empty.")
1425
+ }
1426
+ _ = try width?.positiveSize("image.width")
1427
+ _ = try height?.positiveSize("image.height")
1428
+ }
1429
+
1430
+ func resized(_ source: UIImage) throws -> UIImage {
1431
+ guard width != nil || height != nil else { return source }
1432
+ let requestedWidth = try width.map { try $0.positiveSize("image.width") }
1433
+ let requestedHeight = try height.map { try $0.positiveSize("image.height") }
1434
+ guard source.size.width > 0, source.size.height > 0 else {
1435
+ throw labelError(labelInvalidArgument, "The image has no dimensions: \(uri)")
1436
+ }
1437
+ let ratio = source.size.width / source.size.height
1438
+ let size = CGSize(
1439
+ width: requestedWidth ?? requestedHeight! * ratio,
1440
+ height: requestedHeight ?? requestedWidth! / ratio
1441
+ )
1442
+ return UIGraphicsImageRenderer(size: size, format: MapUnits.imageRendererFormat).image { _ in
1443
+ source.draw(in: CGRect(origin: .zero, size: size))
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ private func textLayout(_ value: String) throws -> PoiTextLayout {
1449
+ switch value {
1450
+ case "center": return .center
1451
+ case "top": return .top
1452
+ case "bottom": return .bottom
1453
+ case "left": return .left
1454
+ case "right": return .right
1455
+ default: throw labelError(labelInvalidArgument, "Unknown label text layout: \(value)")
1456
+ }
1457
+ }
1458
+
1459
+ private func transition(_ value: String) throws -> TransitionType {
1460
+ switch value {
1461
+ case "none": return .none; case "alpha": return .alpha; case "scale": return .scale; default: throw labelError(
1462
+ labelInvalidArgument,
1463
+ "Unknown transition: \(value)"
1464
+ )
1465
+ }
1466
+ }
1467
+
1468
+ private func competitionType(_ value: String) throws -> CompetitionType {
1469
+ switch value {
1470
+ case "none": return .none; case "all": return .all; case "upper": return .upper; case "upperLower": return .upperLower; case "upperSame": return .upperSame; case "same": return .same; case "sameLower": return .sameLower; case "lower": return .lower; default: throw labelError(
1471
+ labelInvalidArgument,
1472
+ "Unknown competition: \(value)"
1473
+ )
1474
+ }
1475
+ }
1476
+
1477
+ private func labelCompetitionUnit(_ value: String) throws -> CompetitionUnit {
1478
+ switch value {
1479
+ case "iconAndText": return .poi; case "iconFirst": return .symbolFirst; default: throw labelError(
1480
+ labelInvalidArgument,
1481
+ "Unknown competition unit: \(value)"
1482
+ )
1483
+ }
1484
+ }
1485
+
1486
+ private func labelOrdering(_ value: String) throws -> OrderingType {
1487
+ switch value {
1488
+ case "rank": return .rank; case "leftBottom": return .closerFromLeftBottom; default: throw labelError(
1489
+ labelInvalidArgument,
1490
+ "Unknown ordering: \(value)"
1491
+ )
1492
+ }
1493
+ }
1494
+
1495
+ private func transformType(_ value: String) throws -> PoiTransformType {
1496
+ switch value {
1497
+ case "absoluteRotation": return .absoluteRotation; case "default": return .default; case "absoluteRotationKeepUpright": return .keepUpright; case "absoluteRotationDecal": return .absoluteRotationDecal; case "decal": return .decal; case "none": throw labelError(
1498
+ labelUnsupported,
1499
+ "iOS has no 'none' POI transform."
1500
+ ); default: throw labelError(labelInvalidArgument, "Unknown transform: \(value)")
1501
+ }
1502
+ }
1503
+
1504
+ private func interpolation(_ value: String, duration: Double) throws -> AnimationInterpolation {
1505
+ let method: InterpolationMethodType
1506
+ switch value {
1507
+ case "linear": method = .linear; case "cubicIn": method = .cubicIn; case "cubicOut": method =
1508
+ .cubicOut; case "cubicInOut": method = .cubicInOut; default: throw labelError(
1509
+ labelInvalidArgument,
1510
+ "Unknown interpolation: \(value)"
1511
+ )
1512
+ }
1513
+ return try AnimationInterpolation(duration: UInt32(duration.duration()), method: method)
1514
+ }
1515
+
1516
+ private func unique(_ kind: String, _ values: [String]) throws {
1517
+ var seen = Set<String>()
1518
+ if let duplicate = values.first(where: { !seen.insert($0).inserted }) {
1519
+ throw labelError(
1520
+ labelInvalidArgument,
1521
+ "Duplicate \(kind) id: \(duplicate)"
1522
+ )
1523
+ }
1524
+ }
1525
+
1526
+ private func notFound(_ kind: String, _ id: String) -> KakaoMapException {
1527
+ labelError(
1528
+ labelNotFound,
1529
+ "\(kind) not found: \(id)"
1530
+ )
1531
+ }
1532
+
1533
+ private func labelError(_ code: String, _ message: String) -> KakaoMapException {
1534
+ KakaoMapException(
1535
+ code,
1536
+ message
1537
+ )
1538
+ }
1539
+
1540
+ private extension Optional {
1541
+ func required(_ name: String) throws -> Wrapped {
1542
+ guard let self
1543
+ else { throw labelError(
1544
+ labelInvalidArgument,
1545
+ "\(name) is required."
1546
+ ) }; return self
1547
+ }
1548
+ }
1549
+
1550
+ private extension MapLabelCommandRecord {
1551
+ func requiredID() throws -> String {
1552
+ try id.required("id")
1553
+ }
1554
+ }
1555
+
1556
+ private extension Double {
1557
+ func duration() throws -> Int {
1558
+ guard isFinite, self >= 0, self <= Double(UInt32.max),
1559
+ rounded() == self
1560
+ else { throw labelError(
1561
+ labelInvalidArgument,
1562
+ "Duration must be a non-negative 32-bit integer."
1563
+ ) }; return Int(self)
1564
+ }
1565
+
1566
+ func finiteFloat(_ name: String) throws -> Float {
1567
+ guard isFinite,
1568
+ self <=
1569
+ Double(Float.greatestFiniteMagnitude),
1570
+ self >=
1571
+ -Double(Float.greatestFiniteMagnitude)
1572
+ else { throw labelError(
1573
+ labelInvalidArgument,
1574
+ "\(name) must be finite."
1575
+ ) }; return Float(self)
1576
+ }
1577
+
1578
+ func finiteDouble(_ name: String) throws -> Double {
1579
+ guard isFinite else {
1580
+ throw labelError(labelInvalidArgument, "\(name) must be finite.")
1581
+ }
1582
+ return self
1583
+ }
1584
+
1585
+ func pixelDouble(_ name: String) throws -> Double {
1586
+ try MapUnits.pixels(finiteDouble(name)).finiteDouble(name)
1587
+ }
1588
+
1589
+ func pixelFloat(_ name: String) throws -> Float {
1590
+ try pixelDouble(name).finiteFloat(name)
1591
+ }
1592
+
1593
+ func unitFloat(_ name: String) throws -> Float {
1594
+ let value = try finiteFloat(name)
1595
+ guard (0 ... 1).contains(value) else {
1596
+ throw labelError(labelInvalidArgument, "\(name) must be between 0 and 1.")
1597
+ }
1598
+ return value
1599
+ }
1600
+
1601
+ func integer(_ name: String) throws -> Int {
1602
+ guard isFinite, rounded() == self,
1603
+ self <= Double(Int.max),
1604
+ self >= Double(Int.min)
1605
+ else { throw labelError(
1606
+ labelInvalidArgument,
1607
+ "\(name) must be an integer."
1608
+ ) }; return Int(self)
1609
+ }
1610
+
1611
+ func pixelInteger(_ name: String) throws -> Int {
1612
+ _ = try finiteDouble(name)
1613
+ return try MapUnits.pixels(self).rounded().integer(name)
1614
+ }
1615
+
1616
+ func nonnegativeInteger(_ name: String) throws
1617
+ -> Int
1618
+ {
1619
+ let value = try integer(name); guard value >= 0
1620
+ else { throw labelError(
1621
+ labelInvalidArgument,
1622
+ "\(name) must be non-negative."
1623
+ ) }; return value
1624
+ }
1625
+
1626
+ func nonnegativePixelInteger(_ name: String) throws -> Int {
1627
+ guard try finiteDouble(name) >= 0 else {
1628
+ throw labelError(labelInvalidArgument, "\(name) must be non-negative.")
1629
+ }
1630
+ return try MapUnits.pixels(self).rounded().nonnegativeInteger(name)
1631
+ }
1632
+
1633
+ func positiveInteger(_ name: String) throws -> Int {
1634
+ let value = try integer(name); guard value >
1635
+ 0
1636
+ else { throw labelError(
1637
+ labelInvalidArgument,
1638
+ "\(name) must be positive."
1639
+ ) }; return value
1640
+ }
1641
+
1642
+ func positivePixelInteger(_ name: String) throws -> Int {
1643
+ guard try finiteDouble(name) > 0 else {
1644
+ throw labelError(labelInvalidArgument, "\(name) must be positive.")
1645
+ }
1646
+ return try MapUnits.pixels(self).rounded().positiveInteger(name)
1647
+ }
1648
+
1649
+ func positiveSize(_ name: String) throws -> CGFloat {
1650
+ guard isFinite, self > 0, self <= Double(CGFloat.greatestFiniteMagnitude) else {
1651
+ throw labelError(labelInvalidArgument, "\(name) must be positive.")
1652
+ }
1653
+ return CGFloat(self)
1654
+ }
1655
+ }
1656
+
1657
+ private extension UIColor {
1658
+ convenience init(hex: String) throws {
1659
+ var value = hex
1660
+ if value.hasPrefix("#") {
1661
+ value.removeFirst()
1662
+ }
1663
+ guard value.count == 6 || value.count == 8,
1664
+ let number = UInt64(value, radix: 16)
1665
+ else { throw labelError(
1666
+ labelInvalidArgument,
1667
+ "Invalid color: \(hex)"
1668
+ ) }
1669
+ let hasAlpha = value.count == 8
1670
+ let red = CGFloat((number >> (hasAlpha ? 24 : 16)) & 0xFF) / 255
1671
+ let green = CGFloat((number >> (hasAlpha ? 16 : 8)) & 0xFF) / 255
1672
+ let blue = CGFloat((number >> (hasAlpha ? 8 : 0)) & 0xFF) / 255
1673
+ let alpha = hasAlpha ? CGFloat(number & 0xFF) / 255 : 1
1674
+ self.init(red: red, green: green, blue: blue, alpha: alpha)
1675
+ }
1676
+ }