State encapsulation #14
@ -2,3 +2,5 @@ import kotlin.math.PI
|
|||||||
|
|
||||||
fun Double.toDegrees() = this * 180 / PI
|
fun Double.toDegrees() = this * 180 / PI
|
||||||
|
|
||||||
|
fun Double.toRadians() = this * PI / 180
|
||||||
|
|
||||||
|
@ -10,9 +10,7 @@ import androidx.compose.ui.graphics.PointMode
|
|||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import center.sciprog.maps.compose.*
|
import center.sciprog.maps.compose.*
|
||||||
import center.sciprog.maps.coordinates.Distance
|
import center.sciprog.maps.coordinates.*
|
||||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
|
||||||
import center.sciprog.maps.coordinates.MapViewPoint
|
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.cio.CIO
|
import io.ktor.client.engine.cio.CIO
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@ -52,24 +50,11 @@ fun App() {
|
|||||||
val pointOne = 55.568548 to 37.568604
|
val pointOne = 55.568548 to 37.568604
|
||||||
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
|
var pointTwo by remember { mutableStateOf(55.929444 to 37.518434) }
|
||||||
val pointThree = 60.929444 to 37.518434
|
val pointThree = 60.929444 to 37.518434
|
||||||
MapView(
|
|
||||||
mapTileProvider = mapTileProvider,
|
|
||||||
initialViewPoint = viewPoint,
|
|
||||||
config = MapViewConfig(
|
|
||||||
inferViewBoxFromFeatures = true,
|
|
||||||
onViewChange = { centerCoordinates = focus },
|
|
||||||
onDrag = { start, end ->
|
|
||||||
if (start.focus.latitude.toDegrees() in (pointTwo.first - 0.05)..(pointTwo.first + 0.05) &&
|
|
||||||
start.focus.longitude.toDegrees() in (pointTwo.second - 0.05)..(pointTwo.second + 0.05)
|
|
||||||
) {
|
|
||||||
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to
|
|
||||||
pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees()
|
|
||||||
false// returning false, because when we are dragging circle we don't want to drag map
|
|
||||||
} else true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
|
|
||||||
|
val state = MapViewState(
|
||||||
|
mapTileProvider = mapTileProvider,
|
||||||
|
initialViewPoint = { viewPoint },
|
||||||
|
) {
|
||||||
image(pointOne, Icons.Filled.Home)
|
image(pointOne, Icons.Filled.Home)
|
||||||
|
|
||||||
points(
|
points(
|
||||||
@ -89,7 +74,19 @@ fun App() {
|
|||||||
centerCoordinates = pointTwo,
|
centerCoordinates = pointTwo,
|
||||||
)
|
)
|
||||||
|
|
||||||
draw(position = pointThree) {
|
draw(
|
||||||
|
position = pointThree,
|
||||||
|
getBoundingBox = {
|
||||||
|
GmcBox.withCenter(
|
||||||
|
center = GeodeticMapCoordinates.ofDegrees(
|
||||||
|
pointThree.first,
|
||||||
|
pointThree.second
|
||||||
|
),
|
||||||
|
height = Distance(0.001),
|
||||||
|
width = Distance(0.001)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
|
drawLine(start = Offset(-10f, -10f), end = Offset(10f, 10f), color = Color.Red)
|
||||||
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
|
drawLine(start = Offset(-10f, 10f), end = Offset(10f, -10f), color = Color.Red)
|
||||||
}
|
}
|
||||||
@ -118,6 +115,29 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val config = MapViewConfig(
|
||||||
|
onViewChange = { centerCoordinates = focus },
|
||||||
|
onDrag = { start, end ->
|
||||||
|
val markerRadius = 5f
|
||||||
|
val startPosition = with(state) { start.focus.toOffset(this@MapViewConfig) }
|
||||||
|
val markerLocation = with(state) {
|
||||||
|
GeodeticMapCoordinates.ofDegrees(pointTwo.first, pointTwo.second).toOffset(this@MapViewConfig)
|
||||||
|
}
|
||||||
|
if (startPosition.x in (markerLocation.x - markerRadius)..(markerLocation.x + markerRadius) &&
|
||||||
|
startPosition.y in (markerLocation.y - markerRadius)..(markerLocation.y + markerRadius)
|
||||||
|
) {
|
||||||
|
pointTwo = pointTwo.first + (end.focus.latitude - start.focus.latitude).toDegrees() to
|
||||||
|
pointTwo.second + (end.focus.longitude - start.focus.longitude).toDegrees()
|
||||||
|
false// returning false, because when we are dragging circle we don't want to drag map
|
||||||
|
} else true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
MapView(
|
||||||
|
mapViewState = state,
|
||||||
|
mapViewConfig = config
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,12 +41,10 @@ public class MapFeatureSelector(
|
|||||||
public class MapDrawFeature(
|
public class MapDrawFeature(
|
||||||
public val position: GeodeticMapCoordinates,
|
public val position: GeodeticMapCoordinates,
|
||||||
override val zoomRange: IntRange = defaultZoomRange,
|
override val zoomRange: IntRange = defaultZoomRange,
|
||||||
|
private val computeBoundingBox: (zoom: Int) -> GmcBox,
|
||||||
public val drawFeature: DrawScope.() -> Unit,
|
public val drawFeature: DrawScope.() -> Unit,
|
||||||
) : MapFeature {
|
) : MapFeature {
|
||||||
override fun getBoundingBox(zoom: Int): GmcBox {
|
override fun getBoundingBox(zoom: Int): GmcBox = computeBoundingBox(zoom)
|
||||||
//TODO add box computation
|
|
||||||
return GmcBox(position, position)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MapPointsFeature(
|
public class MapPointsFeature(
|
||||||
|
@ -72,8 +72,9 @@ public fun MapFeatureBuilder.draw(
|
|||||||
position: Pair<Double, Double>,
|
position: Pair<Double, Double>,
|
||||||
zoomRange: IntRange = defaultZoomRange,
|
zoomRange: IntRange = defaultZoomRange,
|
||||||
id: FeatureId? = null,
|
id: FeatureId? = null,
|
||||||
|
getBoundingBox: (Int) -> GmcBox,
|
||||||
drawFeature: DrawScope.() -> Unit,
|
drawFeature: DrawScope.() -> Unit,
|
||||||
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, drawFeature))
|
): FeatureId = addFeature(id, MapDrawFeature(position.toCoordinates(), zoomRange, getBoundingBox, drawFeature))
|
||||||
|
|
||||||
public fun MapFeatureBuilder.line(
|
public fun MapFeatureBuilder.line(
|
||||||
aCoordinates: Pair<Double, Double>,
|
aCoordinates: Pair<Double, Double>,
|
||||||
@ -144,3 +145,13 @@ public fun MapFeatureBuilder.group(
|
|||||||
val feature = MapFeatureGroup(map, zoomRange)
|
val feature = MapFeatureGroup(map, zoomRange)
|
||||||
return addFeature(id, feature)
|
return addFeature(id, feature)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fun MapFeatureBuilder.featureSelector(
|
||||||
|
id: FeatureId? = null,
|
||||||
|
onSelect: MapFeatureBuilder.(zoom: Int) -> MapFeature
|
||||||
|
): FeatureId = addFeature(
|
||||||
|
id = id,
|
||||||
|
feature = MapFeatureSelector(
|
||||||
|
selector = { onSelect(this, it) }
|
||||||
|
)
|
||||||
|
)
|
@ -1,10 +1,15 @@
|
|||||||
package center.sciprog.maps.compose
|
package center.sciprog.maps.compose
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.NativeCanvas
|
||||||
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
import center.sciprog.maps.coordinates.GeodeticMapCoordinates
|
||||||
import center.sciprog.maps.coordinates.GmcBox
|
import center.sciprog.maps.coordinates.GmcBox
|
||||||
import org.jetbrains.skia.Font
|
|
||||||
|
|
||||||
|
public expect class Font constructor() {
|
||||||
|
public var size: Float
|
||||||
|
}
|
||||||
|
|
||||||
|
public expect fun NativeCanvas.drawString(text: String, x: Float, y: Float, font: Font, color: Color)
|
||||||
|
|
||||||
public class MapTextFeature(
|
public class MapTextFeature(
|
||||||
public val position: GeodeticMapCoordinates,
|
public val position: GeodeticMapCoordinates,
|
@ -3,6 +3,7 @@ package center.sciprog.maps.compose
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.DpSize
|
import androidx.compose.ui.unit.DpSize
|
||||||
import center.sciprog.maps.coordinates.*
|
import center.sciprog.maps.coordinates.*
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
@ -17,9 +18,8 @@ import kotlin.math.min
|
|||||||
*/
|
*/
|
||||||
public data class MapViewConfig(
|
public data class MapViewConfig(
|
||||||
val zoomSpeed: Double = 1.0 / 3.0,
|
val zoomSpeed: Double = 1.0 / 3.0,
|
||||||
val inferViewBoxFromFeatures: Boolean = false,
|
|
||||||
val onClick: MapViewPoint.() -> Unit = {},
|
val onClick: MapViewPoint.() -> Unit = {},
|
||||||
val onDrag: (start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
|
val onDrag: Density.(start: MapViewPoint, end: MapViewPoint) -> Boolean = { _, _ -> true },
|
||||||
val onViewChange: MapViewPoint.() -> Unit = {},
|
val onViewChange: MapViewPoint.() -> Unit = {},
|
||||||
val onSelect: (GmcBox) -> Unit = {},
|
val onSelect: (GmcBox) -> Unit = {},
|
||||||
val zoomOnSelect: Boolean = true,
|
val zoomOnSelect: Boolean = true,
|
||||||
@ -28,11 +28,9 @@ public data class MapViewConfig(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
public expect fun MapView(
|
public expect fun MapView(
|
||||||
mapTileProvider: MapTileProvider,
|
modifier: Modifier = Modifier,
|
||||||
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
mapViewState: MapViewState,
|
||||||
features: Map<FeatureId, MapFeature>,
|
mapViewConfig: MapViewConfig,
|
||||||
config: MapViewConfig = MapViewConfig(),
|
|
||||||
modifier: Modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -47,23 +45,26 @@ public fun MapView(
|
|||||||
val featuresBuilder = MapFeatureBuilderImpl(features)
|
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||||
featuresBuilder.buildFeatures()
|
featuresBuilder.buildFeatures()
|
||||||
MapView(
|
MapView(
|
||||||
mapTileProvider,
|
mapViewState = MapViewState(
|
||||||
{ initialViewPoint },
|
mapTileProvider = mapTileProvider,
|
||||||
featuresBuilder.build(),
|
initialViewPoint = { initialViewPoint },
|
||||||
config,
|
features = featuresBuilder.build(),
|
||||||
modifier
|
),
|
||||||
|
mapViewConfig = config,
|
||||||
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint = { canvasSize ->
|
internal fun GmcBox.computeViewPoint(mapTileProvider: MapTileProvider): (canvasSize: DpSize) -> MapViewPoint =
|
||||||
val zoom = log2(
|
{ canvasSize ->
|
||||||
min(
|
val zoom = log2(
|
||||||
canvasSize.width.value / width,
|
min(
|
||||||
canvasSize.height.value / height
|
canvasSize.width.value / width,
|
||||||
) * PI / mapTileProvider.tileSize
|
canvasSize.height.value / height
|
||||||
)
|
) * PI / mapTileProvider.tileSize
|
||||||
MapViewPoint(center, zoom)
|
)
|
||||||
}
|
MapViewPoint(center, zoom)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
public fun MapView(
|
public fun MapView(
|
||||||
@ -77,10 +78,12 @@ public fun MapView(
|
|||||||
val featuresBuilder = MapFeatureBuilderImpl(features)
|
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||||
featuresBuilder.buildFeatures()
|
featuresBuilder.buildFeatures()
|
||||||
MapView(
|
MapView(
|
||||||
mapTileProvider,
|
mapViewState = MapViewState(
|
||||||
box.computeViewPoint(mapTileProvider),
|
mapTileProvider = mapTileProvider,
|
||||||
featuresBuilder.build(),
|
features = featuresBuilder.build(),
|
||||||
config,
|
initialViewPoint = box.computeViewPoint(mapTileProvider),
|
||||||
modifier
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
mapViewConfig = config,
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -0,0 +1,181 @@
|
|||||||
|
package center.sciprog.maps.compose
|
||||||
|
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import center.sciprog.maps.coordinates.*
|
||||||
|
import mu.KotlinLogging
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
public fun MapViewState(
|
||||||
|
initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||||
|
mapTileProvider: MapTileProvider,
|
||||||
|
features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||||
|
inferViewBoxFromFeatures: Boolean = false,
|
||||||
|
buildFeatures: @Composable (MapFeatureBuilder.() -> Unit) = {},
|
||||||
|
): MapViewState {
|
||||||
|
val featuresBuilder = MapFeatureBuilderImpl(features)
|
||||||
|
featuresBuilder.buildFeatures()
|
||||||
|
return MapViewState(
|
||||||
|
initialViewPoint = initialViewPoint,
|
||||||
|
mapTileProvider = mapTileProvider,
|
||||||
|
features = featuresBuilder.build(),
|
||||||
|
inferViewBoxFromFeatures = inferViewBoxFromFeatures
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MapViewState(
|
||||||
|
public val initialViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
||||||
|
public val mapTileProvider: MapTileProvider,
|
||||||
|
public val features: Map<FeatureId, MapFeature> = emptyMap(),
|
||||||
|
inferViewBoxFromFeatures: Boolean = false,
|
||||||
|
) {
|
||||||
|
public var canvasSize: DpSize by mutableStateOf(DpSize(512.dp, 512.dp))
|
||||||
|
public var viewPointInternal: MapViewPoint? by mutableStateOf(null)
|
||||||
|
public val viewPoint: MapViewPoint by derivedStateOf {
|
||||||
|
viewPointInternal ?: if (inferViewBoxFromFeatures) {
|
||||||
|
features.values.computeBoundingBox(1)?.let { box ->
|
||||||
|
val zoom = log2(
|
||||||
|
min(
|
||||||
|
canvasSize.width.value / box.width,
|
||||||
|
canvasSize.height.value / box.height
|
||||||
|
) * PI / mapTileProvider.tileSize
|
||||||
|
)
|
||||||
|
MapViewPoint(box.center, zoom)
|
||||||
|
} ?: initialViewPoint(canvasSize)
|
||||||
|
} else {
|
||||||
|
initialViewPoint(canvasSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
||||||
|
|
||||||
|
public val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
|
||||||
|
|
||||||
|
public val mapTiles: SnapshotStateList<MapTile> = mutableStateListOf()
|
||||||
|
|
||||||
|
public val centerCoordinates: WebMercatorCoordinates by derivedStateOf {
|
||||||
|
WebMercatorProjection.toMercator(
|
||||||
|
viewPoint.focus,
|
||||||
|
zoom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
||||||
|
zoom,
|
||||||
|
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
|
||||||
|
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Convert screen independent offset to GMC, adjusting for fractional zoom
|
||||||
|
*/
|
||||||
|
public fun DpOffset.toGeodetic(): GeodeticMapCoordinates =
|
||||||
|
with(this@MapViewState) { WebMercatorProjection.toGeodetic(toMercator()) }
|
||||||
|
|
||||||
|
// Selection rectangle. If null - no selection
|
||||||
|
public var selectRect: Rect? by mutableStateOf(null)
|
||||||
|
|
||||||
|
public fun WebMercatorCoordinates.toOffset(density: Density): Offset =
|
||||||
|
with(density) {
|
||||||
|
with(this@MapViewState) {
|
||||||
|
Offset(
|
||||||
|
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
|
||||||
|
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Convert GMC to offset in pixels (not DP), adjusting for zoom
|
||||||
|
public fun GeodeticMapCoordinates.toOffset(density: Density): Offset =
|
||||||
|
WebMercatorProjection.toMercator(this, zoom).toOffset(density)
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger("MapViewState")
|
||||||
|
|
||||||
|
public fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
|
||||||
|
when (feature) {
|
||||||
|
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
|
||||||
|
is MapCircleFeature -> drawCircle(
|
||||||
|
feature.color,
|
||||||
|
feature.size,
|
||||||
|
center = feature.center.toOffset(this@drawFeature)
|
||||||
|
)
|
||||||
|
is MapRectangleFeature -> drawRect(
|
||||||
|
feature.color,
|
||||||
|
topLeft = feature.center.toOffset(this@drawFeature) - Offset(
|
||||||
|
feature.size.width.toPx() / 2,
|
||||||
|
feature.size.height.toPx() / 2
|
||||||
|
),
|
||||||
|
size = feature.size.toSize()
|
||||||
|
)
|
||||||
|
is MapLineFeature -> drawLine(
|
||||||
|
feature.color,
|
||||||
|
feature.a.toOffset(this@drawFeature),
|
||||||
|
feature.b.toOffset(this@drawFeature)
|
||||||
|
)
|
||||||
|
is MapArcFeature -> {
|
||||||
|
val topLeft = feature.oval.topLeft.toOffset(this@drawFeature)
|
||||||
|
val bottomRight = feature.oval.bottomRight.toOffset(this@drawFeature)
|
||||||
|
|
||||||
|
val path = Path().apply {
|
||||||
|
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPath(path, color = feature.color, style = Stroke())
|
||||||
|
|
||||||
|
}
|
||||||
|
is MapBitmapImageFeature -> drawImage(
|
||||||
|
image = feature.image,
|
||||||
|
topLeft = feature.position.toOffset(this@drawFeature)
|
||||||
|
)
|
||||||
|
is MapVectorImageFeature -> {
|
||||||
|
val offset = feature.position.toOffset(this@drawFeature)
|
||||||
|
val size = feature.size.toSize()
|
||||||
|
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
|
||||||
|
with(feature.painter) {
|
||||||
|
draw(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MapTextFeature -> drawIntoCanvas { canvas ->
|
||||||
|
val offset = feature.position.toOffset(this@drawFeature)
|
||||||
|
canvas.nativeCanvas.drawString(
|
||||||
|
feature.text,
|
||||||
|
offset.x + 5,
|
||||||
|
offset.y - 5,
|
||||||
|
Font().apply(feature.fontConfig),
|
||||||
|
feature.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MapDrawFeature -> {
|
||||||
|
val offset = feature.position.toOffset(this)
|
||||||
|
translate(offset.x, offset.y) {
|
||||||
|
feature.drawFeature(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MapFeatureGroup -> {
|
||||||
|
feature.children.values.forEach {
|
||||||
|
drawFeature(zoom, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
logger.error { "Unrecognized feature type: ${feature::class}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
|||||||
|
package center.sciprog.maps.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.NativeCanvas
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import org.jetbrains.skia.Paint
|
||||||
|
|
||||||
|
public actual typealias Font = org.jetbrains.skia.Font
|
||||||
|
|
||||||
|
public actual fun NativeCanvas.drawString(
|
||||||
|
text: String,
|
||||||
|
x: Float,
|
||||||
|
y: Float,
|
||||||
|
font: Font,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
drawString(text, x, y, font, color.toPaint())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.toPaint(): Paint = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = toArgb()
|
||||||
|
}
|
@ -17,15 +17,10 @@ import center.sciprog.maps.coordinates.*
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.jetbrains.skia.Font
|
|
||||||
import org.jetbrains.skia.Paint
|
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
|
|
||||||
|
|
||||||
private fun Color.toPaint(): Paint = Paint().apply {
|
|
||||||
isAntiAlias = true
|
|
||||||
color = toArgb()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
private fun IntRange.intersect(other: IntRange) = max(first, other.first)..min(last, other.last)
|
||||||
|
|
||||||
@ -48,292 +43,171 @@ private val logger = KotlinLogging.logger("MapView")
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
public actual fun MapView(
|
public actual fun MapView(
|
||||||
mapTileProvider: MapTileProvider,
|
|
||||||
computeViewPoint: (canvasSize: DpSize) -> MapViewPoint,
|
|
||||||
features: Map<FeatureId, MapFeature>,
|
|
||||||
config: MapViewConfig,
|
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
|
mapViewState: MapViewState,
|
||||||
|
mapViewConfig: MapViewConfig,
|
||||||
) {
|
) {
|
||||||
var canvasSize by remember { mutableStateOf(DpSize(512.dp, 512.dp)) }
|
with(mapViewState) {
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
val canvasModifier = modifier.pointerInput(Unit) {
|
||||||
|
forEachGesture {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
|
||||||
|
|
||||||
var viewPointInternal: MapViewPoint? by remember {
|
val event: PointerEvent = awaitPointerEvent()
|
||||||
mutableStateOf(null)
|
event.changes.forEach { change ->
|
||||||
}
|
if (event.buttons.isPrimaryPressed) {
|
||||||
|
//Evaluating selection frame
|
||||||
if (config.resetViewPoint) {
|
if (event.keyboardModifiers.isShiftPressed) {
|
||||||
viewPointInternal = null
|
selectRect = Rect(change.position, change.position)
|
||||||
}
|
drag(change.id) { dragChange ->
|
||||||
|
selectRect?.let { rect ->
|
||||||
val viewPoint: MapViewPoint by derivedStateOf {
|
val offset = dragChange.position
|
||||||
viewPointInternal ?: if (config.inferViewBoxFromFeatures) {
|
selectRect = Rect(
|
||||||
features.values.computeBoundingBox(1)?.let { box ->
|
min(offset.x, rect.left),
|
||||||
val zoom = log2(
|
min(offset.y, rect.top),
|
||||||
min(
|
max(offset.x, rect.right),
|
||||||
canvasSize.width.value / box.width,
|
max(offset.y, rect.bottom)
|
||||||
canvasSize.height.value / box.height
|
)
|
||||||
) * PI / mapTileProvider.tileSize
|
}
|
||||||
)
|
|
||||||
MapViewPoint(box.center, zoom)
|
|
||||||
} ?: computeViewPoint(canvasSize)
|
|
||||||
} else {
|
|
||||||
computeViewPoint(canvasSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val zoom: Int by derivedStateOf { floor(viewPoint.zoom).toInt() }
|
|
||||||
|
|
||||||
val tileScale: Double by derivedStateOf { 2.0.pow(viewPoint.zoom - zoom) }
|
|
||||||
|
|
||||||
val mapTiles = remember { mutableStateListOf<MapTile>() }
|
|
||||||
|
|
||||||
val centerCoordinates by derivedStateOf { WebMercatorProjection.toMercator(viewPoint.focus, zoom) }
|
|
||||||
|
|
||||||
fun DpOffset.toMercator(): WebMercatorCoordinates = WebMercatorCoordinates(
|
|
||||||
zoom,
|
|
||||||
(x - canvasSize.width / 2).value / tileScale + centerCoordinates.x,
|
|
||||||
(y - canvasSize.height / 2).value / tileScale + centerCoordinates.y,
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Convert screen independent offset to GMC, adjusting for fractional zoom
|
|
||||||
*/
|
|
||||||
fun DpOffset.toGeodetic() = WebMercatorProjection.toGeodetic(toMercator())
|
|
||||||
|
|
||||||
// Selection rectangle. If null - no selection
|
|
||||||
var selectRect by remember { mutableStateOf<Rect?>(null) }
|
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
|
||||||
val canvasModifier = modifier.pointerInput(Unit) {
|
|
||||||
forEachGesture {
|
|
||||||
awaitPointerEventScope {
|
|
||||||
fun Offset.toDpOffset() = DpOffset(x.toDp(), y.toDp())
|
|
||||||
|
|
||||||
val event: PointerEvent = awaitPointerEvent()
|
|
||||||
event.changes.forEach { change ->
|
|
||||||
if (event.buttons.isPrimaryPressed) {
|
|
||||||
//Evaluating selection frame
|
|
||||||
if (event.keyboardModifiers.isShiftPressed) {
|
|
||||||
selectRect = Rect(change.position, change.position)
|
|
||||||
drag(change.id) { dragChange ->
|
|
||||||
selectRect?.let { rect ->
|
|
||||||
val offset = dragChange.position
|
|
||||||
selectRect = Rect(
|
|
||||||
min(offset.x, rect.left),
|
|
||||||
min(offset.y, rect.top),
|
|
||||||
max(offset.x, rect.right),
|
|
||||||
max(offset.y, rect.bottom)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
selectRect?.let { rect ->
|
||||||
selectRect?.let { rect ->
|
//Use selection override if it is defined
|
||||||
//Use selection override if it is defined
|
val gmcBox = GmcBox(
|
||||||
val gmcBox = GmcBox(
|
rect.topLeft.toDpOffset().toGeodetic(),
|
||||||
rect.topLeft.toDpOffset().toGeodetic(),
|
rect.bottomRight.toDpOffset().toGeodetic()
|
||||||
rect.bottomRight.toDpOffset().toGeodetic()
|
)
|
||||||
)
|
|
||||||
config.onSelect(gmcBox)
|
|
||||||
if (config.zoomOnSelect) {
|
|
||||||
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
|
|
||||||
|
|
||||||
config.onViewChange(newViewPoint)
|
mapViewConfig.onSelect(gmcBox)
|
||||||
|
if (mapViewConfig.zoomOnSelect) {
|
||||||
|
val newViewPoint = gmcBox.computeViewPoint(mapTileProvider).invoke(canvasSize)
|
||||||
|
|
||||||
|
mapViewConfig.onViewChange(newViewPoint)
|
||||||
|
viewPointInternal = newViewPoint
|
||||||
|
}
|
||||||
|
selectRect = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val dragStart = change.position
|
||||||
|
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
||||||
|
mapViewConfig.onClick(
|
||||||
|
MapViewPoint(
|
||||||
|
dpPos.toGeodetic() ,
|
||||||
|
viewPoint.zoom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
drag(change.id) { dragChange ->
|
||||||
|
val dragAmount = dragChange.position - dragChange.previousPosition
|
||||||
|
val dpStart =
|
||||||
|
DpOffset(
|
||||||
|
dragChange.previousPosition.x.toDp(),
|
||||||
|
dragChange.previousPosition.y.toDp()
|
||||||
|
)
|
||||||
|
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
|
||||||
|
if (!mapViewConfig.onDrag(
|
||||||
|
this,
|
||||||
|
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
|
||||||
|
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
|
||||||
|
)
|
||||||
|
) return@drag
|
||||||
|
val newViewPoint = viewPoint.move(
|
||||||
|
-dragAmount.x.toDp().value / tileScale,
|
||||||
|
+dragAmount.y.toDp().value / tileScale
|
||||||
|
)
|
||||||
|
mapViewConfig.onViewChange(newViewPoint)
|
||||||
viewPointInternal = newViewPoint
|
viewPointInternal = newViewPoint
|
||||||
}
|
}
|
||||||
selectRect = null
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
val dragStart = change.position
|
}
|
||||||
val dpPos = DpOffset(dragStart.x.toDp(), dragStart.y.toDp())
|
}
|
||||||
config.onClick(MapViewPoint(dpPos.toGeodetic(), viewPoint.zoom))
|
}
|
||||||
drag(change.id) { dragChange ->
|
}.onPointerEvent(PointerEventType.Scroll) {
|
||||||
val dragAmount = dragChange.position - dragChange.previousPosition
|
val change = it.changes.first()
|
||||||
val dpStart =
|
val (xPos, yPos) = change.position
|
||||||
DpOffset(dragChange.previousPosition.x.toDp(), dragChange.previousPosition.y.toDp())
|
//compute invariant point of translation
|
||||||
val dpEnd = DpOffset(dragChange.position.x.toDp(), dragChange.position.y.toDp())
|
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
||||||
if (!config.onDrag(
|
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * mapViewConfig.zoomSpeed, invariant)
|
||||||
MapViewPoint(dpStart.toGeodetic(), viewPoint.zoom),
|
mapViewConfig.onViewChange(newViewPoint)
|
||||||
MapViewPoint(dpEnd.toGeodetic(), viewPoint.zoom)
|
viewPointInternal = newViewPoint
|
||||||
)
|
}.fillMaxSize()
|
||||||
) return@drag
|
|
||||||
val newViewPoint = viewPoint.move(
|
|
||||||
-dragAmount.x.toDp().value / tileScale,
|
// Load tiles asynchronously
|
||||||
+dragAmount.y.toDp().value / tileScale
|
LaunchedEffect(viewPoint, canvasSize) {
|
||||||
)
|
with(mapTileProvider) {
|
||||||
config.onViewChange(newViewPoint)
|
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
||||||
viewPointInternal = newViewPoint
|
|
||||||
|
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
|
||||||
|
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
|
||||||
|
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
|
||||||
|
|
||||||
|
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
|
||||||
|
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
|
||||||
|
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
|
||||||
|
|
||||||
|
mapTiles.clear()
|
||||||
|
|
||||||
|
for (j in verticalIndices) {
|
||||||
|
for (i in horizontalIndices) {
|
||||||
|
val id = TileId(zoom, i, j)
|
||||||
|
//start all
|
||||||
|
val deferred = loadTileAsync(id)
|
||||||
|
//wait asynchronously for it to finish
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
mapTiles += deferred.await()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
if (ex !is CancellationException) {
|
||||||
|
//displaying the error is maps responsibility
|
||||||
|
logger.error(ex) { "Failed to load tile with id=$id" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onPointerEvent(PointerEventType.Scroll) {
|
|
||||||
val change = it.changes.first()
|
|
||||||
val (xPos, yPos) = change.position
|
|
||||||
//compute invariant point of translation
|
|
||||||
val invariant = DpOffset(xPos.toDp(), yPos.toDp()).toGeodetic()
|
|
||||||
val newViewPoint = viewPoint.zoom(-change.scrollDelta.y.toDouble() * config.zoomSpeed, invariant)
|
|
||||||
config.onViewChange(newViewPoint)
|
|
||||||
viewPointInternal = newViewPoint
|
|
||||||
}.fillMaxSize()
|
|
||||||
|
|
||||||
|
|
||||||
// Load tiles asynchronously
|
Canvas(canvasModifier) {
|
||||||
LaunchedEffect(viewPoint, canvasSize) {
|
if (mapViewState.canvasSize != size.toDpSize()) {
|
||||||
with(mapTileProvider) {
|
mapViewState.canvasSize = size.toDpSize()
|
||||||
val indexRange = 0 until 2.0.pow(zoom).toInt()
|
logger.debug { "Recalculate canvas. Size: $size" }
|
||||||
|
|
||||||
val left = centerCoordinates.x - canvasSize.width.value / 2 / tileScale
|
|
||||||
val right = centerCoordinates.x + canvasSize.width.value / 2 / tileScale
|
|
||||||
val horizontalIndices: IntRange = (toIndex(left)..toIndex(right)).intersect(indexRange)
|
|
||||||
|
|
||||||
val top = (centerCoordinates.y + canvasSize.height.value / 2 / tileScale)
|
|
||||||
val bottom = (centerCoordinates.y - canvasSize.height.value / 2 / tileScale)
|
|
||||||
val verticalIndices: IntRange = (toIndex(bottom)..toIndex(top)).intersect(indexRange)
|
|
||||||
|
|
||||||
mapTiles.clear()
|
|
||||||
|
|
||||||
for (j in verticalIndices) {
|
|
||||||
for (i in horizontalIndices) {
|
|
||||||
val id = TileId(zoom, i, j)
|
|
||||||
//start all
|
|
||||||
val deferred = loadTileAsync(id)
|
|
||||||
//wait asynchronously for it to finish
|
|
||||||
launch {
|
|
||||||
try {
|
|
||||||
mapTiles += deferred.await()
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
if (ex !is CancellationException) {
|
|
||||||
//displaying the error is maps responsibility
|
|
||||||
logger.error(ex) { "Failed to load tile with id=$id" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
clipRect {
|
||||||
}
|
val tileSize = IntSize(
|
||||||
|
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
|
||||||
|
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
|
||||||
Canvas(canvasModifier) {
|
|
||||||
fun WebMercatorCoordinates.toOffset(): Offset = Offset(
|
|
||||||
(canvasSize.width / 2 + (x.dp - centerCoordinates.x.dp) * tileScale.toFloat()).toPx(),
|
|
||||||
(canvasSize.height / 2 + (y.dp - centerCoordinates.y.dp) * tileScale.toFloat()).toPx()
|
|
||||||
)
|
|
||||||
|
|
||||||
//Convert GMC to offset in pixels (not DP), adjusting for zoom
|
|
||||||
fun GeodeticMapCoordinates.toOffset(): Offset = WebMercatorProjection.toMercator(this, zoom).toOffset()
|
|
||||||
|
|
||||||
|
|
||||||
fun DrawScope.drawFeature(zoom: Int, feature: MapFeature) {
|
|
||||||
when (feature) {
|
|
||||||
is MapFeatureSelector -> drawFeature(zoom, feature.selector(zoom))
|
|
||||||
is MapCircleFeature -> drawCircle(
|
|
||||||
feature.color,
|
|
||||||
feature.size,
|
|
||||||
center = feature.center.toOffset()
|
|
||||||
)
|
)
|
||||||
is MapRectangleFeature -> drawRect(
|
mapTiles.forEach { (id, image) ->
|
||||||
feature.color,
|
//converting back from tile index to screen offset
|
||||||
topLeft = feature.center.toOffset() - Offset(
|
val offset = IntOffset(
|
||||||
feature.size.width.toPx() / 2,
|
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
|
||||||
feature.size.height.toPx() / 2
|
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
|
||||||
),
|
)
|
||||||
size = feature.size.toSize()
|
drawImage(
|
||||||
)
|
image = image,
|
||||||
is MapLineFeature -> drawLine(feature.color, feature.a.toOffset(), feature.b.toOffset())
|
dstOffset = offset,
|
||||||
is MapArcFeature -> {
|
dstSize = tileSize
|
||||||
val topLeft = feature.oval.topLeft.toOffset()
|
|
||||||
val bottomRight = feature.oval.bottomRight.toOffset()
|
|
||||||
|
|
||||||
val path = Path().apply {
|
|
||||||
addArcRad(Rect(topLeft, bottomRight), feature.startAngle, feature.endAngle - feature.startAngle)
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPath(path, color = feature.color, style = Stroke())
|
|
||||||
|
|
||||||
}
|
|
||||||
is MapBitmapImageFeature -> drawImage(feature.image, feature.position.toOffset())
|
|
||||||
is MapVectorImageFeature -> {
|
|
||||||
val offset = feature.position.toOffset()
|
|
||||||
val size = feature.size.toSize()
|
|
||||||
translate(offset.x - size.width / 2, offset.y - size.height / 2) {
|
|
||||||
with(feature.painter) {
|
|
||||||
draw(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MapTextFeature -> drawIntoCanvas { canvas ->
|
|
||||||
val offset = feature.position.toOffset()
|
|
||||||
canvas.nativeCanvas.drawString(
|
|
||||||
feature.text,
|
|
||||||
offset.x + 5,
|
|
||||||
offset.y - 5,
|
|
||||||
Font().apply(feature.fontConfig),
|
|
||||||
feature.color.toPaint()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MapDrawFeature -> {
|
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
|
||||||
val offset = feature.position.toOffset()
|
drawFeature(zoom, feature)
|
||||||
translate(offset.x, offset.y) {
|
|
||||||
feature.drawFeature(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
is MapFeatureGroup -> {
|
}
|
||||||
feature.children.values.forEach {
|
selectRect?.let { rect ->
|
||||||
drawFeature(zoom, it)
|
drawRect(
|
||||||
}
|
color = Color.Blue,
|
||||||
}
|
topLeft = rect.topLeft,
|
||||||
is MapPointsFeature -> {
|
size = rect.size,
|
||||||
val points = feature.points.map { it.toOffset() }
|
alpha = 0.5f,
|
||||||
drawPoints(
|
style = Stroke(
|
||||||
points = points,
|
width = 2f,
|
||||||
color = feature.color,
|
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||||
strokeWidth = feature.stroke,
|
|
||||||
pointMode = feature.pointMode
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
logger.error { "Unrecognized feature type: ${feature::class}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canvasSize != size.toDpSize()) {
|
|
||||||
canvasSize = size.toDpSize()
|
|
||||||
logger.debug { "Recalculate canvas. Size: $size" }
|
|
||||||
}
|
|
||||||
clipRect {
|
|
||||||
val tileSize = IntSize(
|
|
||||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt(),
|
|
||||||
ceil((mapTileProvider.tileSize.dp * tileScale.toFloat()).toPx()).toInt()
|
|
||||||
)
|
|
||||||
mapTiles.forEach { (id, image) ->
|
|
||||||
//converting back from tile index to screen offset
|
|
||||||
val offset = IntOffset(
|
|
||||||
(canvasSize.width / 2 + (mapTileProvider.toCoordinate(id.i).dp - centerCoordinates.x.dp) * tileScale.toFloat()).roundToPx(),
|
|
||||||
(canvasSize.height / 2 + (mapTileProvider.toCoordinate(id.j).dp - centerCoordinates.y.dp) * tileScale.toFloat()).roundToPx()
|
|
||||||
)
|
|
||||||
drawImage(
|
|
||||||
image = image,
|
|
||||||
dstOffset = offset,
|
|
||||||
dstSize = tileSize
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
features.values.filter { zoom in it.zoomRange }.forEach { feature ->
|
|
||||||
drawFeature(zoom, feature)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selectRect?.let { rect ->
|
|
||||||
drawRect(
|
|
||||||
color = Color.Blue,
|
|
||||||
topLeft = rect.topLeft,
|
|
||||||
size = rect.size,
|
|
||||||
alpha = 0.5f,
|
|
||||||
style = Stroke(
|
|
||||||
width = 2f,
|
|
||||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user