diff --git a/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt new file mode 100644 index 0000000000..ed912406e6 --- /dev/null +++ b/app/src/main/java/de/westnordost/streetcomplete/data/GpxImport.kt @@ -0,0 +1,261 @@ +package de.westnordost.streetcomplete.data + +import de.westnordost.streetcomplete.ApplicationConstants +import de.westnordost.streetcomplete.data.download.tiles.TilePos +import de.westnordost.streetcomplete.data.download.tiles.enclosingTilesRect +import de.westnordost.streetcomplete.data.osm.mapdata.BoundingBox +import de.westnordost.streetcomplete.data.osm.mapdata.LatLon +import de.westnordost.streetcomplete.data.osm.mapdata.toPolygon +import de.westnordost.streetcomplete.util.logs.Log +import de.westnordost.streetcomplete.util.math.area +import de.westnordost.streetcomplete.util.math.distanceTo +import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.initialBearingTo +import de.westnordost.streetcomplete.util.math.translate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.sqrt + +private const val TAG = "GpxImport" + +// file slightly modified from https://github.com/streetcomplete/StreetComplete/pull/5369 +data class GpxImportData( + val displayTrack: Boolean, + val downloadAlongTrack: Boolean, + val trackpoints: List, + val downloadBBoxes: List, + val areaToDownloadInSqkm: Double, +) + +private class DecoratedBoundingBox(val polygon: Iterable) { + val boundingBox = polygon.enclosingBoundingBox() + val area = boundingBox.area() + val tiles = boundingBox.enclosingTilesRect(ApplicationConstants.DOWNLOAD_TILE_ZOOM) + .asTilePosSequence() + val numberOfTiles = tiles.count() +} + +// TODO sgr: refactor function signature when adapting UI +/** + * @param originalTrackPoints points from GPX + * @param displayTrack display the track on the map after import + * @param minDownloadDistance in meters; points within minDownloadDistance along the track should be downloaded + */ +suspend fun importGpx( + originalTrackPoints: List, + displayTrack: Boolean, + minDownloadDistance: Double, +): Result = withContext(Dispatchers.Default) { + require(minDownloadDistance in 10.0..500.0) { + "minDownloadDistance needs to be of reasonable size" + } + + /* Algorithm overview: + * + * Given that two resampled points A and B are at most 2 * minDownloadDistance away from each + * other and any track point between them is at most minDownloadDistance away from either A or B, + * an area that fully contains the track between A and B is given by a square S_track centered + * on the middle point between A and B, with side length 2 * minDownloadDistance and rotated + * such that two of its sides align with the vector from A to B. As we need to cover the area + * within minDownloadDistance of any track point (which might lie almost on the edge of S_track), + * a square S_min centered and rotated the same as S_track, but with + * side length = 4 * minDownloadDistance is a handy upper bound. + * + * If we download two non-rotated squares centered on A and B, they are guaranteed to contain + * S_min if their side length is at least 4 * minDownloadDistance / sqrt(2) - the worst case + * being were S_min is rotated 45 degrees with respect to the non-rotated squares. + */ + val maxSampleDistance = 2 * minDownloadDistance + val coveringSquareHalfLength = 2 * minDownloadDistance / sqrt(2.0) + + var progress = 0 + val mergedBBoxes = originalTrackPoints + .asSequence() + // TODO sgr: just a test how one could decorate sequences with a callback + // -> this approach would need orchestration at this level from UI code + .map { + progress++ + if (progress % 500 == 0) { + Log.d(TAG, "updating progress: ${progress / originalTrackPoints.size}") + } + it + } + .addInterpolatedPoints(maxSampleDistance) + .discardRedundantPoints(maxSampleDistance) + .mapToCenteredSquares(coveringSquareHalfLength) + .determineBBoxesToDownload() + .mergeBBoxesToDownload() + + return@withContext Result.success( + GpxImportData( + displayTrack, + true, + originalTrackPoints, + mergedBBoxes.map { it.boundingBox }.toList(), + mergedBBoxes + .flatMap { it.tiles } + .distinct() + .sumOf { it.asBoundingBox(ApplicationConstants.DOWNLOAD_TILE_ZOOM).area() } + / 1000000 + ) + ) +} + +/** + * TODO sgr: convert implementation to real sequence.. might be tricky + * Iteratively merge bounding boxes to save download calls in trade for a few more unique tiles + * downloaded + */ +private fun Sequence.mergeBBoxesToDownload(): Sequence { + var bBoxes = this.toList() + val mergedBBoxes = ArrayList() + while (mergedBBoxes.size < bBoxes.size) { + Log.d(TAG, "start a new round of bounding box merging") + var currentBBox: DecoratedBoundingBox? = null + for (bBox in bBoxes) { + if (currentBBox == null) { + currentBBox = bBox + continue + } + val mergedBBox = DecoratedBoundingBox(bBox.polygon + currentBBox.polygon) + // merge two adjacent boxes if at most one additional tile needs to be downloaded to save one call + currentBBox = + if (mergedBBox.numberOfTiles <= (currentBBox.tiles + bBox.tiles).toHashSet().size + 1) { + Log.d(TAG, "merge currentBBox with previous one") + mergedBBox + } else { + Log.d(TAG, "keep currentBBox separate from previous one") + mergedBBoxes.add(currentBBox) + bBox + } + } + currentBBox?.let { mergedBBoxes.add(it) } + if (mergedBBoxes.size < bBoxes.size) { + Log.d(TAG, "reduced bounding boxes from ${bBoxes.size} to ${mergedBBoxes.size}") + bBoxes = mergedBBoxes.toList() + mergedBBoxes.clear() + } else { + Log.d(TAG, "final number of bounding boxes: ${mergedBBoxes.size}") + } + } + return mergedBBoxes.asSequence() +} + +private fun Sequence.determineBBoxesToDownload(): Sequence { + var currentBBox: DecoratedBoundingBox? = null + val uniqueTilesToDownload = HashSet() + val inputIterator = this.map { DecoratedBoundingBox(it.toPolygon()) }.withIndex().iterator() + return sequence { + for ((index, newBBox) in inputIterator) { + if (currentBBox == null) { + currentBBox = newBBox + yield(newBBox) + continue + } + + if (!newBBox.tiles.any { tilePos -> tilePos !in uniqueTilesToDownload }) { + Log.d(TAG, "omit bounding box #$index, all tiles already scheduled for download") + continue + } + + val extendedBBox = DecoratedBoundingBox(currentBBox!!.polygon + newBBox.polygon) + currentBBox = if ( + // no additional tile needed to extend the polygon and download newBBox together with currentBBox + extendedBBox.numberOfTiles <= (currentBBox!!.tiles + newBBox.tiles).toHashSet().size + || + // downloaded area is not increased by extending the current polygon instead of downloading separately + extendedBBox.area < currentBBox!!.area + newBBox.area + ) { + Log.d(TAG, "extend currentBBox with bounding box #$index") + extendedBBox + } else { + Log.d(TAG, "schedule currentBBox, start new with bounding box #$index") + yield(currentBBox!!) + uniqueTilesToDownload.addAll(currentBBox!!.tiles) + newBBox + } + } + currentBBox?.let { yield(it) } + } +} + +/** + * Transform a sequence of points to a sequence of bounding boxes centered on the points. + */ +private fun Sequence.mapToCenteredSquares(halfSideLength: Double): Sequence = + map { + arrayListOf( + it.translate(halfSideLength, 0.0), + it.translate(halfSideLength, 90.0), + it.translate(halfSideLength, 180.0), + it.translate(halfSideLength, 270.0) + ).enclosingBoundingBox() + } + +/** + * Ensure points are at most samplingDistance away from each other. + * + * Given two consecutive points A, B which are more than samplingDistance away from each other, + * add intermediate points on the line from A to B, samplingDistance away from each other until the + * last one is <= samplingDistance away from B. + */ +private fun Sequence.addInterpolatedPoints(samplingDistance: Double): Sequence { + var candidatePoint: LatLon? = null + val seq = this.flatMap { currentPoint -> + if (candidatePoint == null) { + candidatePoint = currentPoint + return@flatMap emptySequence() + } + val interpolatedPoints = interpolate(candidatePoint!!, currentPoint, samplingDistance) + candidatePoint = currentPoint + return@flatMap interpolatedPoints + } + return seq + sequenceOf(candidatePoint).mapNotNull { it } +} + +/** + * Interpolate points between start (included) and end (not included) + * + * Returned points are samplingDistance away from each other and on the line between start and end. + * The last returned point is <= samplingDistance away from end. + */ +private fun interpolate(start: LatLon, end: LatLon, samplingDistance: Double): Sequence = + sequence { + val bearing = start.initialBearingTo(end) + var intermediatePoint = start + while (true) { + yield(intermediatePoint) + intermediatePoint = intermediatePoint.translate(samplingDistance, bearing) + if (intermediatePoint.distanceTo(end) <= samplingDistance) { + break + } + } + } + +/** + * Discard redundant points, such that no three remaining points A, B, C exist where B is less than + * samplingDistance away from both A and C + */ +private fun Sequence.discardRedundantPoints(samplingDistance: Double): Sequence { + var lastRetainedPoint: LatLon? = null + var candidatePoint: LatLon? = null + return this.flatMap { currentPoint -> + sequence { + if (candidatePoint == null) { + candidatePoint = currentPoint + } else if (lastRetainedPoint == null) { + lastRetainedPoint = candidatePoint + candidatePoint = currentPoint + } else if (lastRetainedPoint!!.distanceTo(candidatePoint!!) < samplingDistance + && candidatePoint!!.distanceTo(currentPoint) < samplingDistance + ) { + // discard candidatePoint + candidatePoint = currentPoint + } else { + lastRetainedPoint = candidatePoint + yield(lastRetainedPoint!!) + candidatePoint = currentPoint + } + } + } + sequenceOf(candidatePoint).mapNotNull { it } +} diff --git a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsFragment.kt b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsFragment.kt index a569b234f4..fc99a5da69 100644 --- a/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsFragment.kt +++ b/app/src/main/java/de/westnordost/streetcomplete/screens/settings/DisplaySettingsFragment.kt @@ -20,16 +20,23 @@ import de.westnordost.streetcomplete.Prefs import de.westnordost.streetcomplete.R import de.westnordost.streetcomplete.StreetCompleteApplication import de.westnordost.streetcomplete.data.download.DownloadController +import de.westnordost.streetcomplete.data.download.DownloadWorker import de.westnordost.streetcomplete.data.download.tiles.TilePos import de.westnordost.streetcomplete.data.download.tiles.enclosingTilePos import de.westnordost.streetcomplete.data.download.tiles.upToTwoMinTileRects +import de.westnordost.streetcomplete.data.importGpx import de.westnordost.streetcomplete.data.osm.mapdata.LatLon import de.westnordost.streetcomplete.data.visiblequests.VisibleQuestTypeController import de.westnordost.streetcomplete.screens.HasTitle import de.westnordost.streetcomplete.util.dialogs.setViewWithDefaultPadding import de.westnordost.streetcomplete.util.ktx.toast +import de.westnordost.streetcomplete.util.logs.Log import de.westnordost.streetcomplete.util.math.area +import de.westnordost.streetcomplete.util.math.contains import de.westnordost.streetcomplete.util.math.enclosingBoundingBox +import de.westnordost.streetcomplete.util.math.isCompletelyInside +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import java.io.File import java.io.IOException @@ -77,32 +84,14 @@ class DisplaySettingsFragment : isEnabled = gpxFileExists setOnClickListener { val points = loadGpxTrackPoints(requireContext(), true) ?: return@setOnClickListener - // for getting tiles containing the track, we simply assume that there is at least one point in each tile - // this will cause issues for crude tracks, but we can take care about that later - val usedTiles = hashSetOf() - points.forEach { usedTiles.add(it.enclosingTilePos(16)) } - val bbox = points.enclosingBoundingBox() - // only directly download if area < 2 kmĀ² - if (bbox.area() < 2 * 1000000) { - downloadController.download(bbox, false, true) - return@setOnClickListener + GlobalScope.launch { + val import = importGpx(points, true, 10.0).getOrNull() + import?.downloadBBoxes?.let { + if (it.isEmpty()) return@launch + DownloadWorker.enqueuedDownloads.addAll(it.drop(1)) + downloadController.download(it.first(), false, true) + } } - // try splitting - // todo: this way of splitting is not working well for tracks, so often the area is too large - // -> need to improve it - val tileRects = usedTiles.upToTwoMinTileRects() ?: return@setOnClickListener - if (tileRects.size == 1) { - if (bbox.area() < MAX_DOWNLOADABLE_AREA_IN_SQKM * 1000000) - downloadController.download(bbox, false, true) - else context?.toast(R.string.pref_gpx_track_download_too_big, Toast.LENGTH_LONG) - return@setOnClickListener - } - // if any area can't be split to smaller MAX_DOWNLOADABLE_AREA_IN_SQKM, cancel - // could try to split the tileRects even more, but don't care for now - if (tileRects.any { it.asBoundingBox(16).area() > MAX_DOWNLOADABLE_AREA_IN_SQKM * 1000000 }) - context?.toast(R.string.pref_gpx_track_download_too_big, Toast.LENGTH_LONG) - else - tileRects.forEach { downloadController.download(it.asBoundingBox(16), false, true) } } } val layout = LinearLayout(requireContext()).apply { diff --git a/app/src/main/res/values/strings_ee.xml b/app/src/main/res/values/strings_ee.xml index 90dbb2d0ee..0712fd409f 100644 --- a/app/src/main/res/values/strings_ee.xml +++ b/app/src/main/res/values/strings_ee.xml @@ -64,6 +64,7 @@ Error loading GPX track file Display GPX track Download area covered by GPX track + Download area too big, please download manually