Skip to content

Commit

Permalink
Clip offline imagery to selected areas
Browse files Browse the repository at this point in the history
  • Loading branch information
gino-m committed Sep 10, 2023
1 parent 7db3ec3 commit bba7ed9
Show file tree
Hide file tree
Showing 11 changed files with 122 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,11 @@ import androidx.room.Query
import com.google.android.ground.persistence.local.room.entity.OfflineAreaEntity
import io.reactivex.Flowable
import io.reactivex.Maybe
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.reactive.asFlow

/** Provides read/write operations for writing [OfflineAreaEntity] to the local db. */
@Dao
interface OfflineAreaDao : BaseDao<OfflineAreaEntity> {
@Query("SELECT * FROM offline_area")
fun findAllOnceAndStream(): Flowable<List<OfflineAreaEntity>>
@Query("SELECT * FROM offline_area") fun findAllOnceAndStream(): Flowable<List<OfflineAreaEntity>>

@Query("SELECT * FROM offline_area WHERE id = :id")
fun findById(id: String): Maybe<OfflineAreaEntity>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ import com.google.android.ground.rx.Schedulers
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.Single
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.reactive.asFlow
import javax.inject.Inject
import javax.inject.Singleton
import timber.log.Timber
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import com.google.android.ground.rx.annotations.Cold
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.Single
import kotlinx.coroutines.flow.Flow

interface LocalOfflineAreaStore {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.reactive.asFlow
import timber.log.Timber

/**
* Corners of the viewport are scaled by this value when determining the name of downloaded areas.
Expand Down Expand Up @@ -101,7 +100,6 @@ constructor(

fun getOfflineTileSources(): Flow<List<TileSource>> =
surveyRepository.activeSurveyFlow
.onEach { Timber.e("!!! Survey ${it?.id}") }
// TODO(#1593): Room's equivalent Flow never emits a value, perhaps due to using incorrect
// scheduler?
.combine(getOfflineAreaBounds().asFlow()) { survey, bounds ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,19 @@ import com.google.android.gms.maps.model.Tile
import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.TileProvider.NO_TILE
import com.google.android.ground.ui.map.gms.mog.TileCoordinates
import timber.log.Timber
import com.google.android.ground.ui.map.gms.mog.toPixelBounds
import com.google.android.ground.ui.map.gms.mog.toPixelCoordinate
import java.io.ByteArrayOutputStream

private const val MAX_ZOOM = 19

class ClippingTileProvider(
private val sourceTileProvider: TileProvider,
private val clipBounds: List<LatLngBounds>
clipBounds: List<LatLngBounds>
) : TileProvider {

private val pixelBounds = clipBounds.map { it.toPixelBounds(MAX_ZOOM) }

override fun getTile(x: Int, y: Int, zoom: Int): Tile {
val sourceTile = sourceTileProvider.getTile(x, y, zoom) ?: NO_TILE
if (sourceTile == NO_TILE) return sourceTile
Expand All @@ -45,11 +51,10 @@ class ClippingTileProvider(
opts.inMutable = true
val bitmap = BitmapFactory.decodeByteArray(tile.data, 0, tile.data!!.size, opts)
bitmap.setHasAlpha(true)
Timber.e("!!! $tileCoords, " + tileCoords.getLatLngAtPixelOffset(0, 0)+ ", $clipBounds")
for (y in 0 until bitmap.height) {
for (x in 0 until bitmap.width) {
val pixelCoords = tileCoords.getLatLngAtPixelOffset(x, y)
if (clipBounds.none { it.contains(pixelCoords) }) {
val pixelCoords = tileCoords.toPixelCoordinate(x, y)
if (pixelBounds.none { it.contains(pixelCoords) }) {
bitmap.setPixel(x, y, Color.TRANSPARENT)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.ground.ui.map.gms.mog

import kotlin.math.abs
import kotlin.math.cos

/**
* Returns the value shifted left [n] bits when [n] is positive, or right [-n] bits when negative.]
*/
fun Int.shiftLeft(n: Int) = if (n >= 0) this shl n else this shr abs(n)

/** Returns the secant of angle `x` given in radians. */
fun sec(x: Double) = 1 / cos(x)

/** Converts degrees into radians. */
fun Double.toRadians() = this * (Math.PI / 180)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.ground.ui.map.gms.mog

import com.google.android.gms.maps.model.LatLngBounds

data class PixelBounds(val min: PixelCoordinates, val max: PixelCoordinates) {
fun contains(pixelCoordinates: PixelCoordinates): Boolean {
// Do pixel math at precision of point in highest resolution plane.
val (minX, minY, _) = min.atZoom(pixelCoordinates.zoom)
val (maxX, maxY, _) = max.atZoom(pixelCoordinates.zoom)
val (x, y, _) = pixelCoordinates
return x in minX..maxX && y in minY..maxY
}
}

fun LatLngBounds.toPixelBounds(zoom: Int) =
PixelBounds(northwest().toPixelCoordinates(zoom), southeast().toPixelCoordinates(zoom))
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.ground.ui.map.gms.mog

import com.google.android.gms.maps.model.LatLng
import kotlin.math.ln
import kotlin.math.tan

data class PixelCoordinates(val x: Int, val y: Int, val zoom: Int) {
fun atZoom(targetZoom: Int): PixelCoordinates {
val delta = targetZoom - zoom
return PixelCoordinates(x.shiftLeft(delta), y.shiftLeft(delta), targetZoom)
}
}

fun LatLng.toPixelCoordinates(zoom: Int): PixelCoordinates {
val zoomFactor = 1 shl zoom
val latRad = this.latitude.toRadians()
val x = zoomFactor * (this.longitude + 180) / 360
val y = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / Math.PI)) / 2
return PixelCoordinates((x * 256.0).toInt(), (y * 256.0).toInt(), zoom)
}

fun TileCoordinates.toPixelCoordinate(xOffset: Int, yOffset: Int): PixelCoordinates {
return PixelCoordinates(x * 256 + xOffset, y * 256 + yOffset, zoom)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,7 @@ package com.google.android.ground.ui.map.gms.mog

import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import java.lang.Math.PI
import java.lang.Math.toDegrees
import kotlin.math.abs
import kotlin.math.atan
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.sinh
import kotlin.math.tan

/**
Expand All @@ -36,45 +29,10 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) {
/** Returns the coordinates of the tile at [targetZoom] with the same northwest corner. */
fun originAtZoom(targetZoom: Int): TileCoordinates {
val zoomDelta = targetZoom - zoom
val scale = { x: Int -> if (zoomDelta > 0) x shl zoomDelta else x shr abs(zoomDelta) }
return TileCoordinates(scale(x), scale(y), targetZoom)
return TileCoordinates(x.shiftLeft(zoomDelta), y.shiftLeft(zoomDelta), targetZoom)
}

override fun toString(): String = "($x, $y) at zoom $zoom"
fun toCoords(xOffset: Int, yOffset: Int): LatLng {
val w = 256 / 2
val a = (w / PI) * (1 shl zoom)
var lon = (x / a) - PI
var lat = (atan(exp(PI - (y / a))) - (PI / 4)) * 2
lat = toDegrees(lat)
lon = toDegrees(lon)
return LatLng(lat, lon)
}


fun tileCoordsAndOffsetToLatLon(offsetX: Int, offsetY: Int): Pair<Double, Double> {
val tileSize = 256.0 // Tile size for Web Mercator projection
val pixelX = x * tileSize + offsetX
val pixelY = y * tileSize + offsetY
val mercatorX = (pixelX / tileSize - 0.5) * (1 shl zoom)
val mercatorY = (0.5 - pixelY / tileSize) * (1 shl zoom)
val longitude = mercatorX * 360.0
val latitude = Math.toDegrees(atan(sinh(mercatorY * PI)))
return Pair(latitude, longitude)
}

/**
* Returns the latitude and longitude of a specific pixel at the specified offset within the
* specified tile.
*/
fun getLatLngAtPixelOffset(xOffset: Int, yOffset: Int): LatLng {
val tileSize = 256.0
val mercatorX = (x * tileSize + xOffset) / tileSize - 0.5
val mercatorY = 0.5 - (y * tileSize + yOffset) / tileSize
val longitude = mercatorX * 360.0
val latitude = toDegrees(atan(sinh(2.0 * PI * mercatorY)))
return LatLng(latitude, longitude)
}

companion object {
/**
Expand All @@ -84,9 +42,10 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) {
fun fromLatLng(coordinates: LatLng, zoom: Int): TileCoordinates {
val zoomFactor = 1 shl zoom
val latRad = coordinates.latitude.toRadians()
val x = zoomFactor * (coordinates.longitude + 180) / 360
val y = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / PI)) / 2
return TileCoordinates(x.toInt(), y.toInt(), zoom)
val x1 = zoomFactor * (coordinates.longitude + 180) / 360
val y1 = zoomFactor * (1 - (ln(tan(latRad) + sec(latRad)) / Math.PI)) / 2
val (x, y) = PixelCoordinates((x1 * 256.0).toInt(), (y1 * 256.0).toInt(), zoom)
return TileCoordinates(x / 256, y / 256, zoom)
}

/**
Expand All @@ -106,9 +65,3 @@ data class TileCoordinates(val x: Int, val y: Int, val zoom: Int) {
}
}
}

/** Returns the secant of angle `x` given in radians. */
private fun sec(x: Double) = 1 / cos(x)

/** Converts degrees into radians. */
private fun Double.toRadians() = this * (PI / 180)
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ class MapStateRepositoryTest : BaseHiltTest() {
fun isOfflineImageryEnabled_whenEnabled_returnsTrue() = runWithTestDispatcher {
mapStateRepository.isOfflineImageryEnabled = true

mapStateRepository.offlineImageryEnabledFlow.test { assertThat(expectMostRecentItem()).isTrue() }
mapStateRepository.offlineImageryEnabledFlow.test {
assertThat(expectMostRecentItem()).isTrue()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ class TileCoordinatesTest {
// println(TileCoordinates(1, 1, 1).getLatLngAtPixelOffset(0,0))
// println(TileCoordinates(1, 1, 1).getLatLngAtPixelOffset(511,511))
// https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#6/-37.07/-6.97
println(TileCoordinates(25, 33, 6).getLatLngAtPixelOffset(0, 0))
// println(TileCoordinates(25, 33, 6).getLatLngAtPixelOffset(0, 0))
println(TileCoordinates(25, 33, 6).tileCoordsAndOffsetToLatLon(0, 0))
println(TileCoordinates(25, 33, 6).toCoords(0, 0))
// println(TileCoordinates(25, 33, 6).toCoords(0, 0))

}
}

0 comments on commit bba7ed9

Please sign in to comment.