diff --git a/README.md b/README.md index 032a9d56..a19bb4c1 100644 --- a/README.md +++ b/README.md @@ -384,3 +384,17 @@ Feedback, issues, and contributions are welcome. ## License MapConductor Android SDK is released under the Apache License 2.0. + +## Bulk circles and raster tiles + +Inside map content, use `Circles(states)` from `com.mapconductor.compose.circle` to register a collection in one operation. Each collection removes only its own IDs. Use `CircleTiling(CircleTilingOptions.Disabled)` to keep native rendering, or `CircleTiling(CircleTilingOptions(minCircleCount = 5000))` to customize the threshold. Options are in `com.mapconductor.core.circle`. + +By default, raster tiling starts above 2,000 tileable circles. Large collections keep their drawing geometry in a disk-backed cache. Circle radius remains a ground distance in metres and stroke width remains a screen distance. Clicks and `onAdded` use the original states. `onAdded` indicates that the tile layer was handed to the map. + +Circles crossing the antimeridian, reaching Mercator's latitude limit, or having invalid radii stay native. Circle tiles stack above polygons and below polylines. + +## US antenna horizons example + +Choose **US Antenna Horizons** in the example sidebar. It bundles 723,800 OpenCellID records (MCC 310–314), with carrier filters and US and Alaska camera presets. Each estimated cell position is drawn as a 500 m geodesic circle. The radius is a display setting and does not model signal coverage. + +Cell tower data from [OpenCellID](https://opencellid.org) · [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). diff --git a/android-for-arcgis b/android-for-arcgis index 994a6fe9..7f49b2a9 160000 --- a/android-for-arcgis +++ b/android-for-arcgis @@ -1 +1 @@ -Subproject commit 994a6fe99a6ceb20f978c5d77e18f8e8ede785db +Subproject commit 7f49b2a9d8e12a2841f9dc2fce79c6bf209f8063 diff --git a/android-for-googlemaps b/android-for-googlemaps index 0613e2be..26ca973d 160000 --- a/android-for-googlemaps +++ b/android-for-googlemaps @@ -1 +1 @@ -Subproject commit 0613e2be25f476e8be291fd65a2fcc05d5715246 +Subproject commit 26ca973dc8ed035a96441e13efc4466e74602042 diff --git a/android-for-here b/android-for-here index 45c8b128..ce49fb2f 160000 --- a/android-for-here +++ b/android-for-here @@ -1 +1 @@ -Subproject commit 45c8b1289db15d4e09a5b9c7aacb29c23665830f +Subproject commit ce49fb2f28d610449fb6bc3b81604a964d44a12d diff --git a/android-for-longdo b/android-for-longdo index 2c722a80..bd155431 160000 --- a/android-for-longdo +++ b/android-for-longdo @@ -1 +1 @@ -Subproject commit 2c722a80f1d58175d240404636a837bdda333439 +Subproject commit bd155431f6bfb8967dcd6f7f7f29bbf6d8fe82fc diff --git a/android-for-mapbox b/android-for-mapbox index a5d21219..e60d0328 160000 --- a/android-for-mapbox +++ b/android-for-mapbox @@ -1 +1 @@ -Subproject commit a5d212192aff08f4909674e60345caece118c1cc +Subproject commit e60d0328988d737a3e5d8cde075f8491578f031e diff --git a/android-for-maplibre b/android-for-maplibre index 23b58380..301d9c94 160000 --- a/android-for-maplibre +++ b/android-for-maplibre @@ -1 +1 @@ -Subproject commit 23b58380defb95554e9ece8be45c437d6d14e0e5 +Subproject commit 301d9c947c59b4df2f114bad05e0d65b37697c6e diff --git a/android-for-mappls b/android-for-mappls index 87cddcc8..d31f26d3 160000 --- a/android-for-mappls +++ b/android-for-mappls @@ -1 +1 @@ -Subproject commit 87cddcc87b7cd613d3c235ea91fe853e507fba0e +Subproject commit d31f26d3132253b25f5834c122d4369bc2f8fe52 diff --git a/android-for-maptiler b/android-for-maptiler index ee131350..b94cd531 160000 --- a/android-for-maptiler +++ b/android-for-maptiler @@ -1 +1 @@ -Subproject commit ee1313505c93c56fea46be371a10c18d5c67a854 +Subproject commit b94cd5316ab699484d0ad61cd71ee12be463aa5a diff --git a/android-for-openmobilemaps b/android-for-openmobilemaps index f57f996e..2d80cbda 160000 --- a/android-for-openmobilemaps +++ b/android-for-openmobilemaps @@ -1 +1 @@ -Subproject commit f57f996e1dccd29dc1789356052a2127b75efecd +Subproject commit 2d80cbdae05707d6b959f4ff781b49f35f11aba3 diff --git a/android-for-tomtom b/android-for-tomtom index 69318061..6d84235c 160000 --- a/android-for-tomtom +++ b/android-for-tomtom @@ -1 +1 @@ -Subproject commit 69318061413eaa6d30e6b6a67dfd9b421976185c +Subproject commit 6d84235ceb4cc89bf7b047cf1772d7c9c8e7ffb0 diff --git a/android-sdk-compose b/android-sdk-compose index 60d3a460..1e77bbce 160000 --- a/android-sdk-compose +++ b/android-sdk-compose @@ -1 +1 @@ -Subproject commit 60d3a460bfd4e2f600aa1a6cd5ea1bd275e1cf19 +Subproject commit 1e77bbce1cf5aa355a0eff3874334771800fd662 diff --git a/android-sdk-core b/android-sdk-core index 9bff0dad..252f4825 160000 --- a/android-sdk-core +++ b/android-sdk-core @@ -1 +1 @@ -Subproject commit 9bff0dad0811d2156ddf1e9e3bc8f060a929a21b +Subproject commit 252f4825058c9ce25fcfc7b1ab2d2cc32298f74e diff --git a/example-app/src/androidTest/java/com/mapconductor/example/CircleFilteringStressTest.kt b/example-app/src/androidTest/java/com/mapconductor/example/CircleFilteringStressTest.kt new file mode 100644 index 00000000..ba7e1233 --- /dev/null +++ b/example-app/src/androidTest/java/com/mapconductor/example/CircleFilteringStressTest.kt @@ -0,0 +1,134 @@ +package com.mapconductor.example + +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getAllSemanticsNodes +import androidx.compose.ui.semantics.getOrNull +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapView +import org.maplibre.android.camera.CameraUpdateFactory +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import android.content.Intent +import android.os.SystemClock +import android.util.Log +import android.view.View +import android.view.ViewGroup + +/** Full nationwide data, native camera zooms, repeated carrier removal/restoration. */ +@RunWith(AndroidJUnit4::class) +class CircleFilteringStressTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + private fun onMain(block: () -> T): T { + var result: T? = null + instrumentation.runOnMainSync { result = block() } + return result!! + } + + private fun activity(): MainActivity = + ActivityLifecycleMonitorRegistry + .getInstance() + .getActivitiesInStage(Stage.RESUMED) + .filterIsInstance() + .single() + + // Read Compose semantics directly. This leaves the app's real frame clock + // running and avoids depending on the device accessibility service cache. + private fun nodes(view: View): Sequence = + sequence { + if (view is ViewRootForTest) yieldAll(view.semanticsOwner.getAllSemanticsNodes(false)) + if (view is ViewGroup) for (index in 0 until view.childCount) yieldAll(nodes(view.getChildAt(index))) + } + + private fun toggleVerizon() = + onMain { + val checkboxes = + nodes(activity().window.decorView) + .filter { it.config.getOrNull(SemanticsProperties.ToggleableState) != null } + .sortedBy { it.boundsInRoot.top } + .toList() + assertTrue(checkboxes.size >= 4) + assertTrue(checkboxes[2].config[SemanticsActions.OnClick].action!!.invoke()) + } + + private fun waitForCount(count: Int) { + val deadline = SystemClock.uptimeMillis() + 60_000 + var lastTexts = emptyList() + while (SystemClock.uptimeMillis() < deadline) { + lastTexts = + onMain { + nodes(activity().window.decorView) + .mapNotNull { + it.config.getOrNull(SemanticsProperties.Text)?.joinToString("") { text -> text.text } + }.toList() + } + if (lastTexts.any { it == "$count / 723800 circles" }) return + SystemClock.sleep(250) + } + throw AssertionError("Circle count did not reach $count; semantics texts: $lastTexts") + } + + private fun mapView(view: View): MapView? { + if (view is MapView) return view + if (view is ViewGroup) for (i in 0 until view.childCount) mapView(view.getChildAt(i))?.let { return it } + return null + } + + @Test + fun zoomAndToggleVerizonFiveTimes() { + val intent = + Intent(ApplicationProvider.getApplicationContext(), MainActivity::class.java).apply { + putExtra("page", "antenna-horizons") + putExtra("provider", "maplibre") + } + ActivityScenario.launch(intent).use { + waitForCount(723800) + SystemClock.sleep(5_000) + lateinit var map: MapLibreMap + val ready = CountDownLatch(1) + onMain { + val view = mapView(activity().window.decorView)!! + view.getMapAsync { + map = it + ready.countDown() + } + } + assertTrue(ready.await(10, TimeUnit.SECONDS)) + + fun zoom(): Double { + var result = 0.0 + instrumentation.runOnMainSync { result = map.cameraPosition.zoom } + return result + } + repeat(5) { + Log.i("CircleFilterStress", "Starting cycle ${it + 1}") + val before = zoom() + onMain { map.animateCamera(CameraUpdateFactory.zoomTo(before + 2), 300) } + SystemClock.sleep(1_000) + assertTrue("Native map must actually zoom in", zoom() > before + 0.5) + onMain { map.animateCamera(CameraUpdateFactory.zoomTo(before), 300) } + SystemClock.sleep(1_000) + assertTrue("Native map must actually zoom out", zoom() < before + 0.5) + toggleVerizon() + waitForCount(576636) + SystemClock.sleep(3_000) + toggleVerizon() + waitForCount(723800) + SystemClock.sleep(3_000) + Log.i("CircleFilterStress", "Completed cycle ${it + 1}") + } + } + } +} diff --git a/example-app/src/main/assets/antennas/README.md b/example-app/src/main/assets/antennas/README.md new file mode 100644 index 00000000..f194bdd7 --- /dev/null +++ b/example-app/src/main/assets/antennas/README.md @@ -0,0 +1,20 @@ +# OpenCellID cell positions (MCC 310–314) + +Cell tower data from [OpenCellID](https://opencellid.org) · [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) + +Derived from user-supplied 310.csv.gz through 314.csv.gz. All 723,800 rows are preserved as separate cells (not unique physical towers). Coordinates are rounded to five decimals and stored as signed E5 integers; range is float32. No token is shipped. + +Circles automatically use the shared ShapeTiler and RasterLayer infrastructure above 2,000 circles. No tiling parameter is passed by the example. + +Tiled circles share the tile source's states and ID lookup with the hit manager; they do not retain a CircleEntity or Fingerprint per cell. Native changes use revision numbers and attribute bit flags. Legacy fingerprint snapshots remain available for native-provider compatibility. Cheap circle projections are recomputed for requested tiles instead of retaining a full population at every zoom. + +Carrier counts and MCC counts are in us-antennas.json. Classification uses the MCC/MNC pair. +Assignment source: https://imsiadmin.com/assignments/hni/ (2026-11-10). Classification is by MCC/MNC assignment, not tower ownership. CDMA SID is not interpreted as MNC. Supplied files do not represent a complete physical tower inventory. + +Each cell is drawn as a fixed 500 m radius circle to indicate its estimated position, not signal coverage. The CSV range is retained in the asset but is not used as a display radius. Geodesic=true uses spherical distance. All cells for the selected carriers are rendered without sampling or a count limit; deselecting all carriers renders no circles. Carrier colors: blue, pink, red, gray. + +Binary: MCCE + u32 little-endian count, then 16-byte records: i32 latitude_e5, i32 longitude_e5, f32 range_m, u32 (carrier_index * 1000 + MNC). Stable row index identifies each cell. Carrier indices: AT&T=0, T-Mobile/Sprint=1, Verizon=2, Others=3. + +Reproduce with pack-opencellid.py 310.csv.gz 311.csv.gz 312.csv.gz 313.csv.gz 314.csv.gz --output OUTPUT.bin [--output OUTPUT.bin ...]. pack.py is the older FCC importer and is not used for this dataset. Modifications and binary data remain under CC BY-SA 4.0; retain the attribution above when redistributing. + +CircleState.extra holds only the source row ID. A shared click handler resolves details from the source table. Subpixel circles sharing a world pixel use the topmost visible circle as their display representative; their alpha no longer accumulates at that scale. Source states and hit testing remain intact, and zooming in restores every circle. diff --git a/example-app/src/main/assets/antennas/us-antennas.bin b/example-app/src/main/assets/antennas/us-antennas.bin new file mode 100644 index 00000000..8f2c2761 Binary files /dev/null and b/example-app/src/main/assets/antennas/us-antennas.bin differ diff --git a/example-app/src/main/assets/antennas/us-antennas.json b/example-app/src/main/assets/antennas/us-antennas.json new file mode 100644 index 00000000..6429bf12 --- /dev/null +++ b/example-app/src/main/assets/antennas/us-antennas.json @@ -0,0 +1,31 @@ +{ + "source": "https://opencellid.org", + "license": "https://creativecommons.org/licenses/by-sa/4.0/", + "attribution": "Cell tower data from OpenCellID \u00b7 CC BY-SA 4.0", + "inputs": [ + "310.csv.gz", + "311.csv.gz", + "312.csv.gz", + "313.csv.gz", + "314.csv.gz" + ], + "count": 723800, + "carrier_counts": [ + 199569, + 372599, + 147164, + 4468 + ], + "mcc_counts": { + "310": 567764, + "311": 150228, + "312": 199, + "313": 5349, + "314": 260 + }, + "file_sha256": "eda2bc13c14fe4d29ee94b4e6a92d6b8be14566ef04a95cb6aaf2954b456acec", + "bytes": 11580808, + "format": "MCCE + u32 count + records (i32 latitude_e5, i32 longitude_e5, f32 range_m, u32 carrier*1000+MNC), little endian", + "classification_source": "https://imsiadmin.com/assignments/hni/", + "coverage": "Supplied MCC 310-314 files; not a complete inventory of physical towers" +} diff --git a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt index 0c4463d9..c90de642 100644 --- a/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt +++ b/example-app/src/main/java/com/mapconductor/example/DemoAppScreen.kt @@ -47,6 +47,7 @@ import com.mapconductor.example.pages.polygon.geodesic.PolygonGeodesicPage import com.mapconductor.example.pages.polygon.hole.HolePolygonMapPage import com.mapconductor.example.pages.polyline.PolylineClickMapPage import com.mapconductor.example.pages.polyline.PolylineMapPage +import com.mapconductor.example.pages.circle.AntennaHorizonsPage import com.mapconductor.example.pages.polyline.boston.BostonRoadsPage import com.mapconductor.example.pages.rasterlayer.RasterHeaderProbePage import com.mapconductor.example.pages.rasterlayer.RasterLayerMapPage @@ -132,7 +133,7 @@ fun DemoAppScreen(initPage: String = "map") { ), SidebarSection( title = "Circle", - items = listOf(SidebarItem(id = "circle", title = "Circle")), + items = listOf(SidebarItem(id = "circle", title = "Circle"), SidebarItem(id = "antenna-horizons", title = "US Antenna Horizons")), ), SidebarSection( title = "Ground Image", @@ -342,6 +343,7 @@ fun DemoAppScreen(initPage: String = "map") { onToggleSidebar = navigationViewModel::toggleSidebar, ) } + "antenna-horizons" -> { AntennaHorizonsPage(onToggleSidebar = navigationViewModel::toggleSidebar) } "boston-roads" -> { BostonRoadsPage( onToggleSidebar = navigationViewModel::toggleSidebar, diff --git a/example-app/src/main/java/com/mapconductor/example/pages/circle/AntennaHorizonsPage.kt b/example-app/src/main/java/com/mapconductor/example/pages/circle/AntennaHorizonsPage.kt new file mode 100644 index 00000000..eddf9e7b --- /dev/null +++ b/example-app/src/main/java/com/mapconductor/example/pages/circle/AntennaHorizonsPage.kt @@ -0,0 +1,136 @@ +package com.mapconductor.example.pages.circle + +import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.ClickableText +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalUriHandler +import com.mapconductor.compose.circle.Circles +import com.mapconductor.compose.info.InfoBubble +import com.mapconductor.core.circle.CircleState +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.map.MapViewStateInterface +import com.mapconductor.example.MapViewContainer +import com.mapconductor.example.ui.DefaultMapViewItems +import com.mapconductor.example.ui.DemoMapPageScaffold +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private val carrierNames = listOf("AT&T", "T-Mobile / Sprint", "Verizon", "Others") +private val carrierColors = listOf(Color(0xFF2563EB), Color(0xFFDB2777), Color(0xFFDC2626), Color(0xFF64748B)) +private data class Antenna(val lat: Double, val lon: Double, val radius: Double, val registration: Long, val carrier: Int) + +// Keep source records packed. CircleState.extra resolves a row on demand; +// retaining another 723,800 JVM record objects would duplicate the source data. +private class AntennaTable(bytes: ByteArray) : AbstractList() { + private val buffer = ByteBuffer.wrap(bytes).asReadOnlyBuffer().order(ByteOrder.LITTLE_ENDIAN) + override val size: Int + val carrierCounts = IntArray(4) + init { + require(bytes.size >= 8 && String(bytes, 0, 4, Charsets.US_ASCII) == "MCCE") + size = buffer.getInt(4) + require(size >= 0 && bytes.size.toLong() == 8L + size * 16L) + for (index in 0 until size) { + val row = get(index) + require(row.lat.isFinite() && row.lat in -90.0..90.0 && row.lon.isFinite() && row.lon in -180.0..180.0 && row.radius > 0 && row.radius <= 100000 && row.carrier in 0..3) + carrierCounts[row.carrier]++ + } + } + override fun get(index: Int): Antenna { + require(index in 0 until size) + val p = 8 + index * 16 + return Antenna(buffer.getInt(p) / 100000.0, buffer.getInt(p + 4) / 100000.0, + buffer.getFloat(p + 8).toDouble(), index.toLong(), buffer.getInt(p + 12) / 1000) + } + fun carrierAt(index: Int): Int = buffer.getInt(8 + index * 16 + 12) / 1000 +} + +@Composable +fun AntennaHorizonsPage( + modifier: Modifier = Modifier, + onToggleSidebar: () -> Unit = {}, +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + var carriers by remember { mutableStateOf(setOf(0, 1, 2, 3)) } + var rows by remember { mutableStateOf>(emptyList()) } + var error by remember { mutableStateOf(null) } + var alaska by remember { mutableStateOf(false) } + var mapState by remember { mutableStateOf?>(null) } + var states by remember { mutableStateOf>(emptyList()) } + var allStates by remember { mutableStateOf>(emptyList()) } + var tap by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + try { + rows = withContext(Dispatchers.IO) { + val bytes = context.assets.open("antennas/us-antennas.bin").use { it.readBytes() } + AntennaTable(bytes) + } + } catch (e: Exception) { error = e.message } + } + LaunchedEffect(rows) { + tap = null + allStates = withContext(Dispatchers.Default) { + val onCellClick: com.mapconductor.core.circle.OnCircleEventHandler = { event -> + tap = (event.state.extra as? String)?.removePrefix("asr-")?.toIntOrNull()?.let { rows.getOrNull(it) } + } + rows.map { a -> + val color = carrierColors[a.carrier] + val id = "asr-${a.registration}" + CircleState(center = GeoPoint(a.lat, a.lon), radiusMeters = 500.0, id = id, geodesic = true, + zIndex = a.registration.toInt(), strokeWidth = 0.5.dp, strokeColor = color.copy(alpha = 0.35f), fillColor = color.copy(alpha = 0.65f), extra = id, onClick = onCellClick) + } + } + } + LaunchedEffect(allStates, carriers) { + tap = null + states = withContext(Dispatchers.Default) { + if (carriers.size == carrierNames.size) allStates + else if (carriers.isEmpty()) emptyList() + else allStates.filterIndexed { index, _ -> + ((rows as? AntennaTable)?.carrierAt(index) ?: rows[index].carrier) in carriers + } + } + } + val camera = remember(alaska) { MapCameraPosition(position = if (alaska) GeoPoint(64.0, -150.0) else GeoPoint(39.0, -98.0), zoom = if (alaska) 5.0 else 4.0) } + key(alaska) { + DemoMapPageScaffold(menuItems = DefaultMapViewItems(camera), onToggleSidebar = onToggleSidebar, onMapViewStateChanged = { mapState = it }) { padding -> + mapState?.let { state -> + MapViewContainer(state = state, modifier = modifier.padding(padding), onMapClick = { tap = null }) { + Circles(states) + tap?.let { a -> InfoBubble(position = GeoPoint(a.lat, a.lon)) { Text("${carrierNames[a.carrier]}\nCell position · Display radius: 500 m") } } + } + } + Column(Modifier.padding(top = padding.calculateTopPadding() + 12.dp, start = 12.dp).widthIn(max = 350.dp).background(MaterialTheme.colorScheme.surface).padding(12.dp)) { + Text("US cell positions (MCC 310–314)", style = MaterialTheme.typography.titleMedium) + Text(error ?: if (rows.isEmpty()) "Loading OpenCellID data…" else "${states.size} / ${rows.size} circles") + TextButton(onClick = { alaska = !alaska }) { Text(if (alaska) "US overview" else "Alaska") } + carrierNames.forEachIndexed { index, name -> + Row { Checkbox(index in carriers, { checked -> carriers = if (checked) carriers + index else carriers - index }); Text("$name (${(rows as? AntennaTable)?.carrierCounts?.get(index) ?: 0})") } + } + Text("Cell positions shown as 500 m radius circles; not signal coverage. Showing all cells for the selected carriers. MCC 310–314; not a complete tower inventory.", style = MaterialTheme.typography.bodySmall) + ClickableText(text = buildAnnotatedString { + append("Cell tower data from ") + pushStringAnnotation("URL", "https://opencellid.org"); append("OpenCellID"); pop() + append(" · ") + pushStringAnnotation("URL", "https://creativecommons.org/licenses/by-sa/4.0/"); append("CC BY-SA 4.0"); pop() + }, style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.primary), onClick = { offset -> + // Link annotations keep both source and license independently tappable. + val url = if (offset < 30) "https://opencellid.org" else "https://creativecommons.org/licenses/by-sa/4.0/" + uriHandler.openUri(url) + }) + } + } + } +}