Drag done

This commit is contained in:
Alexander Nozik 2022-08-31 22:48:52 +03:00
parent 5a1d3d701f
commit 491a4e6000
No known key found for this signature in database
GPG Key ID: F7FCF2DD25C71357
9 changed files with 243 additions and 102 deletions

View File

@ -7,7 +7,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PointMode
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import center.sciprog.maps.compose.*
@ -51,32 +52,24 @@ fun App() {
val pointOne = 55.568548 to 37.568604
var pointTwo: Pair<Double, Double> by remember { mutableStateOf(55.929444 to 37.518434) }
val pointTwo = 55.929444 to 37.518434
val pointThree = 60.929444 to 37.518434
val dragPoint = 55.744 to 37.614
MapView(
mapTileProvider = mapTileProvider,
initialViewPoint = viewPoint,
config = MapViewConfig(
inferViewBoxFromFeatures = true,
onViewChange = { centerCoordinates = focus },
dragHandle = { event, start, end ->
if (!event.buttons.isPrimaryPressed) {
true
} else if (start.focus.latitude.degrees.value in (pointTwo.first - 0.05)..(pointTwo.first + 0.05) &&
start.focus.longitude.degrees.value in (pointTwo.second - 0.05)..(pointTwo.second + 0.05)
) {
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).degrees.value to
pointTwo.second + (end.focus.longitude - start.focus.longitude).degrees.value
false// returning false, because when we are dragging circle we don't want to drag map
} else {
true
}
}
)
) {
image(pointOne, Icons.Filled.Home)
rectangle(dragPoint, id = "dragMe", size = DpSize(10.dp, 10.dp)).draggable()
points(
points = listOf(
55.742465 to 37.615812,
@ -89,7 +82,7 @@ fun App() {
pointMode = PointMode.Polygon
)
//remember feature Id
//remember feature ID
val circleId: FeatureId = circle(
centerCoordinates = pointTwo,
)

View File

@ -11,16 +11,20 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
import center.sciprog.maps.coordinates.GmcRectangle
import center.sciprog.maps.coordinates.wrapAll
import center.sciprog.maps.coordinates.*
import kotlin.math.floor
public interface MapFeature {
public val zoomRange: IntRange
public fun getBoundingBox(zoom: Int): GmcRectangle?
public fun getBoundingBox(zoom: Double): GmcRectangle?
}
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Int): GmcRectangle? =
public interface DraggableMapFeature : MapFeature {
public fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature
}
public fun Iterable<MapFeature>.computeBoundingBox(zoom: Double): GmcRectangle? =
mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
internal fun Pair<Double, Double>.toCoordinates() = GeodeticMapCoordinates.ofDegrees(first, second)
@ -35,18 +39,21 @@ public class MapFeatureSelector(
) : MapFeature {
override val zoomRange: IntRange get() = defaultZoomRange
override fun getBoundingBox(zoom: Int): GmcRectangle? = selector(zoom).getBoundingBox(zoom)
override fun getBoundingBox(zoom: Double): GmcRectangle? = selector(floor(zoom).toInt()).getBoundingBox(zoom)
}
public class MapDrawFeature(
public val position: GeodeticMapCoordinates,
override val zoomRange: IntRange = defaultZoomRange,
public val drawFeature: DrawScope.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle {
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
//TODO add box computation
return GmcRectangle(position, position)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapDrawFeature(newCoordinates, zoomRange, drawFeature)
}
public class MapPointsFeature(
@ -54,9 +61,9 @@ public class MapPointsFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val stroke: Float = 2f,
public val color: Color = Color.Red,
public val pointMode: PointMode = PointMode.Points
public val pointMode: PointMode = PointMode.Points,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle {
override fun getBoundingBox(zoom: Double): GmcRectangle {
return GmcRectangle(points.first(), points.last())
}
}
@ -66,8 +73,14 @@ public class MapCircleFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val size: Float = 5f,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(center, center)
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size/scale).radians, (size/scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapCircleFeature(newCoordinates, zoomRange, size, color)
}
public class MapRectangleFeature(
@ -75,8 +88,14 @@ public class MapRectangleFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val size: DpSize = DpSize(5.dp, 5.dp),
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(center, center)
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle {
val scale = WebMercatorProjection.scaleFactor(zoom)
return GmcRectangle.square(center, (size.height.value/scale).radians, (size.width.value/scale).radians)
}
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapRectangleFeature(newCoordinates, zoomRange, size, color)
}
public class MapLineFeature(
@ -85,7 +104,7 @@ public class MapLineFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(a, b)
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(a, b)
}
public class MapArcFeature(
@ -95,7 +114,7 @@ public class MapArcFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color = Color.Red,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = oval
override fun getBoundingBox(zoom: Double): GmcRectangle = oval
}
public class MapBitmapImageFeature(
@ -103,8 +122,11 @@ public class MapBitmapImageFeature(
public val image: ImageBitmap,
public val size: IntSize = IntSize(15, 15),
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(position, position)
) : DraggableMapFeature {
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapBitmapImageFeature(newCoordinates, image, size, zoomRange)
}
public class MapVectorImageFeature(
@ -112,8 +134,11 @@ public class MapVectorImageFeature(
public val painter: Painter,
public val size: DpSize,
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(position, position)
) : DraggableMapFeature{
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapVectorImageFeature(newCoordinates,painter, size, zoomRange)
}
@Composable
@ -131,7 +156,8 @@ public class MapFeatureGroup(
public val children: Map<FeatureId, MapFeature>,
override val zoomRange: IntRange = defaultZoomRange,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle? = children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
override fun getBoundingBox(zoom: Double): GmcRectangle? =
children.values.mapNotNull { it.getBoundingBox(zoom) }.wrapAll()
}
public class MapTextFeature(
@ -140,6 +166,9 @@ public class MapTextFeature(
override val zoomRange: IntRange = defaultZoomRange,
public val color: Color,
public val fontConfig: MapTextFeatureFont.() -> Unit,
) : MapFeature {
override fun getBoundingBox(zoom: Int): GmcRectangle = GmcRectangle(position, position)
) : DraggableMapFeature{
override fun getBoundingBox(zoom: Double): GmcRectangle = GmcRectangle(position, position)
override fun withCoordinates(newCoordinates: GeodeticMapCoordinates): MapFeature =
MapTextFeature(newCoordinates, text, zoomRange, color, fontConfig)
}

View File

@ -15,27 +15,53 @@ import center.sciprog.maps.coordinates.GmcRectangle
public typealias FeatureId = String
public interface MapFeatureAttributeKey<T>
public class MapFeatureAttributeSet(private val map: Map<MapFeatureAttributeKey<*>, *>) {
public operator fun <T> get(key: MapFeatureAttributeKey<*>): T? = map[key]?.let {
@Suppress("UNCHECKED_CAST")
it as T
}
}
public interface MapFeatureBuilder {
public fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId
public fun build(): SnapshotStateMap<FeatureId, MapFeature>
public fun <T> setAttribute(id: FeatureId, key: MapFeatureAttributeKey<T>, value: T)
public val features: MutableMap<FeatureId, MapFeature>
public fun attributes(): Map<FeatureId, MapFeatureAttributeSet>
//TODO use context receiver for that
public fun FeatureId.draggable(enabled: Boolean = true) {
setAttribute(this, DraggableAttribute, enabled)
}
}
internal class MapFeatureBuilderImpl(initialFeatures: Map<FeatureId, MapFeature>) : MapFeatureBuilder {
internal class MapFeatureBuilderImpl(
override val features: SnapshotStateMap<FeatureId, MapFeature>,
) : MapFeatureBuilder {
private val attributes = SnapshotStateMap<FeatureId, SnapshotStateMap<MapFeatureAttributeKey<out Any?>, in Any?>>()
private val content: SnapshotStateMap<FeatureId, MapFeature> = mutableStateMapOf<FeatureId, MapFeature>().apply {
putAll(initialFeatures)
}
private fun generateID(feature: MapFeature): FeatureId = "@feature[${feature.hashCode().toUInt()}]"
override fun addFeature(id: FeatureId?, feature: MapFeature): FeatureId {
val safeId = id ?: generateID(feature)
content[id ?: generateID(feature)] = feature
features[id ?: generateID(feature)] = feature
return safeId
}
override fun build(): SnapshotStateMap<FeatureId, MapFeature> = content
override fun <T> setAttribute(id: FeatureId, key: MapFeatureAttributeKey<T>, value: T) {
attributes.getOrPut(id) { SnapshotStateMap() }[key] = value
}
override fun attributes(): Map<FeatureId, MapFeatureAttributeSet> =
attributes.mapValues { MapFeatureAttributeSet(it.value) }
}
public fun MapFeatureBuilder.circle(
@ -123,7 +149,7 @@ public fun MapFeatureBuilder.points(
stroke: Float = 2f,
color: Color = Color.Red,
pointMode: PointMode = PointMode.Points,
id: FeatureId? = null
id: FeatureId? = null,
): FeatureId = addFeature(id, MapPointsFeature(points.map { it.toCoordinates() }, zoomRange, stroke, color, pointMode))
@Composable
@ -140,7 +166,7 @@ public fun MapFeatureBuilder.group(
id: FeatureId? = null,
builder: MapFeatureBuilder.() -> Unit,
): FeatureId {
val map = MapFeatureBuilderImpl(emptyMap()).apply(builder).build()
val map = MapFeatureBuilderImpl(mutableStateMapOf()).apply(builder).features
val feature = MapFeatureGroup(map, zoomRange)
return addFeature(id, feature)
}

View File

@ -1,9 +1,10 @@
package center.sciprog.maps.compose
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.unit.DpSize
import center.sciprog.maps.coordinates.*
import kotlin.math.PI
@ -19,10 +20,33 @@ public fun interface DragHandle {
*
* @return true if default event processors should be used after this one
*/
public fun drag(event: PointerEvent, start: MapViewPoint, end: MapViewPoint): Boolean
public fun handle(event: PointerEvent, start: MapViewPoint, end: MapViewPoint): Boolean
public companion object {
public val BYPASS: DragHandle = DragHandle { _, _, _ -> true }
/**
* Process only events with primary button pressed
*/
public fun withPrimaryButton(
block: (event: PointerEvent, start: MapViewPoint, end: MapViewPoint) -> Boolean,
): DragHandle = DragHandle { event, start, end ->
if (event.buttons.isPrimaryPressed) {
block(event, start, end)
} else {
false
}
}
/**
* Combine several handles into one
*/
public fun combine(vararg handles: DragHandle): DragHandle = DragHandle { event, start, end ->
handles.forEach {
if (!it.handle(event, start, end)) return@DragHandle false
}
return@DragHandle true
}
}
}
@ -49,53 +73,83 @@ public expect fun MapView(
modifier: Modifier = Modifier.fillMaxSize(),
)
private fun prepareConfig(initialConfig: MapViewConfig, featureBuilder: MapFeatureBuilder): MapViewConfig {
val draggableFeatureIds: Set<FeatureId> = featureBuilder.attributes().filterValues {
it[DraggableAttribute] ?: false
}.keys
val features = featureBuilder.features
val featureDrag = DragHandle.withPrimaryButton { _, start, end ->
val zoom = start.zoom
draggableFeatureIds.forEach { id ->
val feature = features[id] as? DraggableMapFeature ?: return@forEach
//val border = WebMercatorProjection.scaleFactor(zoom)
val boundingBox = feature.getBoundingBox(zoom) ?: return@forEach
if (start.focus in boundingBox) {
features[id] = feature.withCoordinates(end.focus)
return@withPrimaryButton false
}
}
return@withPrimaryButton true
}
return initialConfig.copy(
dragHandle = DragHandle.combine(featureDrag, initialConfig.dragHandle),
)
}
@Composable
public fun MapView(
mapTileProvider: MapTileProvider,
initialViewPoint: MapViewPoint,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
val featuresBuilder = MapFeatureBuilderImpl(mutableStateMapOf())
featuresBuilder.buildFeatures()
val features = remember { featuresBuilder.features }
val newConfig = remember(features) {
prepareConfig(config, featuresBuilder)
}
MapView(
mapTileProvider,
{ initialViewPoint },
featuresBuilder.build(),
config,
features,
newConfig,
modifier
)
}
internal fun GmcRectangle.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint =
{ canvasSize ->
val zoom = log2(
min(
canvasSize.width.value / longitudeDelta.radians.value,
canvasSize.height.value / latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize
)
MapViewPoint(center, zoom)
}
@Composable
public fun MapView(
internal fun GmcRectangle.computeViewPoint(
mapTileProvider: MapTileProvider,
box: GmcRectangle,
features: Map<FeatureId, MapFeature> = emptyMap(),
config: MapViewConfig = MapViewConfig(),
modifier: Modifier = Modifier.fillMaxSize(),
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
) {
val featuresBuilder = MapFeatureBuilderImpl(features)
featuresBuilder.buildFeatures()
MapView(
mapTileProvider,
box.computeViewPoint(mapTileProvider),
featuresBuilder.build(),
config,
modifier
): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
val zoom = log2(
min(
canvasSize.width.value / longitudeDelta.radians.value,
canvasSize.height.value / latitudeDelta.radians.value
) * PI / mapTileProvider.tileSize
)
MapViewPoint(center, zoom)
}
//
//@Composable
//public fun MapView(
// mapTileProvider: MapTileProvider,
// box: GmcRectangle,
// config: MapViewConfig = MapViewConfig(),
// modifier: Modifier = Modifier.fillMaxSize(),
// buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
//) {
// val builder by derivedStateOf { MapFeatureBuilderImpl().apply(buildFeatures) }
//
// MapView(
// mapTileProvider,
// box.computeViewPoint(mapTileProvider),
// builder.features,
// prepareConfig(config, builder),
// modifier
// )
//}

View File

@ -0,0 +1,3 @@
package center.sciprog.maps.compose
public object DraggableAttribute: MapFeatureAttributeKey<Boolean>

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
@ -66,7 +67,7 @@ public actual fun MapView(
val viewPoint: MapViewPoint by derivedStateOf {
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
features.values.computeBoundingBox(1)?.let { box ->
features.values.computeBoundingBox(1.0)?.let { box ->
val zoom = log2(
min(
canvasSize.width.value / box.longitudeDelta.radians.value,
@ -132,8 +133,9 @@ public actual fun MapView(
)
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
//apply drag handle and check if it prohibits the drag even propagation
if (
!config.dragHandle.drag(
!config.dragHandle.handle(
event,
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)

View File

@ -50,7 +50,7 @@ public class GeodeticMapCoordinates(
/**
* Short name for GeodeticMapCoordinates
*/
public typealias GMC = GeodeticMapCoordinates
public typealias Gmc = GeodeticMapCoordinates
//public interface GeoToScreenConversion {
// public fun getScreenX(gmc: GeodeticMapCoordinates): Double

View File

@ -57,8 +57,8 @@ public fun GeoEllipsoid.meridianCurve(
}
return GmcCurve(
forward = GmcPose(GMC(fromLatitude, longitude), if (up) zero else pi),
backward = GmcPose(GMC(toLatitude, longitude), if (up) pi else zero),
forward = GmcPose(Gmc(fromLatitude, longitude), if (up) zero else pi),
backward = GmcPose(Gmc(toLatitude, longitude), if (up) pi else zero),
distance = s
)
}
@ -70,8 +70,8 @@ public fun GeoEllipsoid.parallelCurve(latitude: Angle, fromLongitude: Angle, toL
require(latitude in (-piDiv2)..(piDiv2)) { "Latitude must be in (-90, 90) degrees range" }
val right = toLongitude > fromLongitude
return GmcCurve(
forward = GmcPose(GMC(latitude, fromLongitude), if (right) piDiv2.radians else -piDiv2.radians),
backward = GmcPose(GMC(latitude, toLongitude), if (right) -piDiv2.radians else piDiv2.radians),
forward = GmcPose(Gmc(latitude, fromLongitude), if (right) piDiv2.radians else -piDiv2.radians),
backward = GmcPose(Gmc(latitude, toLongitude), if (right) -piDiv2.radians else piDiv2.radians),
distance = reducedRadius(latitude) * abs((fromLongitude - toLongitude).radians.value)
)
}
@ -186,7 +186,7 @@ public fun GeoEllipsoid.curveInDirection(
val L = lambda - (1 - C) * f * sinAlpha *
(sigma.value + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2)))
val endPoint = GMC(phi2, L.radians)
val endPoint = Gmc(phi2, L.radians)
// eq. 12
@ -212,7 +212,7 @@ public fun GeoEllipsoid.curveInDirection(
* @return solution to the inverse geodetic problem
*/
@Suppress("SpellCheckingInspection", "LocalVariableName")
public fun GeoEllipsoid.curveBetween(start: GMC, end: GMC, precision: Double = 1e-6): GmcCurve {
public fun GeoEllipsoid.curveBetween(start: Gmc, end: Gmc, precision: Double = 1e-6): GmcCurve {
//
// All equation numbers refer back to Vincenty's publication:
// See http://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf

View File

@ -9,29 +9,39 @@ package center.sciprog.maps.coordinates
public data class GmcRectangle(
public val a: GeodeticMapCoordinates,
public val b: GeodeticMapCoordinates,
public val ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
) {
public companion object {
/**
* A quasi-square section.
*/
public fun square(
center: GeodeticMapCoordinates,
height: Angle,
width: Angle,
): GmcRectangle {
val a = GeodeticMapCoordinates(
center.latitude - (height / 2),
center.longitude - (width / 2)
)
val b = GeodeticMapCoordinates(
center.latitude + (height / 2),
center.longitude + (width / 2)
)
return GmcRectangle(a, b)
}
/**
* A quasi-square section. Note that latitudinal distance could be imprecise for large distances
*/
public fun square(
center: GeodeticMapCoordinates,
width: Distance,
height: Distance,
width: Distance,
ellipsoid: GeoEllipsoid = GeoEllipsoid.WGS84,
): GmcRectangle {
val reducedRadius = ellipsoid.reducedRadius(center.latitude)
val a = GeodeticMapCoordinates(
center.latitude - (height / ellipsoid.polarRadius / 2).radians,
center.longitude - (width / reducedRadius / 2).radians
)
val b = GeodeticMapCoordinates(
center.latitude + (height / ellipsoid.polarRadius / 2).radians,
center.longitude + (width / reducedRadius / 2).radians
)
return GmcRectangle(a, b, ellipsoid)
return square(center, (height / ellipsoid.polarRadius).radians, (width / reducedRadius).radians)
}
}
}
@ -68,6 +78,30 @@ public val GmcRectangle.latitudeDelta: Angle get() = abs(a.latitude - b.latitude
public val GmcRectangle.topLeft: GeodeticMapCoordinates get() = GeodeticMapCoordinates(top, left)
public val GmcRectangle.bottomRight: GeodeticMapCoordinates get() = GeodeticMapCoordinates(bottom, right)
//public fun GmcRectangle.enlarge(
// top: Distance,
// bottom: Distance = top,
// left: Distance = top,
// right: Distance = left,
//): GmcRectangle {
//
//}
//
//public fun GmcRectangle.enlarge(
// top: Angle,
// bottom: Angle = top,
// left: Angle = top,
// right: Angle = left,
//): GmcRectangle {
//
//}
/**
* Check if coordinate is inside the box
*/
public operator fun GmcRectangle.contains(coordinate: Gmc): Boolean =
coordinate.latitude in (bottom..top) && coordinate.longitude in (left..right)
/**
* Compute a minimal bounding box including all given boxes. Return null if collection is empty
*/