diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-08-29 14:51:10 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-09-01 08:34:16 +0200 |
| commit | e02d460164fe09d5cf085cdcb3f40a9251bae633 (patch) | |
| tree | cbad368a9d30876f24631a6548e7d32e2e2c9524 /android/lib/map/src/main | |
| parent | 4f03db1de3224d666cf6fc5ab255f3629435a59a (diff) | |
| download | mullvadvpn-interactive-maps.tar.xz mullvadvpn-interactive-maps.zip | |
Add interactive mapsinteractive-maps
Diffstat (limited to 'android/lib/map/src/main')
9 files changed, 199 insertions, 28 deletions
diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt index 2e0b2fcf05..0963ec1aa1 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/CameraAnimation.kt @@ -88,16 +88,17 @@ fun animatedCameraPosition( return CameraPosition( zoom = baseZoom * zoomOutMultiplier.value, + verticalBias = cameraVerticalBias, latLong = LatLong( Latitude(latitudeAnimation.value), Longitude.fromFloat(longitudeAnimation.value), ), - verticalBias = cameraVerticalBias, + // verticalBias = cameraVerticalBias, ) } -private fun Float.toAnimationDurationMillis(): Int = +fun Float.toAnimationDurationMillis(): Int = (this * DISTANCE_DURATION_SCALE_FACTOR) .toInt() .coerceIn(MIN_ANIMATION_MILLIS, MAX_ANIMATION_MILLIS) diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt index 05b8d59701..76e84dca98 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/Map.kt @@ -1,19 +1,25 @@ package net.mullvad.mullvadvpn.lib.map +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import co.touchlab.kermit.Logger import net.mullvad.mullvadvpn.lib.map.data.CameraPosition import net.mullvad.mullvadvpn.lib.map.data.GlobeColors import net.mullvad.mullvadvpn.lib.map.data.MapViewState import net.mullvad.mullvadvpn.lib.map.data.Marker import net.mullvad.mullvadvpn.lib.map.internal.MapGLSurfaceView +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.LatLong +import net.mullvad.mullvadvpn.lib.model.RelayItemId @Composable fun Map( @@ -21,9 +27,11 @@ fun Map( cameraLocation: CameraPosition, markers: List<Marker>, globeColors: GlobeColors, + onClickRelayItemId: (GeoLocationId) -> Unit, + onLongClickRelayItemId: (Offset, GeoLocationId) -> Unit, ) { val mapViewState = MapViewState(cameraLocation, markers, globeColors) - Map(modifier = modifier, mapViewState = mapViewState) + Map(modifier = modifier, mapViewState = mapViewState, onClickRelayItemId, onLongClickRelayItemId) } @Composable @@ -34,6 +42,8 @@ fun AnimatedMap( cameraVerticalBias: Float, markers: List<Marker>, globeColors: GlobeColors, + onClickRelayItemId: (RelayItemId) -> Unit, + onLongClickRelayItemId: (Offset, RelayItemId) -> Unit ) { Map( modifier = modifier, @@ -45,12 +55,18 @@ fun AnimatedMap( ), markers = markers, globeColors, + onClickRelayItemId = onClickRelayItemId, + onLongClickRelayItemId = onLongClickRelayItemId, ) } @Composable -internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) { - +internal fun Map( + modifier: Modifier = Modifier, + mapViewState: MapViewState, + onClickRelayItemId: (GeoLocationId) -> Unit, + onLongClickRelayItemId: (Offset, GeoLocationId) -> Unit, +) { var view: MapGLSurfaceView? = remember { null } val lifeCycleState = LocalLifecycleOwner.current.lifecycle @@ -76,7 +92,25 @@ internal fun Map(modifier: Modifier = Modifier, mapViewState: MapViewState) { } } - AndroidView(modifier = modifier, factory = { MapGLSurfaceView(it) }) { glSurfaceView -> + AndroidView( + modifier = + Modifier.pointerInput(lifeCycleState) { + detectTapGestures( + onTap = { + Logger.i("Registered marker click: $it") + val result = view?.onMapClick(it) ?: return@detectTapGestures + onClickRelayItemId(result.first) + }, + onLongPress = { + Logger.i("Registered marker long click") + val result = view?.onMapClick(it) ?: return@detectTapGestures + + onLongClickRelayItemId(result.second, result.first) + }, + ) + }.then(modifier), + factory = { MapGLSurfaceView(it) }, + ) { glSurfaceView -> view = glSurfaceView glSurfaceView.setData(mapViewState) } diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt index b66b0ea657..e8ac13f793 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/CameraPosition.kt @@ -4,4 +4,13 @@ import androidx.compose.runtime.Immutable import net.mullvad.mullvadvpn.lib.model.LatLong @Immutable -data class CameraPosition(val latLong: LatLong, val zoom: Float, val verticalBias: Float) +data class CameraPosition( + val latLong: LatLong, + val zoom: Float, + val verticalBias: Float, + val fov: Float = DEFAULT_FIELD_OF_VIEW, +) { + companion object { + const val DEFAULT_FIELD_OF_VIEW = 70f + } +} diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt index 4e0959912b..b5fc439e6e 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/LocationMarkerColors.kt @@ -8,7 +8,7 @@ data class LocationMarkerColors( val centerColor: Color, val ringBorderColor: Color = Color.White, val shadowColor: Color = Color.Black.copy(alpha = DEFAULT_SHADOW_ALPHA), - val perimeterColors: Color = centerColor.copy(alpha = DEFAULT_PERIMETER_ALPHA), + val perimeterColors: Color? = centerColor.copy(alpha = DEFAULT_PERIMETER_ALPHA), ) { companion object { private const val DEFAULT_SHADOW_ALPHA = 0.55f diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt index a7f25ec545..aae3bb991e 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/data/Marker.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.lib.map.data +import android.os.Parcelable import androidx.compose.runtime.Immutable +import net.mullvad.mullvadvpn.lib.model.GeoLocationId import net.mullvad.mullvadvpn.lib.model.LatLong @Immutable @@ -8,6 +10,7 @@ data class Marker( val latLong: LatLong, val size: Float = DEFAULT_MARKER_SIZE, val colors: LocationMarkerColors, + val id: GeoLocationId? = null, ) { companion object { private const val DEFAULT_MARKER_SIZE = 0.02f diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt index a0aacc34c6..292abcf06a 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/Constants.kt @@ -12,9 +12,9 @@ internal const val MIN_ANIMATION_MILLIS = 1300 internal const val MAX_ANIMATION_MILLIS = 2500 // The cut off where we go from a short animation (camera pans) to a far animation (camera pans + // zoom out) -internal const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700 +const val SHORT_ANIMATION_CUTOFF_MILLIS = 1700 // Multiplier for the zoom out animation -internal const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.30f +const val FAR_ANIMATION_MAX_ZOOM_MULTIPLIER = 1.80f // When in the far animation we reach the MAX_ZOOM_MULTIPLIER, value is between 0 and 1 -internal const val MAX_MULTIPLIER_PEAK_TIMING = .35f +const val MAX_MULTIPLIER_PEAK_TIMING = .35f diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt index b767d894b7..40ffc7c61e 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLRenderer.kt @@ -5,19 +5,34 @@ import android.opengl.GLES20 import android.opengl.GLSurfaceView import android.opengl.Matrix import androidx.collection.LruCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import co.touchlab.kermit.Logger import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.opengles.GL10 +import kotlin.math.pow +import kotlin.math.sqrt import kotlin.math.tan import net.mullvad.mullvadvpn.lib.map.data.CameraPosition import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.map.data.Marker import net.mullvad.mullvadvpn.lib.map.internal.shapes.Globe import net.mullvad.mullvadvpn.lib.map.internal.shapes.LocationMarker +import net.mullvad.mullvadvpn.lib.model.map.Ray +import net.mullvad.mullvadvpn.lib.model.map.Sphere +import net.mullvad.mullvadvpn.lib.model.map.Vector3 +import net.mullvad.mullvadvpn.lib.model.map.rotateAroundX +import net.mullvad.mullvadvpn.lib.model.map.rotateAroundY +import net.mullvad.mullvadvpn.lib.model.map.toLatLng +import net.mullvad.mullvadvpn.lib.model.map.toVector3 import net.mullvad.mullvadvpn.lib.model.toRadians internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.Renderer { private lateinit var globe: Globe + private var viewPortSize: Size = Size(0f, 0f) + private val radius: Float = 1f // Due to location markers themselves containing colors we cache them to avoid recreating them // for every draw call. @@ -34,6 +49,8 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R } private lateinit var viewState: MapViewState + private val projectionMatrix = newIdentityMatrix() + private var globalViewMatrix = newIdentityMatrix() override fun onSurfaceCreated(unused: GL10, config: EGLConfig) { globe = Globe(resources) @@ -51,8 +68,6 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA) } - private val projectionMatrix = newIdentityMatrix() - override fun onDrawFrame(gl10: GL10) { // Clear canvas clear() @@ -67,6 +82,7 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.latitude.value, 1f, 0f, 0f) Matrix.rotateM(viewMatrix, 0, viewState.cameraPosition.latLong.longitude.value, 0f, -1f, 0f) + globalViewMatrix = viewMatrix.copyOf() globe.draw(projectionMatrix, viewMatrix, viewState.globeColors) // Draw location markers @@ -82,8 +98,9 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R private fun toOffsetY(cameraPosition: CameraPosition): Float { val percent = cameraPosition.verticalBias val z = cameraPosition.zoom - 1f + // Calculate the size of the plane at the current z position - val planeSizeY = tan(FIELD_OF_VIEW.toRadians() / 2f) * z * 2f + val planeSizeY = tan(cameraPosition.fov.toRadians() / 2f) * z * 2f // Calculate the start of the plane val planeStartY = planeSizeY / 2f @@ -105,6 +122,8 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R override fun onSurfaceChanged(unused: GL10, width: Int, height: Int) { GLES20.glViewport(0, 0, width, height) + viewPortSize = Size(width.toFloat(), height.toFloat()) + val ratio: Float = width.toFloat() / height.toFloat() if (ratio.isFinite()) { @@ -121,6 +140,82 @@ internal class MapGLRenderer(private val resources: Resources) : GLSurfaceView.R fun setViewState(viewState: MapViewState) { this.viewState = viewState + markerVector = viewState.locationMarker.map { it.latLong.toVector3() to it }.toMap() + } + + var markerVector = mapOf<Vector3, Marker>() + + fun closestMarker(offset: Offset): Pair<Marker?, Float>? { + val cameraz = -viewState.cameraPosition.zoom + val camerax = 0f + val cameray = toOffsetY(viewState.cameraPosition) + + val camera = Vector3(camerax, cameray, cameraz) + + val sphere = Sphere(Vector3(0f, 0f, 0f), 1f) + val ratio: Float = viewPortSize.width.toFloat() / viewPortSize.height.toFloat() + + val directionVector = + calculateDirectionVector( + viewState.cameraPosition.fov, + ratio, + viewPortSize.width, + viewPortSize.height, + offset.x, + offset.y, + nearPlaneDistance = PERSPECTIVE_Z_NEAR, + ) + + val ray = Ray(camera, directionVector) + + val oc = ray.origin - sphere.center // Vector from ray origin to sphere center + val a = ray.direction.dot(ray.direction) + val b = 2f * oc.dot(ray.direction) + val c = oc.dot(oc) - sphere.radius.pow(2f) + val discriminant = b.pow(2f) - 4f * a * c + + if (discriminant < 0f) { + return null // No intersection + } else { + val t = (-b - sqrt(discriminant)) / (2f * a) // Closest intersection point + val t2 = (-b + sqrt(discriminant)) / (2f * a) // Closest intersection point + Logger.d("Intersection t1: $t, t2: $t2") + val point2 = ray.origin + ray.direction * t2 + + val newPosition = + point2 + .rotateAroundX(-viewState.cameraPosition.latLong.latitude.value) + .rotateAroundY(viewState.cameraPosition.latLong.longitude.value) + + Logger.d("Intersection point2: $point2") + Logger.d("Intersection real vector: $newPosition") + Logger.d("Clicked lat lng: ${newPosition.toLatLng()}") + + val closestMarker = markerVector.minByOrNull { it.key.distanceTo(newPosition) } + + if (closestMarker != null) { + return closestMarker.value to closestMarker.key.distanceTo(newPosition) + } + + return null + } + } + + fun calculateDirectionVector( + fovy: Float, + aspectRatio: Float, + viewportWidth: Float, + viewportHeight: Float, + tapScreenX: Float, + tapScreenY: Float, + nearPlaneDistance: Float = 1.0f, + ): Vector3 { + val halfHeight = tan(fovy.toRadians() / 2.0f) * nearPlaneDistance + val halfWidth = halfHeight * aspectRatio + val x = (2.0f * tapScreenX / viewportWidth - 1.0f) * halfWidth + val y = (1.0f - 2.0f * tapScreenY / viewportHeight) * halfHeight + val z = -nearPlaneDistance + return Vector3(x, y, z).normalize() } companion object { diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt index 19dd085524..3607cca820 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/MapGLSurfaceView.kt @@ -2,8 +2,11 @@ package net.mullvad.mullvadvpn.lib.map.internal import android.content.Context import android.opengl.GLSurfaceView +import androidx.compose.ui.geometry.Offset import net.mullvad.mullvadvpn.lib.map.BuildConfig import net.mullvad.mullvadvpn.lib.map.data.MapViewState +import net.mullvad.mullvadvpn.lib.model.GeoLocationId +import net.mullvad.mullvadvpn.lib.model.RelayItemId internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) { @@ -28,4 +31,11 @@ internal class MapGLSurfaceView(context: Context) : GLSurfaceView(context) { renderer.setViewState(viewState) requestRender() } + + fun onMapClick(offset: Offset): Pair<GeoLocationId, Offset>? { + val (marker, distance) = renderer.closestMarker(offset) ?: return null + if (distance < 0.02f) { + return marker?.id?.let { it to offset } + } else return null + } } diff --git a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt index 26e69416b9..c12209273c 100644 --- a/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt +++ b/android/lib/map/src/main/kotlin/net/mullvad/mullvadvpn/lib/map/internal/shapes/LocationMarker.kt @@ -54,14 +54,17 @@ internal class LocationMarker(val colors: LocationMarkerColors) { val modelViewMatrix = viewMatrix.copyOf() GLES20.glUseProgram(shaderProgram) - Matrix.rotateM(modelViewMatrix, 0, latLong.longitude.value, 0f, 1f, 0f) Matrix.rotateM(modelViewMatrix, 0, latLong.latitude.value, -1f, 0f, 0f) Matrix.scaleM(modelViewMatrix, 0, size, size, 1f) // Translate marker to put it above the globe - Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR) + if (colors.perimeterColors != null) { + Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR + 0.0003f) + } else { + Matrix.translateM(modelViewMatrix, 0, 0f, 0f, MARKER_TRANSLATE_Z_FACTOR) + } GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, positionBuffer) GLES20.glVertexAttribPointer( @@ -148,37 +151,53 @@ internal class LocationMarker(val colors: LocationMarkerColors) { } @Suppress("MagicNumber") - private fun createRings(): List<Ring> = - listOf( - circleFanVertices( - 32, - 0.5f, - floatArrayOf(0.0f, 0.0f, 0.0f), - colors.perimeterColors, - colors.perimeterColors, - ), // Semi-transparent outer + private fun createRings(): List<Ring> = buildList { + colors.perimeterColors?.let { + // Semi-transparent outer + add( + circleFanVertices( + 32, + 0.5f, + floatArrayOf(0.0f, 0.0f, 0.0f), + colors.perimeterColors, + colors.perimeterColors, + ) + ) + } + + // Shadow + add( circleFanVertices( 16, 0.28f, floatArrayOf(0.0f, -0.05f, 0.00001f), colors.shadowColor, colors.shadowColor.copy(alpha = 0.0f), - ), // Shadow + ) + ) + + // White ring + add( circleFanVertices( 32, 0.185f, floatArrayOf(0.0f, 0.0f, 0.00002f), colors.ringBorderColor, colors.ringBorderColor, - ), // White ring + ) + ) + + // Center colored circle + add( circleFanVertices( 32, 0.15f, floatArrayOf(0.0f, 0.0f, 0.00003f), colors.centerColor, colors.centerColor, - ), // Center colored circle + ) ) + } fun onRemove() { GLES20.glDeleteBuffers(2, intArrayOf(positionBuffer, colorBuffer), 0) |
