diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index f99b3b1..3deb998 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2113,6 +2113,16 @@ final class com.mapconductor.core.polygon.IntArrayList { public final void add(int); public final void clear(); } +public final class com.mapconductor.core.polygon.IntList { + public com.mapconductor.core.polygon.IntList(int); + public com.mapconductor.core.polygon.IntList(int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final int getSize(); + public final void add(int); + public final int pop(); + public final int[] toArray(); + public final int[] toSortedDistinctArray(); + public com.mapconductor.core.polygon.IntList(); +} final class com.mapconductor.core.polygon.Node { public com.mapconductor.core.polygon.Node(double, double, com.mapconductor.core.features.GeoPointInterface); public final double getX(); @@ -2150,6 +2160,8 @@ public abstract class com.mapconductor.core.polygon.PolygonController getClickListener(); public final void setClickListener(kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polygon.PolygonTiler getTiler(); + public final void setTiler(com.mapconductor.core.polygon.PolygonTiler); public final void dispatchClick(com.mapconductor.core.polygon.PolygonEvent); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object update(com.mapconductor.core.polygon.PolygonState, kotlin.coroutines.Continuation); @@ -2210,6 +2222,14 @@ public final class com.mapconductor.core.polygon.PolygonFingerPrint { public int hashCode(); public boolean equals(java.lang.Object); } +public final class com.mapconductor.core.polygon.PolygonGridIndex { + public static final com.mapconductor.core.polygon.PolygonGridIndex$Companion Companion; + public com.mapconductor.core.polygon.PolygonGridIndex(java.util.HashMap, int[]); + public final int[] query(com.mapconductor.core.polygon.TilePolygon[], double, double, double, double); +} +public final class com.mapconductor.core.polygon.PolygonGridIndex$Companion { + public final com.mapconductor.core.polygon.PolygonGridIndex build(com.mapconductor.core.polygon.TilePolygon[]); +} public final class com.mapconductor.core.polygon.PolygonHolePieceSplitKt { public static final kotlin.Pair, java.util.List> splitPiece(java.util.List, java.util.List, java.util.List>); public static final com.mapconductor.core.features.GeoPointInterface mirrorLngPoint(com.mapconductor.core.features.GeoPointInterface); @@ -2301,6 +2321,108 @@ public final class com.mapconductor.core.polygon.PolygonState implements com.map public final com.mapconductor.core.polygon.PolygonState copy-lqbfp2Q(java.util.List, java.util.List>, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1); public com.mapconductor.core.polygon.PolygonState(java.util.List, java.util.List, java.lang.String, long, float, long, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } +public final class com.mapconductor.core.polygon.PolygonTileGeometryKt { + public static final double POLYGON_CELL; + public static final int POLYGON_MAX_CELLS; + public static final double DP_TOLERANCE_PX; + public static final double MIN_OUTLINED_PX; + public static final boolean isVisibleColor(int); + public static final double[] toMercatorRing(java.util.List); + public static final double signedArea(double[]); + public static final double[] reversedRing(double[]); + public static final com.mapconductor.core.polygon.TileShape simplifyPolygon(com.mapconductor.core.polygon.TilePolygon, double); + public static final double[] simplifyRing(double[], double); + public static final double[] douglasPeucker(double[], int, double); + public static final int vertexCountOf(java.util.List); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer implements com.mapconductor.core.tileserver.TileProviderInterface { + public static final com.mapconductor.core.polygon.PolygonTileRenderer$Companion Companion; + public static final int DEFAULT_CACHE_BYTES; + public com.mapconductor.core.polygon.PolygonTileRenderer(int, int, kotlin.jvm.functions.Function0); + public com.mapconductor.core.polygon.PolygonTileRenderer(int, int, kotlin.jvm.functions.Function0, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final int getTileSize(); + public final void setPolygons(java.util.List); + public final void update(java.util.List>); + public final void clear(); + public byte[] renderTile(com.mapconductor.core.tileserver.TileRequest); + public byte[] renderTile(com.mapconductor.core.tileserver.TileRequest, kotlin.jvm.functions.Function0); + public final android.graphics.Bitmap renderBitmap(int, int, int, int); + public com.mapconductor.core.polygon.PolygonTileRenderer(); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer$Companion { +} +final class com.mapconductor.core.polygon.PolygonTileRenderer$Model { + public final com.mapconductor.core.polygon.TilePolygon[] getPolygons(); + public final com.mapconductor.core.polygon.PolygonGridIndex getIndex(); + public final float getMaxStrokePx(); + public final java.util.LinkedHashMap> getPerZoom(); +} +final class com.mapconductor.core.polygon.PolygonTileRenderer$Simplified { + public final com.mapconductor.core.polygon.TilePolygon getSource(); + public final com.mapconductor.core.polygon.TileShape getShape(); +} +public final class com.mapconductor.core.polygon.PolygonTileRenderer$TileRenderCancelled extends java.lang.Exception { +} +public final class com.mapconductor.core.polygon.PolygonTileSet { + public static final com.mapconductor.core.polygon.PolygonTileSet$Companion Companion; + public com.mapconductor.core.polygon.PolygonTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.core.polygon.PolygonTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final java.lang.String getRouteId(); + public final kotlin.jvm.functions.Function2, java.lang.Object> getOnTemplateChanged(); + public final void setOnTemplateChanged(kotlin.jvm.functions.Function2, ? extends java.lang.Object>); + public final int getTileSize(); + public final java.lang.Object setPolygons(java.util.List, kotlin.coroutines.Continuation); + public final boolean has(java.lang.String); + public final void update(com.mapconductor.core.polygon.PolygonState); + public final void remove(java.lang.String); + public final java.lang.String template(); + public final void dispose(); + public com.mapconductor.core.polygon.PolygonTileSet(); +} +public final class com.mapconductor.core.polygon.PolygonTileSet$Companion { + public final boolean canTile(com.mapconductor.core.polygon.PolygonState); + public final int vertexCount(java.util.List); +} +public final class com.mapconductor.core.polygon.PolygonTiler { + public com.mapconductor.core.polygon.PolygonTiler(com.mapconductor.core.polygon.PolygonTilingOptions); + public com.mapconductor.core.polygon.PolygonTiler(com.mapconductor.core.polygon.PolygonTilingOptions, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final com.mapconductor.core.polygon.PolygonTilingOptions getOptions(); + public final kotlin.jvm.functions.Function3, java.lang.Object> getOnTemplate(); + public final void setOnTemplate(kotlin.jvm.functions.Function3, ? extends java.lang.Object>); + public final java.lang.Object route(java.util.List, kotlin.coroutines.Continuation>); + public final java.util.List tiledStates(); + public final boolean isTiled(java.lang.String); + public final void update(com.mapconductor.core.polygon.PolygonState); + public final void remove(java.lang.String); + public final void dispose(); + public final java.lang.Object clear(kotlin.coroutines.Continuation); + public com.mapconductor.core.polygon.PolygonTiler(); +} +public final class com.mapconductor.core.polygon.PolygonTilerKt { + public static final java.lang.String POLYGON_TILE_LAYER_ID; + public static final com.mapconductor.core.raster.RasterLayerState polygonTileRasterLayerState(java.lang.String, int); +} +public final class com.mapconductor.core.polygon.PolygonTilingOptions { + public static final com.mapconductor.core.polygon.PolygonTilingOptions$Companion Companion; + public com.mapconductor.core.polygon.PolygonTilingOptions(boolean, int, int); + public com.mapconductor.core.polygon.PolygonTilingOptions(boolean, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final boolean getEnabled(); + public final int getMinPolygonCount(); + public final int getMinVertexCount(); + public final boolean shouldUseTiles(int, int); + public final boolean component1(); + public final int component2(); + public final int component3(); + public final com.mapconductor.core.polygon.PolygonTilingOptions copy(boolean, int, int); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.polygon.PolygonTilingOptions(); +} +public final class com.mapconductor.core.polygon.PolygonTilingOptions$Companion { + public final com.mapconductor.core.polygon.PolygonTilingOptions getDefault(); + public final com.mapconductor.core.polygon.PolygonTilingOptions getDisabled(); +} public final class com.mapconductor.core.polygon.PolygonUnionKt { public static final com.mapconductor.core.polygon.PolygonState unionHoles(com.mapconductor.core.polygon.PolygonState); public static final com.mapconductor.core.polygon.PolygonState union(com.mapconductor.core.polygon.PolygonState); @@ -2311,6 +2433,32 @@ public final class com.mapconductor.core.polygon.PolygonWindingUtilsKt { public static final java.util.List ensureCounterClockwise(java.util.List); public static final java.util.List ensureClockwiseRing(java.util.List); } +public final class com.mapconductor.core.polygon.TilePolygon { + public static final com.mapconductor.core.polygon.TilePolygon$Companion Companion; + public com.mapconductor.core.polygon.TilePolygon(double[][], double[], int, int, float); + public final double[][] getRings(); + public final double[] getBbox(); + public final int getFill(); + public final int getStroke(); + public final float getStrokeWidth(); + public final boolean getFillVisible(); + public final boolean getStrokeVisible(); + public final boolean sameBox(com.mapconductor.core.polygon.TilePolygon); +} +public final class com.mapconductor.core.polygon.TilePolygon$Companion { + public final com.mapconductor.core.polygon.TilePolygon from(com.mapconductor.core.polygon.PolygonState); +} +public abstract class com.mapconductor.core.polygon.TileShape { + public com.mapconductor.core.polygon.TileShape(kotlin.jvm.internal.DefaultConstructorMarker); +} +public final class com.mapconductor.core.polygon.TileShape$Dot extends com.mapconductor.core.polygon.TileShape { + public final double getX(); + public final double getY(); +} +public final class com.mapconductor.core.polygon.TileShape$Rings extends com.mapconductor.core.polygon.TileShape { + public final double[][] getRings(); + public final boolean getOutlined(); +} final class com.mapconductor.core.polygon.Vec { public com.mapconductor.core.polygon.Vec(double, double); public final double getX(); diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt new file mode 100644 index 0000000..8493c5e --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTileRendererDeviceTest.kt @@ -0,0 +1,182 @@ +package com.mapconductor.core.polygon + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.tileserver.TileRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * ポリゴンタイルの画素。tile-sw.js の「Polygon tiles」節と同じ描き方であることを固定する。 + * + * 1 単位 = 1 画素(pixelScale = 1)で描かせ、z0 のタイル(世界全体が 256 画素)を見る。 + * 経度 λ は x = (λ + 180) / 360 * 256、赤道は y = 128。 + */ +@RunWith(AndroidJUnit4::class) +class PolygonTileRendererDeviceTest { + private val red = Color(0xFFFF0000) + private val blue = Color(0xFF0000FF) + private val green = Color(0xFF00FF00) + + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun box( + south: Double, + west: Double, + north: Double, + east: Double, + ): List = listOf(p(south, west), p(south, east), p(north, east), p(north, west)) + + private fun renderer(vararg states: PolygonState) = + PolygonTileRenderer(tileSize = 256, pixelScale = { 1f }).apply { + setPolygons(states.map(TilePolygon::from)) + } + + private fun x(lng: Double) = ((lng + 180) / 360 * 256).toInt() + + private fun alpha(pixel: Int) = pixel ushr 24 + + private fun rgb(pixel: Int) = pixel and 0xFFFFFF + + /** 外周と同じ向きの穴も抜け、枠線は外周にだけ引く */ + @Test + fun holeWoundLikeOutlineIsCutOut_andOnlyOutlineIsStroked() { + val outer = box(-60.0, -90.0, 60.0, 90.0) + val hole = box(-20.0, -30.0, 20.0, 30.0) + // 穴を外周と同じ向きで渡す。nonzero で抜けるのは、描く前に巻きを逆にそろえるから + val bitmap = + renderer( + PolygonState(points = outer, holes = listOf(hole), fillColor = blue, strokeColor = red, strokeWidth = 2.dp), + ).renderBitmap(0, 0, 0)!! + assertEquals("穴の中は透明", 0, alpha(bitmap.getPixel(128, 128))) + assertEquals("塗り", 0x0000FF, rgb(bitmap.getPixel(x(-60.0), 128))) + assertEquals("外周の枠線", 0xFF0000, rgb(bitmap.getPixel(x(-90.0), 128))) + // 穴の縁(経度 -30 度)は塗りの縁で、枠線の色は無い + for (dx in -1..1) { + val pixel = bitmap.getPixel(x(-30.0) + dx, 128) + assertTrue("穴の縁 dx=$dx に枠線の赤: ${Integer.toHexString(pixel)}", (pixel shr 16 and 0xFF) < 0x40) + } + } + + /** 画面上 4 画素未満のポリゴンは枠線を省く */ + @Test + fun polygonUnderFourPixelsHasNoOutline() { + // 経度 2 度 = 1.4 画素、緯度 2 度 ≒ 1.4 画素 + val bitmap = + renderer( + PolygonState(points = box(0.0, 0.0, 4.0, 4.0), fillColor = blue, strokeColor = red, strokeWidth = 1.dp), + ).renderBitmap(0, 0, 0)!! + var reddest = 0 + for (py in 120..130) for (px in 125..135) reddest = maxOf(reddest, bitmap.getPixel(px, py) shr 16 and 0xFF) + assertEquals("枠線の赤が無い", 0, reddest) + } + + /** 1 画素に満たないポリゴンは塗りの色の 1 画素になる */ + @Test + fun subPixelPolygonIsOnePixelOfFill() { + val bitmap = + renderer( + PolygonState(points = box(0.0, 0.0, 0.01, 0.01), fillColor = blue, strokeColor = red), + ).renderBitmap(0, 0, 0)!! + var painted = 0 + for (py in 0 until 256) { + for (px in 0 until 256) { + val pixel = bitmap.getPixel(px, py) + if (alpha(pixel) != 0) { + painted++ + assertEquals(0x0000FF, rgb(pixel)) + } + } + } + assertEquals(1, painted) + } + + /** 何も無いタイルと、見えない色だけのタイルは null(空) */ + @Test + fun tileWithNothingVisibleIsEmpty() { + val r = + renderer( + PolygonState(points = box(0.0, 10.0, 10.0, 20.0), fillColor = Color.Transparent, strokeColor = Color.Transparent), + ) + // z1 の北西のタイル(経度 -180..0、北半球)には何も無い + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + // 北東のタイルにはあるが、見えない色だけ + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + } + + /** その場の変更は、重なるタイルだけ描き直す */ + @Test + fun inPlaceChangeRedrawsOnlyOverlappingTiles() { + val west = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "west") + val east = PolygonState(points = box(10.0, 90.0, 20.0, 100.0), fillColor = blue, id = "east") + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + val westBefore = r.renderTile(westTile)!! + val eastBefore = r.renderTile(eastTile)!! + + west.fillColor = green + r.update(listOf(0 to TilePolygon.from(west))) + + val westAfter = r.renderTile(westTile)!! + assertNotSame("変えたポリゴンのタイルは描き直す", westBefore, westAfter) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + val decoded = decode(westAfter) + // z1: 世界が 512 画素。経度 -95 度は x = 120、北緯 15 度は y = 234 + assertEquals(0x00FF00, rgb(decoded.getPixel(120, 234))) + } + + /** タイルから外したポリゴン(null)は、重なっていたタイルだけ描き直して消える */ + @Test + fun removedPolygonRedrawsOnlyItsTiles() { + val west = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "west") + val east = PolygonState(points = box(10.0, 90.0, 20.0, 100.0), fillColor = blue, id = "east") + val r = renderer(west, east) + val westTile = TileRequest(x = 0, y = 0, z = 1) + val eastTile = TileRequest(x = 1, y = 0, z = 1) + assertNotNull(r.renderTile(westTile)) + val eastBefore = r.renderTile(eastTile)!! + + r.update(listOf(0 to null)) + + assertNull("外したポリゴンしか無いタイルは空", r.renderTile(westTile)) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + } + + /** 形が変わったら索引も作り直す */ + @Test + fun movedShapeIsReindexed() { + val moving = PolygonState(points = box(10.0, -100.0, 20.0, -90.0), fillColor = blue, id = "m") + val r = renderer(moving) + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + moving.points = box(10.0, 90.0, 20.0, 100.0) + r.update(listOf(0 to TilePolygon.from(moving))) + assertNotNull("移った先のタイルに出る", r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + assertNull("元のタイルからは消える", r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + } + + /** 取りやめられたタイルは空ではなく失敗にする */ + @Test + fun cancelledTileFailsInsteadOfComingBackEmpty() { + val r = renderer(PolygonState(points = box(0.0, 0.0, 10.0, 10.0), fillColor = blue)) + val thrown = + runCatching { r.renderTile(TileRequest(x = 0, y = 0, z = 0)) { true } }.exceptionOrNull() + assertTrue(thrown is PolygonTileRenderer.TileRenderCancelled) + } + + private fun decode(png: ByteArray): Bitmap = BitmapFactory.decodeByteArray(png, 0, png.size) +} diff --git a/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt new file mode 100644 index 0000000..3d5b30d --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polygon/PolygonTilerDeviceTest.kt @@ -0,0 +1,337 @@ +package com.mapconductor.core.polygon + +import android.graphics.BitmapFactory +import androidx.compose.ui.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.net.HttpURLConnection +import java.net.URL + +/** + * PolygonController と PolygonTiler の振り分け。js-sdk-core の PolygonController(tiler プロパティ)と同じ規則: + * タイルにしたポリゴンは PolygonManager に残し(タップ用、元の形)、レンダラには渡さない。 + * + * PolygonTileSet が本物のタイルサーバーに経路を作るので、端末で動かす。 + */ +@RunWith(AndroidJUnit4::class) +class PolygonTilerDeviceTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + /** 0.001 度角の区画を n 個、ボストンに並べる。 */ + private fun parcels( + n: Int, + fill: Color = Color(0xFF2563EB), + ): List = + List(n) { i -> + val lat = 42.30 + (i / 100) * 0.0012 + val lng = -71.10 + (i % 100) * 0.0012 + PolygonState( + points = listOf(p(lat, lng), p(lat, lng + 0.001), p(lat + 0.001, lng + 0.001), p(lat + 0.001, lng)), + id = "parcel-$i", + fillColor = fill, + ) + } + + private class Renderer : PolygonOverlayRendererInterface { + val onMap = mutableSetOf() + val added = mutableListOf() + val changed = mutableListOf() + val removed = mutableListOf() + + override suspend fun onAdd(data: List): List = + data.map { params -> + params.state.id.also { + onMap.add(it) + added.add(it) + } + } + + override suspend fun onChange(data: List>): List = + data.map { params -> params.current.state.id.also { changed.add(it) } } + + override suspend fun onRemove(data: List>) { + data.forEach { + onMap.remove(it.state.id) + removed.add(it.state.id) + } + } + + override suspend fun onPostProcess() {} + } + + private class Controller( + renderer: Renderer, + ) : PolygonController(PolygonManager(), renderer) + + private class Templates { + val seen = mutableListOf() + + fun last() = seen.lastOrNull() + } + + private fun controller(): Triple { + val renderer = Renderer() + val controller = Controller(renderer) + val templates = Templates() + controller.tiler = + PolygonTiler().apply { + onTemplate = { template, _ -> synchronized(templates) { templates.seen.add(template) } } + } + return Triple(controller, renderer, templates) + } + + /** しきい値を超えたらネイティブには何も渡さず、タップ用にマネージャへ残す */ + @Test + fun pastThresholdNothingGoesNative_andTapsStillHit() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + assertTrue(renderer.onMap.isEmpty()) + assertEquals(2000, controller.polygonManager.allEntities().size) + assertNotNull(templates.last()) + // タップ判定は元の形で + assertEquals("parcel-101", controller.find(p(42.3017, -71.0983))?.state?.id) + assertNull(controller.find(p(42.3011, -71.0995))) + } + + /** しきい値を下回ったらネイティブに戻し、上回ったらネイティブから外す */ + @Test + fun crossingThresholdMovesPolygonsBetweenNativeAndTiles() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.add(parcels(1999)) + assertEquals(1999, renderer.onMap.size) + assertNull(templates.last()) + + controller.add(parcels(2000)) + assertTrue("タイルへ移ったらネイティブから消す", renderer.onMap.isEmpty()) + assertEquals(2000, controller.polygonManager.allEntities().size) + assertNotNull(templates.last()) + + controller.add(parcels(10)) + assertEquals(10, renderer.onMap.size) + assertEquals("リストから消えたものは忘れる", 10, controller.polygonManager.allEntities().size) + assertNull(templates.last()) + } + + /** タイルのポリゴンのその場の変更は、版を上げたテンプレートで知らせる */ + @Test + fun inPlaceChangeOfTiledPolygonAnnouncesNewTemplate() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = parcels(2000) + controller.add(states) + val before = templates.last()!! + states[5].fillColor = Color(0xFF16A34A) + controller.update(states[5]) + assertTrue(renderer.onMap.isEmpty()) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + assertTrue(templates.last()!! != before) + assertEquals( + Color(0xFF16A34A), + controller.polygonManager + .getEntity("parcel-5") + ?.state + ?.fillColor, + ) + } + + /** タイルはタイルサーバーから PNG で届き、何も無い場所は透明の 200 */ + @Test + fun tilesAreServedAsPng_andEmptyPlacesAsTransparent200() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(parcels(2000)) + val template = templates.last()!! + // z14 で区画の並ぶ場所と、何も無い南半球 + val (x, y) = tileOf(42.31, -71.07, 14) + val drawn = get(template, 14, x, y) + assertEquals(200, drawn.first) + val bitmap = BitmapFactory.decodeByteArray(drawn.second, 0, drawn.second.size) + assertNotNull(bitmap) + var painted = 0 + for (py in 0 until bitmap.height step 4) { + for (px in 0 until bitmap.width step 4) { + if (bitmap.getPixel(px, py) ushr 24 != + 0 + ) { + painted++ + } + } + } + assertTrue("区画が描かれている: $painted", painted > 0) + val empty = get(template, 14, 100, 12000) + assertEquals(200, empty.first) + } + + /** clear でテンプレートを外し、タイル側のポリゴンも忘れる */ + @Test + fun clearRemovesTemplateAndForgetsTiledPolygons() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(parcels(2000)) + controller.clear() + assertNull(templates.last()) + assertTrue(controller.polygonManager.allEntities().isEmpty()) + assertTrue(TileServerRegistry.get().isRunning()) + } + + // ── タイルにできなくなったポリゴン(geodesic・経度の幅 180° 以上・頂点 3 未満) ────── + // + // タイルにするかは add でだけ決めていた。タイルで描いているポリゴンをその場で + // geodesic=true にしたり、点を書き換えてタイルにできない形にしたりすると、update は + // タイルの経路に入ったまま canTile を見直さず、直線のままタイルに描き続けた。 + // js-sdk-core の PolygonTilingInPlace.test.mjs、ios-sdk-core の PolygonTilingInPlaceTests と同じ内容。 + + /** parcel-101 の中と、隣の parcel-102 の中 */ + private val inside101 = 42.3017 to -71.0983 + private val inside102 = 42.3017 to -71.0971 + + /** 混在リストでは geodesic だけネイティブ、タイルには描かれない */ + @Test + fun inAMixedListOnlyTheGeodesicOneGoesNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val g = + PolygonState( + points = listOf(p(42.29, -71.10), p(42.29, -71.099), p(42.291, -71.099), p(42.291, -71.10)), + id = "g", + fillColor = Color(0xFFDC2626), + geodesic = true, + ) + controller.add(parcels(2000) + g) + assertEquals(setOf("g"), renderer.onMap) + val template = templates.last()!! + assertEquals("タイルには描かない", 0, alphaAt(template, 42.2905, -71.0995, 17)) + assertTrue("区画はタイルに描く", alphaAt(template, inside101.first, inside101.second, 17) > 0) + } + + private suspend fun movesToNative(change: (PolygonState) -> Unit): Triple> { + val (controller, renderer, templates) = controller() + // しきい値(2,000)より 1 つ多い。移った 1 つを除いても、渡し直したときに残りはタイルのまま。 + val states = parcels(2001) + controller.add(states) + assertTrue(renderer.onMap.isEmpty()) + val before = templates.last()!! + assertTrue(alphaAt(before, inside101.first, inside101.second, 17) > 0) + + change(states[101]) + controller.update(states[101]) + withTimeout(2000) { + while (synchronized(templates) { templates.last() } == before) delay(5) + } + + assertEquals("ネイティブで描く", listOf("parcel-101"), renderer.added) + assertEquals("parcel-101", controller.polygonManager.getEntity("parcel-101")?.polygon) + val after = templates.last()!! + assertEquals("タイルから消える", 0, alphaAt(after, inside101.first, inside101.second, 17)) + assertTrue("隣はタイルに残る", alphaAt(after, inside102.first, inside102.second, 17) > 0) + return Triple(controller, renderer, states) + } + + /** その場で geodesic=true にしたらネイティブへ移り、タイルから消える。タップは引き続き当たる */ + @Test + fun settingGeodesicInPlaceMovesItToNative() = + runBlocking { + val (controller, renderer, states) = movesToNative { it.geodesic = true } + + val hit = controller.find(p(inside101.first, inside101.second)) + assertEquals("parcel-101", hit?.state?.id) + assertEquals("parcel-101", hit?.polygon) + assertEquals("parcel-102", controller.find(p(inside102.first, inside102.second))?.state?.id) + + // その後のその場の変更はネイティブへ + states[101].fillColor = Color(0xFF16A34A) + controller.update(states[101]) + assertEquals(listOf("parcel-101"), renderer.changed) + + // 同じリストを渡し直しても、ネイティブのまま(足しも消しもしない) + controller.add(states) + assertEquals(listOf("parcel-101"), renderer.added) + assertTrue(renderer.removed.isEmpty()) + assertEquals(setOf("parcel-101"), renderer.onMap) + } + + /** その場で点を書き換えて経度の幅 180° 以上にしても同じ */ + @Test + fun pointsSpanningHalfTheWorldInPlaceMoveItToNative() = + runBlocking { + movesToNative { + it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978), p(42.3022, -71.0978), p(42.3022, 120.0)) + } + Unit + } + + /** その場で点を 3 未満にしても同じ */ + @Test + fun fewerThanThreePointsInPlaceMoveItToNative() = + runBlocking { + movesToNative { it.points = listOf(p(42.3012, -71.0988), p(42.3012, -71.0978)) } + Unit + } + + /** The alpha of the tile pixel at a coordinate. */ + private fun alphaAt( + template: String, + lat: Double, + lng: Double, + z: Int, + ): Int { + val (x, y) = tileOf(lat, lng, z) + val (code, bytes) = get(template, z, x, y) + assertEquals(200, code) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return 0 + val scale = 256.0 * (1 shl z) + val s = kotlin.math.sin(Math.toRadians(lat)) + val px = (lng + 180) / 360 * scale - x * 256 + val py = (0.5 - kotlin.math.ln((1 + s) / (1 - s)) / (4 * Math.PI)) * scale - y * 256 + val k = bitmap.width / 256.0 + return bitmap.getPixel((px * k).toInt(), (py * k).toInt()) ushr 24 + } + + private fun tileOf( + lat: Double, + lng: Double, + z: Int, + ): Pair { + val n = 1 shl z + val x = ((lng + 180) / 360 * n).toInt() + val s = kotlin.math.sin(Math.toRadians(lat)) + val y = ((0.5 - kotlin.math.ln((1 + s) / (1 - s)) / (4 * Math.PI)) * n).toInt() + return x to y + } + + private fun get( + template: String, + z: Int, + x: Int, + y: Int, + ): Pair { + val url = template.replace("{z}", "$z").replace("{x}", "$x").replace("{y}", "$y") + val connection = URL(url).openConnection() as HttpURLConnection + connection.useCaches = false + return try { + val code = connection.responseCode + code to connection.inputStream.use { it.readBytes() } + } finally { + connection.disconnect() + } + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt index b1061e9..b5d6c88 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonController.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonController.kt @@ -4,8 +4,10 @@ import com.mapconductor.core.controller.OverlayHit import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.SlottedOverlayController import com.mapconductor.core.features.GeoPointInterface +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext abstract class PolygonController( val polygonManager: PolygonManagerInterface, @@ -19,6 +21,15 @@ abstract class PolygonController( var clickListener: OnPolygonEventHandler? = null + /** + * Set by a provider that can show polygon tiles (see [PolygonTiler]). + * Polygons it tiles stay in [polygonManager], for taps, with no native + * overlay: their entity's `polygon` is null and the renderer never sees + * them. + */ + var tiler: PolygonTiler? = null + private var tiledIds: Set = emptySet() + fun dispatchClick(event: PolygonEvent) { // 配送座標の wrap は PolygonEvent の生成時に一元化済み。 event.state.onClick?.invoke(event) @@ -27,13 +38,22 @@ abstract class PolygonController( override suspend fun add(data: List) { semaphore.withPermit { + val wasTiled = tiledIds + val tiled = tiler?.route(data) ?: emptySet() + tiledIds = tiled + val native = if (tiled.isEmpty()) data else data.filter { it.id !in tiled } val modifiedEntities = mutableListOf>() - val previous = polygonManager.allEntities().map { it.state.id }.toMutableSet() + // Only natively drawn polygons are diffed against the renderer. + val previous = + polygonManager + .allEntities() + .map { it.state.id } + .filterTo(mutableSetOf()) { it !in wasTiled } val added = mutableListOf() val updated = mutableListOf>() val removed = mutableListOf>() - data.forEach { state -> + native.forEach { state -> if (previous.contains(state.id)) { // Gone only if destroy() cleared the manager meanwhile; nothing left to update. val prevEntity = polygonManager.getEntity(state.id) ?: return@withPermit @@ -111,13 +131,64 @@ abstract class PolygonController( } } + // Tiled polygons: kept for taps, nothing native. Those that left + // the tiles and the list are forgotten; those that left the tiles + // for native drawing were added above. + if (wasTiled.isNotEmpty()) { + val nativeIds = native.mapTo(HashSet()) { it.id } + for (id in wasTiled) { + if (id !in tiled && id !in nativeIds) polygonManager.removeEntity(id) + } + } + if (tiled.isNotEmpty()) { + val tiledStates = tiler!!.tiledStates() + withContext(Dispatchers.Default) { + for (state in tiledStates) { + polygonManager.registerEntity(PolygonEntity(polygon = inTiles(), state = state)) + } + } + } + renderer.onPostProcess() } } + /** What a tiled polygon's entity holds instead of a native polygon. */ + @Suppress("UNCHECKED_CAST") + private fun inTiles(): ActualPolygon = null as ActualPolygon + override suspend fun update(state: PolygonState) { semaphore.withPermit { val prevEntity = polygonManager.getEntity(state.id) ?: return + if (state.id in tiledIds) { + if (PolygonTileSet.canTile(state)) { + polygonManager.registerEntity(PolygonEntity(polygon = prevEntity.polygon, state = state)) + tiler?.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 3 points): the map SDK draws it from now on. Still on the + // map, so this is not an add. Whether the rest stay in tiles is + // left to the next add(), which decides tiling for the whole list. + tiledIds = tiledIds - state.id + tiler?.remove(state.id) + val polygons = + renderer.onAdd( + listOf( + object : PolygonOverlayRendererInterface.AddParamsInterface { + override val state: PolygonState = state + }, + ), + ) + val polygon = polygons.firstOrNull() + if (polygon != null) { + polygonManager.registerEntity(PolygonEntity(polygon = polygon, state = state)) + } else { + polygonManager.removeEntity(state.id) + } + renderer.onPostProcess() + return + } val currentFinger = state.fingerPrint() val prevFinger = prevEntity.fingerPrint if (currentFinger == prevFinger) { @@ -152,7 +223,11 @@ abstract class PolygonController( override suspend fun clear() { semaphore.withPermit { - val entities: List> = polygonManager.allEntities() + val tiled = tiledIds + tiledIds = emptySet() + tiler?.clear() + val entities: List> = + polygonManager.allEntities().filter { it.state.id !in tiled } renderer.onRemove(entities) // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 @@ -166,6 +241,9 @@ abstract class PolygonController( .find(position) override fun destroy() { + // The tile server outlives the map; without this its route keeps every + // tiled polygon (and the renderer's copies of them) alive. + tiler?.dispose() // A destroyed controller can still be referenced (by its map controller, // or a state that has not let go yet); without this it keeps every entity. polygonManager.clear() diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt index aa0ad07..13b1e99 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonHoleUnion.kt @@ -34,6 +34,17 @@ fun unionHoleRings(holes: List>): List 0.0 }) return holes + return holes.mapIndexed { i, hole -> if (signedArea(rings[i]) > 0.0) hole.asReversed() else hole } + } val merged = unionRings(rings) if (merged.isEmpty()) return holes @@ -391,6 +402,54 @@ private fun nextEdge( return best } +/** + * True when no two rings cross, touch or nest. Edges are filed in an [EdgeGrid] + * and each is tested only against the edges near it, so the cost grows with the + * edge count rather than its square. Same as js-sdk-core's `holesAreDisjoint`. + */ +private fun holesAreDisjoint(rings: List>): Boolean { + val boxes = rings.map(::ringBounds) + // A ring inside another: one of its vertices is inside it. + for (i in rings.indices) { + val p = rings[i][0] + for (j in rings.indices) { + if (i == j) continue + val b = boxes[j] + if (p.x >= b[0] && p.x <= b[2] && p.y >= b[1] && p.y <= b[3] && pointInRing(p, rings[j])) { + return false + } + } + } + + val edges = ArrayList() + val edgeRing = IntArrayList() + for ((r, ring) in rings.withIndex()) { + for (i in ring.indices) { + edges.add(Edge(ring[i], ring[(i + 1) % ring.size])) + edgeRing.add(r) + } + } + // Only an edge whose box comes within MEET_PAD of another's can meet it. + val grid = EdgeGrid(edges, MEET_PAD) + val near = IntArrayList() + for (i in edges.indices) { + val seg = edges[i] + grid.near( + minOf(seg.a.x, seg.b.x) - MEET_PAD, + minOf(seg.a.y, seg.b.y) - MEET_PAD, + maxOf(seg.a.x, seg.b.x) + MEET_PAD, + maxOf(seg.a.y, seg.b.y) + MEET_PAD, + near, + ) + for (k in 0 until near.size) { + val j = near[k] + if (j <= i || edgeRing[j] == edgeRing[i]) continue + if (intersectionPoints(seg, edges[j]).isNotEmpty()) return false + } + } + return true +} + /** 線分 `other` が線分 `seg` と出会う点(交差・T 字接触・共線重複の端点)。 */ private fun intersectionPoints( seg: Edge, diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt index e325f59..9d87e0c 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonManager.kt @@ -4,10 +4,8 @@ import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.geometry.densifyPolygonRing import com.mapconductor.core.normalizeLng import java.util.concurrent.ConcurrentHashMap -import kotlin.math.abs import kotlin.math.max import kotlin.math.min -import kotlin.math.sqrt interface PolygonManagerInterface { fun registerEntity(entity: PolygonEntityInterface) @@ -164,13 +162,17 @@ class PolygonManager : PolygonManagerInterface { by: Double, eps: Double, ): Boolean { + // 線分までの距離が eps(度)以内なら辺上とみなす。 + // 以前は外積と内積を eps と直接比べていた。内積は度の 2 乗なので、 + // `(p-a)·(p-b) <= 1e-6` は「辺の中点から約 0.001°(~100 m)以内」を意味し、 + // 短い辺を持つ建物ほど、離れたタップを辺上として拾っていた。 val dx = bx - ax val dy = by - ay - val cross = dx * (py - ay) - dy * (px - ax) - val segLen = sqrt(dx * dx + dy * dy) - if (abs(cross) > eps * max(1.0, segLen)) return false - val dot = (px - ax) * (px - bx) + (py - ay) * (py - by) - return dot <= eps * max(1.0, segLen) + val lenSq = dx * dx + dy * dy + val t = if (lenSq > 0.0) (((px - ax) * dx + (py - ay) * dy) / lenSq).coerceIn(0.0, 1.0) else 0.0 + val ex = px - (ax + t * dx) + val ey = py - (ay + t * dy) + return ex * ex + ey * ey <= eps * eps } private fun unwrapLongitudesAround( diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt new file mode 100644 index 0000000..2dc9ae6 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt @@ -0,0 +1,367 @@ +package com.mapconductor.core.polygon + +import androidx.compose.ui.graphics.toArgb +import com.mapconductor.core.features.GeoPointInterface +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.hypot +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +// The geometry of polygon tiles, without any drawing: the same steps as the +// "Polygon tiles" section of js-sdk-core's tile-sw.js, so the three platforms +// draw the same pixels from the same polygons. +// +// Polygons become Web Mercator coordinates in [0, 1], with the outline wound +// one way and the holes the other, so a nonzero fill leaves out the union of +// the holes without computing it. +// +// Each zoom gets its own simplified copy of a polygon, built the first time a +// tile at that zoom needs it: vertices snapped to that zoom's pixel grid, +// repeats dropped, Douglas-Peucker at half a pixel, and a polygon smaller than +// a pixel drawn as one pixel. Taps never see any of this -- they are resolved +// against the original shapes in PolygonManager. + +/** Cells of the polygon index, in Web Mercator units: one z12 tile. */ +internal const val POLYGON_CELL = 1.0 / 4096.0 + +/** A polygon covering more cells than this is kept in a list every query checks. */ +internal const val POLYGON_MAX_CELLS = 256 + +internal const val DP_TOLERANCE_PX = 0.5 + +/** + * Below this size on screen a polygon's outline is left out: a 1 px line round + * a 3 px parcel is the whole parcel, and a city of them zoomed out turns into + * the outline colour. + */ +internal const val MIN_OUTLINED_PX = 4.0 + +private const val MAX_LATITUDE = 85.05112878 + +/** + * One polygon as the tiles draw it. + * + * [rings] are Web Mercator `[x0, y0, x1, y1, ...]` in [0, 1], the outline + * first and wound positive, every hole after it wound negative. [strokeWidth] + * is in dp, the unit of [PolygonState.strokeWidth]. + */ +internal class TilePolygon( + val rings: Array, + /** minX, minY, maxX, maxY of the outline. */ + val bbox: DoubleArray, + val fill: Int, + val stroke: Int, + val strokeWidth: Float, +) { + val fillVisible: Boolean get() = isVisibleColor(fill) + val strokeVisible: Boolean get() = strokeWidth > 0f && isVisibleColor(stroke) + + fun sameBox(other: TilePolygon): Boolean = bbox.contentEquals(other.bbox) + + companion object { + /** Null when the outline has fewer than three points. */ + fun from(state: PolygonState): TilePolygon? { + val rings = ArrayList(1 + state.holes.size) + val all = listOf(state.points) + state.holes + for ((r, points) in all.withIndex()) { + var ring = toMercatorRing(points) + if (ring.size < 6) { + if (r == 0) return null + continue + } + // Outline positive, holes negative: opposite windings under nonzero. + val area = signedArea(ring) + if ((r == 0 && area < 0) || (r > 0 && area > 0)) ring = reversedRing(ring) + rings.add(ring) + } + val outer = rings[0] + var minX = Double.POSITIVE_INFINITY + var minY = Double.POSITIVE_INFINITY + var maxX = Double.NEGATIVE_INFINITY + var maxY = Double.NEGATIVE_INFINITY + var i = 0 + while (i < outer.size) { + minX = min(minX, outer[i]) + maxX = max(maxX, outer[i]) + minY = min(minY, outer[i + 1]) + maxY = max(maxY, outer[i + 1]) + i += 2 + } + return TilePolygon( + rings = rings.toTypedArray(), + bbox = doubleArrayOf(minX, minY, maxX, maxY), + fill = state.fillColor.toArgb(), + stroke = state.strokeColor.toArgb(), + strokeWidth = state.strokeWidth.value, + ) + } + } +} + +internal fun isVisibleColor(argb: Int): Boolean = (argb ushr 24) != 0 + +internal fun toMercatorRing(points: List): DoubleArray { + val ring = DoubleArray(points.size * 2) + for ((i, p) in points.withIndex()) { + val lat = p.latitude.coerceIn(-MAX_LATITUDE, MAX_LATITUDE) + val s = sin(lat * PI / 180.0) + ring[i * 2] = (p.longitude + 180.0) / 360.0 + ring[i * 2 + 1] = 0.5 - ln((1 + s) / (1 - s)) / (4 * PI) + } + return ring +} + +internal fun signedArea(ring: DoubleArray): Double { + var area = 0.0 + val n = ring.size / 2 + var j = n - 1 + for (i in 0 until n) { + area += ring[j * 2] * ring[i * 2 + 1] - ring[i * 2] * ring[j * 2 + 1] + j = i + } + return area / 2 +} + +internal fun reversedRing(ring: DoubleArray): DoubleArray { + val n = ring.size / 2 + val out = DoubleArray(ring.size) + for (i in 0 until n) { + out[i * 2] = ring[(n - 1 - i) * 2] + out[i * 2 + 1] = ring[(n - 1 - i) * 2 + 1] + } + return out +} + +/** The polygon index: polygons by the grid cells their bounding box covers. */ +internal class PolygonGridIndex( + private val cells: HashMap, + private val large: IntArray, +) { + /** + * The polygons whose bounding box meets the given box, each once, in + * ascending index order -- which is draw order. + */ + fun query( + polygons: Array, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + ): IntArray { + val out = IntList() + + fun take(i: Int) { + val b = polygons[i]?.bbox ?: return + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) + } + for (i in large) take(i) + val cx0 = floor(minX / POLYGON_CELL).toLong() + val cx1 = floor(maxX / POLYGON_CELL).toLong() + val cy0 = floor(minY / POLYGON_CELL).toLong() + val cy1 = floor(maxY / POLYGON_CELL).toLong() + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + cells[cellKey(cx, cy)]?.forEach(::take) + } + } + // A polygon in several cells was taken once per cell. + return out.toSortedDistinctArray() + } + + companion object { + fun build(polygons: Array): PolygonGridIndex { + val cells = HashMap() + val large = IntList() + for (i in polygons.indices) { + val p = polygons[i] ?: continue + val cx0 = floor(p.bbox[0] / POLYGON_CELL).toLong() + val cx1 = floor(p.bbox[2] / POLYGON_CELL).toLong() + val cy0 = floor(p.bbox[1] / POLYGON_CELL).toLong() + val cy1 = floor(p.bbox[3] / POLYGON_CELL).toLong() + if ((cx1 - cx0 + 1) * (cy1 - cy0 + 1) > POLYGON_MAX_CELLS) { + large.add(i) + continue + } + for (cx in cx0..cx1) { + for (cy in cy0..cy1) { + cells.getOrPut(cellKey(cx, cy)) { IntList() }.add(i) + } + } + } + val packed = HashMap(cells.size * 2) + for ((key, list) in cells) packed[key] = list.toArray() + return PolygonGridIndex(packed, large.toArray()) + } + + private fun cellKey( + cx: Long, + cy: Long, + ): Long = cx * 1_048_576L + cy + } +} + +/** What one polygon looks like at one zoom. */ +internal sealed class TileShape { + /** World-pixel rings at the zoom, `[x0, y0, ...]`, outline first. */ + class Rings( + val rings: Array, + val outlined: Boolean, + ) : TileShape() + + /** A polygon under a pixel, drawn as the pixel at [x], [y]. */ + class Dot( + val x: Double, + val y: Double, + ) : TileShape() +} + +/** + * The polygon as drawn at a zoom whose world is [scale] pixels wide: rings of + * world-pixel points, a dot for a polygon under a pixel, never nothing. + */ +internal fun simplifyPolygon( + p: TilePolygon, + scale: Double, +): TileShape { + val w = (p.bbox[2] - p.bbox[0]) * scale + val h = (p.bbox[3] - p.bbox[1]) * scale + if (w < 1 && h < 1) { + return TileShape.Dot( + floor(((p.bbox[0] + p.bbox[2]) / 2) * scale), + floor(((p.bbox[1] + p.bbox[3]) / 2) * scale), + ) + } + val outer = + simplifyRing(p.rings[0], scale) + ?: return TileShape.Dot( + jsRound(((p.bbox[0] + p.bbox[2]) / 2) * scale), + jsRound(((p.bbox[1] + p.bbox[3]) / 2) * scale), + ) + val rings = ArrayList(p.rings.size) + rings.add(outer) + for (r in 1 until p.rings.size) { + simplifyRing(p.rings[r], scale)?.let(rings::add) + } + return TileShape.Rings(rings.toTypedArray(), outlined = max(w, h) >= MIN_OUTLINED_PX) +} + +/** Snapped to the pixel grid, repeats dropped, Douglas-Peucker; null under three points. */ +internal fun simplifyRing( + ring: DoubleArray, + scale: Double, +): DoubleArray? { + val snapped = DoubleArray(ring.size) + var n = 0 + var px = Double.NaN + var py = Double.NaN + var i = 0 + while (i < ring.size) { + val x = jsRound(ring[i] * scale) + val y = jsRound(ring[i + 1] * scale) + i += 2 + if (x == px && y == py) continue + snapped[n++] = x + snapped[n++] = y + px = x + py = y + } + val simplified = douglasPeucker(snapped, n / 2, DP_TOLERANCE_PX) + return if (simplified.size >= 6) simplified else null +} + +/** `Math.round` as JavaScript has it (halves go up), so pixels land where tile-sw.js puts them. */ +private fun jsRound(v: Double): Double = floor(v + 0.5) + +/** Douglas-Peucker on the first [n] points of [points]; returns the kept points. */ +internal fun douglasPeucker( + points: DoubleArray, + n: Int, + tolerance: Double, +): DoubleArray { + if (n < 3) return points.copyOf(n * 2) + val keep = BooleanArray(n) + keep[0] = true + keep[n - 1] = true + val stack = IntList() + stack.add(0) + stack.add(n - 1) + while (stack.size > 0) { + val b = stack.pop() + val a = stack.pop() + val ax = points[a * 2] + val ay = points[a * 2 + 1] + val dx = points[b * 2] - ax + val dy = points[b * 2 + 1] - ay + val len = hypot(dx, dy) + var best = -1.0 + var bestIndex = -1 + for (i in a + 1 until b) { + val px = points[i * 2] + val py = points[i * 2 + 1] + val d = if (len != 0.0) abs(dy * (px - ax) - dx * (py - ay)) / len else hypot(px - ax, py - ay) + if (d > best) { + best = d + bestIndex = i + } + } + if (best > tolerance) { + keep[bestIndex] = true + stack.add(a) + stack.add(bestIndex) + stack.add(bestIndex) + stack.add(b) + } + } + var kept = 0 + for (k in keep) if (k) kept++ + val out = DoubleArray(kept * 2) + var o = 0 + for (i in 0 until n) { + if (keep[i]) { + out[o++] = points[i * 2] + out[o++] = points[i * 2 + 1] + } + } + return out +} + +/** Total vertices across outlines and holes. */ +internal fun vertexCountOf(states: List): Int { + var n = 0 + for (s in states) { + n += s.points.size + for (h in s.holes) n += h.size + } + return n +} + +/** A growable int array, so indexes do not box every polygon index. */ +internal class IntList( + capacity: Int = 8, +) { + private var data = IntArray(capacity) + var size = 0 + private set + + fun add(v: Int) { + if (size == data.size) data = data.copyOf(max(8, size * 2)) + data[size++] = v + } + + fun pop(): Int = data[--size] + + fun toArray(): IntArray = data.copyOf(size) + + fun toSortedDistinctArray(): IntArray { + if (size == 0) return IntArray(0) + val a = data.copyOf(size) + a.sort() + var n = 1 + for (i in 1 until a.size) if (a[i] != a[n - 1]) a[n++] = a[i] + return if (n == a.size) a else a.copyOf(n) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt new file mode 100644 index 0000000..5467497 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt @@ -0,0 +1,388 @@ +package com.mapconductor.core.polygon + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.LruCache +import androidx.core.graphics.createBitmap +import com.mapconductor.core.ResourceProvider +import com.mapconductor.core.tileserver.ScratchPool +import com.mapconductor.core.tileserver.TilePngEncoder +import com.mapconductor.core.tileserver.TileProviderInterface +import com.mapconductor.core.tileserver.TileRequest +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReferenceArray +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Draws a set of polygons into raster tiles: the counterpart of the "Polygon + * tiles" section of js-sdk-core's tile-sw.js, served by [com.mapconductor.core.tileserver.LocalTileServer]. + * + * Geometry is in tile units (a tile is [tileSize] wide whatever its pixels); + * a tile is drawn [pixelScale] pixels per unit, so a stroke of 1 dp is one + * unit wide, as on the web where it is one CSS pixel. + * + * Tiles are cached here without the version the map sees in their URL. An + * in-place change ([update]) drops only the tiles that overlap what changed; + * the map asks again for every tile because the version moved, and the ones + * the change did not touch come straight back from the cache. + * + * [renderTile] runs concurrently on the tile server's threads. Every change + * replaces the model it reads, so a tile is drawn from one consistent set of + * polygons. + */ +internal class PolygonTileRenderer( + val tileSize: Int = 256, + cacheSizeBytes: Int = DEFAULT_CACHE_BYTES, + private val pixelScale: () -> Float = { ResourceProvider.getDensity() }, +) : TileProviderInterface { + private class Simplified( + val source: TilePolygon, + val shape: TileShape, + ) + + /** + * What tiles are drawn from. [perZoom] is shared by the models an + * [update] derives from one [setPolygons]: an entry is used only while its + * source is still the polygon at that index, so a changed polygon is + * simplified again without clearing anything. + */ + private class Model( + val polygons: Array, + val index: PolygonGridIndex, + val maxStrokePx: Float, + val perZoom: LinkedHashMap>, + ) + + @Volatile + private var model: Model? = null + + /** Bumped by every change, so a tile drawn before it is not cached after it. */ + private val generation = AtomicLong() + + private val tileCache = + object : LruCache(cacheSizeBytes) { + override fun sizeOf( + key: Long, + value: ByteArray, + ): Int = value.size + } + private val cacheLock = Any() + + // Pools, not thread-locals: tiles are drawn on the map SDK's threads, which can + // outlive this renderer (see ScratchPool) and would keep a tile bitmap each. + private val scratchBitmaps = ScratchPool() + private val byteStreams = ScratchPool() + + /** Replaces every polygon. Draw order is list order. */ + fun setPolygons(polygons: List) { + val array = polygons.toTypedArray() + val next = Model(array, PolygonGridIndex.build(array), maxStrokeOf(array), LinkedHashMap(8, 0.75f, true)) + model = next + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + /** + * Replaces the polygons at the given indexes. Only the tiles that + * overlapped one of them, before or after, are drawn again. + */ + fun update(updates: List>) { + val current = model ?: return + val polygons = current.polygons.copyOf() + val touched = ArrayList(updates.size * 2) + var maxStroke = current.maxStrokePx + var reindex = false + for ((index, after) in updates) { + if (index !in polygons.indices) continue + val before = polygons[index] + polygons[index] = after + before?.let { touched.add(it.bbox) } + after?.let { touched.add(it.bbox) } + if (after != null && after.strokeVisible) maxStroke = max(maxStroke, after.strokeWidth) + // A shape that moved needs the index rebuilt; a restyle does not. + if (before == null || after == null || !before.sameBox(after)) reindex = true + } + val index = if (reindex) PolygonGridIndex.build(polygons) else current.index + model = Model(polygons, index, maxStroke, current.perZoom) + synchronized(cacheLock) { + generation.incrementAndGet() + dropTilesIn(touched, maxStroke) + } + } + + fun clear() { + model = null + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + override fun renderTile(request: TileRequest): ByteArray? = renderTile(request) { false } + + override fun renderTile( + request: TileRequest, + isCancelled: () -> Boolean, + ): ByteArray? { + val generationAtStart = generation.get() + val m = model ?: return null + val z = request.z + if (z !in 0..MAX_ZOOM) return null + val n = 1 shl z + val x = ((request.x % n) + n) % n + val y = request.y + if (y !in 0 until n) return null + val ratio = request.pixelRatio.coerceIn(1, 3) + val key = cacheKey(z, x, y, ratio) + synchronized(cacheLock) { tileCache.get(key) }?.let { return it } + + val bitmap = draw(m, x, y, z, ratio, isCancelled) ?: return null + val bytes = + try { + encode(bitmap) + } finally { + releaseScratch(bitmap) + } + synchronized(cacheLock) { + // The polygons may have changed while this tile was drawn. + if (generation.get() == generationAtStart) tileCache.put(key, bytes) + } + return bytes + } + + /** The tile as a bitmap, or null when nothing of any polygon is in it. For tests and [renderTile]. */ + fun renderBitmap( + x: Int, + y: Int, + z: Int, + pixelRatio: Int = 1, + ): Bitmap? = + model?.let { draw(it, x, y, z, pixelRatio) { false } }?.let { scratch -> + try { + scratch.copy(Bitmap.Config.ARGB_8888, false) + } finally { + releaseScratch(scratch) + } + } + + private fun draw( + m: Model, + x: Int, + y: Int, + z: Int, + pixelRatio: Int, + isCancelled: () -> Boolean, + ): Bitmap? { + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val originX = x.toDouble() * tileSize + val originY = y.toDouble() * tileSize + val pad = m.maxStrokePx / 2.0 + 1.0 + val candidates = + m.index.query( + m.polygons, + (originX - pad) / scale, + (originY - pad) / scale, + (originX + tileSize + pad) / scale, + (originY + tileSize + pad) / scale, + ) + if (candidates.isEmpty()) return null + + val r = pixelScale() * pixelRatio + val px = (tileSize * r).roundToInt().coerceAtLeast(1) + val bitmap = scratch(px) + try { + return drawInto(bitmap, m, candidates, originX, originY, r, z, scale, isCancelled) + } catch (error: Throwable) { + releaseScratch(bitmap) + throw error + } + } + + /** Draws into [bitmap]; null (and the bitmap back in the pool) when nothing was drawn. */ + private fun drawInto( + bitmap: Bitmap, + m: Model, + candidates: IntArray, + originX: Double, + originY: Double, + r: Float, + z: Int, + scale: Double, + isCancelled: () -> Boolean, + ): Bitmap? { + val canvas = Canvas(bitmap) + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val strokePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + val dotPaint = Paint().apply { style = Paint.Style.FILL } + val path = Path().apply { fillType = Path.FillType.WINDING } + val perZoom = perZoomOf(m, z) + var drew = false + for ((k, index) in candidates.withIndex()) { + if (k % CANCEL_CHECK_EVERY == 0 && isCancelled()) throw TileRenderCancelled() + val p = m.polygons[index] ?: continue + val shape = simplifiedAt(perZoom, index, p, scale) + val fillVisible = p.fillVisible + when (shape) { + is TileShape.Dot -> { + if (!fillVisible && !p.strokeVisible) continue + dotPaint.color = if (fillVisible) p.fill else p.stroke + val left = ((shape.x - originX) * r).toFloat() + val top = ((shape.y - originY) * r).toFloat() + canvas.drawRect(left, top, left + r, top + r, dotPaint) + drew = true + } + is TileShape.Rings -> { + if (fillVisible) { + path.rewind() + path.fillType = Path.FillType.WINDING + for (ring in shape.rings) addRing(path, ring, originX, originY, r) + fillPaint.color = p.fill + canvas.drawPath(path, fillPaint) + drew = true + } + if (p.strokeVisible && shape.outlined) { + // Outline only, as the map SDKs draw it. + path.rewind() + addRing(path, shape.rings[0], originX, originY, r) + strokePaint.color = p.stroke + strokePaint.strokeWidth = p.strokeWidth * r + canvas.drawPath(path, strokePaint) + drew = true + } + } + } + } + if (!drew) releaseScratch(bitmap) + return if (drew) bitmap else null + } + + private fun addRing( + path: Path, + ring: DoubleArray, + originX: Double, + originY: Double, + r: Float, + ) { + path.moveTo(((ring[0] - originX) * r).toFloat(), ((ring[1] - originY) * r).toFloat()) + var i = 2 + while (i < ring.size) { + path.lineTo(((ring[i] - originX) * r).toFloat(), ((ring[i + 1] - originY) * r).toFloat()) + i += 2 + } + path.close() + } + + private fun perZoomOf( + m: Model, + z: Int, + ): AtomicReferenceArray = + synchronized(m.perZoom) { + // Access order: a lookup makes the zoom the most recent one. + m.perZoom[z] ?: AtomicReferenceArray(m.polygons.size).also { created -> + m.perZoom[z] = created + val it = m.perZoom.keys.iterator() + while (m.perZoom.size > ZOOMS_KEPT && it.hasNext()) { + it.next() + it.remove() + } + } + } + + private fun simplifiedAt( + perZoom: AtomicReferenceArray, + index: Int, + p: TilePolygon, + scale: Double, + ): TileShape { + val cached = perZoom.get(index) + if (cached != null && cached.source === p) return cached.shape + val shape = simplifyPolygon(p, scale) + perZoom.set(index, Simplified(p, shape)) + return shape + } + + private fun scratch(px: Int): Bitmap { + val existing = scratchBitmaps.acquire { !it.isRecycled && it.width == px && it.height == px } + if (existing != null) { + existing.eraseColor(Color.TRANSPARENT) + return existing + } + return createBitmap(px, px) + } + + private fun releaseScratch(bitmap: Bitmap) { + scratchBitmaps.release(bitmap) + } + + private fun encode(bitmap: Bitmap): ByteArray { + // Rust first, as for marker tiles: Bitmap.compress is several times slower. + TilePngEncoder.encode(bitmap)?.let { return it } + val out = byteStreams.acquire() ?: ByteArrayOutputStream(16 * 1024) + try { + out.reset() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + return out.toByteArray() + } finally { + byteStreams.release(out) + } + } + + private fun dropTilesIn( + boxes: List, + strokePx: Float, + ) { + if (boxes.isEmpty()) return + for (key in tileCache.snapshot().keys) { + val z = (key ushr 56).toInt() + val x = ((key ushr 27) and COORD_MASK).toInt() + val y = (key and COORD_MASK).toInt() + val scale = tileSize.toDouble() * (1L shl z).toDouble() + val pad = (strokePx + 2.0) / scale + val tx0 = x * tileSize / scale - pad + val tx1 = (x + 1) * tileSize / scale + pad + val ty0 = y * tileSize / scale - pad + val ty1 = (y + 1) * tileSize / scale + pad + if (boxes.any { b -> b[0] <= tx1 && b[2] >= tx0 && b[1] <= ty1 && b[3] >= ty0 }) { + tileCache.remove(key) + } + } + } + + private fun cacheKey( + z: Int, + x: Int, + y: Int, + ratio: Int, + ): Long = (z.toLong() shl 56) or (ratio.toLong() shl 54) or (x.toLong() shl 27) or y.toLong() + + /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ + class TileRenderCancelled : Exception("polygon tile no longer wanted") + + companion object { + const val DEFAULT_CACHE_BYTES = 16 * 1024 * 1024 + + /** Simplified copies kept, most recent zooms first. */ + private const val ZOOMS_KEPT = 4 + private const val MAX_ZOOM = 26 + private const val CANCEL_CHECK_EVERY = 256 + private const val COORD_MASK = (1L shl 27) - 1 + + private fun maxStrokeOf(polygons: Array): Float { + var maxStroke = 0f + for (p in polygons) if (p != null && p.strokeVisible) maxStroke = max(maxStroke, p.strokeWidth) + return maxStroke + } + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt new file mode 100644 index 0000000..e835325 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -0,0 +1,180 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds + +/** + * The polygons of one map that are drawn as raster tiles, and their route on + * the local tile server. + * + * Provider-independent: a provider decides which polygons to tile + * ([PolygonTilingOptions.shouldUseTiles], [canTile]), hands them to + * [setPolygons], and shows the returned URL template as a raster layer. + * In-place changes go through [update]; each one ends in a new template (the + * version in it is what makes the map ask again), reported to + * [onTemplateChanged]. Tiles the change did not touch are still cached by the + * renderer and come back without being drawn again. + * + * Same shape as js-sdk-core's `PolygonTileSet`. The web draws in its tile + * service worker and hands the polygons over as Encoded Polylines; here the + * server is in the process, so the renderer reads the states directly. + */ +class PolygonTileSet( + private val server: LocalTileServer = TileServerRegistry.get(), + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + val routeId: String = "polygons-${UUID.randomUUID().toString().take(8)}" + + /** Called with the new template after an [update]. */ + var onTemplateChanged: (suspend (String) -> Unit)? = null + + val tileSize: Int get() = TILE_SIZE + + private val renderer = PolygonTileRenderer(TILE_SIZE) + private val lock = Any() + private var indexById: Map = emptyMap() + + @Volatile + private var version = 0 + private var registered = false + private val pending = LinkedHashMap() + private val pendingRemovals = ArrayList() + private var flushJob: Job? = null + + /** + * Registers [states] (replacing whatever was registered) and returns the + * URL template of their tiles. + */ + suspend fun setPolygons(states: List): String { + cancelPending() + val (polygons, ids) = + withContext(Dispatchers.Default) { + // Draw order is list order: zIndex, then the order given (sortedBy is stable). + val ordered = states.sortedBy { it.zIndex } + val ids = HashMap(ordered.size * 2) + ordered.forEachIndexed { index, state -> ids[state.id] = index } + ordered.map(TilePolygon::from) to ids + } + synchronized(lock) { + renderer.setPolygons(polygons) + if (!registered) server.register(routeId, renderer) + indexById = ids + registered = true + version++ + } + return template() + } + + fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + + /** + * One polygon changed in place. A change of zIndex reorders the list and + * needs [setPolygons] instead; everything else is applied here. + */ + fun update(state: PolygonState) { + synchronized(lock) { + if (!indexById.containsKey(state.id)) return + pending[state.id] = state + scheduleFlushLocked() + } + } + + /** + * Takes one polygon out of the tiles. Its slot in the renderer's list is + * left empty, so the others keep their indices and only the tiles it was + * in are drawn again. + */ + fun remove(id: String) { + synchronized(lock) { + val index = indexById[id] ?: return + indexById = indexById - id + pending.remove(id) + pendingRemovals.add(index) + scheduleFlushLocked() + } + } + + /** Called with [lock] held. */ + private fun scheduleFlushLocked() { + if (flushJob == null) { + flushJob = + scope.launch { + delay(UPDATE_COALESCE) + flush() + } + } + } + + fun template(): String = server.urlTemplate(routeId, TILE_SIZE, version.toString()) + + fun dispose() { + cancelPending() + synchronized(lock) { + if (registered) server.unregister(routeId) + registered = false + indexById = emptyMap() + renderer.clear() + } + } + + private suspend fun flush() { + val template = + synchronized(lock) { + flushJob = null + val updates: List> = + pending.values.mapNotNull { state -> + indexById[state.id]?.let { index -> index to TilePolygon.from(state) } + } + pendingRemovals.map { index -> index to null } + pending.clear() + pendingRemovals.clear() + if (updates.isEmpty() || !registered) return + renderer.update(updates) + version++ + template() + } + onTemplateChanged?.invoke(template) + } + + private fun cancelPending() { + synchronized(lock) { + flushJob?.cancel() + flushJob = null + pending.clear() + pendingRemovals.clear() + } + } + + companion object { + private const val TILE_SIZE = 256 + + /** In-place changes arriving within this window are drawn as one. */ + private val UPDATE_COALESCE = 16.milliseconds + + /** + * Whether a polygon can be drawn in a tile. Geodesic edges are curves + * the tile does not trace, and a ring spanning half the world or more + * crosses the antimeridian; the map SDK draws both. + */ + fun canTile(state: PolygonState): Boolean { + if (state.geodesic || state.points.size < 3) return false + var min = Double.POSITIVE_INFINITY + var max = Double.NEGATIVE_INFINITY + for (p in state.points) { + if (p.longitude < min) min = p.longitude + if (p.longitude > max) max = p.longitude + } + return max - min < 180 + } + + fun vertexCount(states: List): Int = vertexCountOf(states) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt new file mode 100644 index 0000000..97ed549 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -0,0 +1,115 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** The id of the raster layer that shows tiled polygons. */ +const val POLYGON_TILE_LAYER_ID: String = "mc-polygon-tiles" + +/** The raster layer state for tiled polygons at [template]. */ +fun polygonTileRasterLayerState( + template: String, + tileSize: Int = 256, +): RasterLayerState = + RasterLayerState( + id = POLYGON_TILE_LAYER_ID, + source = RasterLayerSource.UrlTemplate(template = template, tileSize = tileSize), + ) + +/** + * Decides which of a map's polygons are drawn as tiles, keeps them registered + * with the local tile server, and tells the provider where the tiles are. + * + * Every provider's polygon controller uses it the same way: + * 1. [route] with the full list on every composition. It returns the ids now + * in tiles; the controller keeps those in its [PolygonManager] (taps are + * resolved there, against the original shapes) but gives the map SDK + * nothing for them, and removes any native polygon it had drawn for one. + * 2. [update] for an in-place change of a tiled polygon, or [remove] when the + * change means it can no longer be tiled. + * 3. [onTemplate] is how the tiles reach the map: a URL template to show as a + * raster layer, or null to remove it. A provider with a RasterLayer + * controller hands [polygonTileRasterLayerState] to its `upsert`, and + * removes it with `removeById(POLYGON_TILE_LAYER_ID)`. + * + * Same as js-sdk-core's and ios-sdk-core's `PolygonTiler`. + */ +class PolygonTiler( + val options: PolygonTilingOptions = PolygonTilingOptions.Default, +) { + var onTemplate: (suspend (template: String?, tileSize: Int) -> Unit)? = null + + private val tileSet = PolygonTileSet() + + @Volatile + private var tiled: Set = emptySet() + + @Volatile + private var states: List = emptyList() + + init { + tileSet.onTemplateChanged = { template -> onTemplate?.invoke(template, tileSet.tileSize) } + } + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set { + // A city of polygons is a second of work; none of it belongs on the map's thread. + val (tileable, useTiles) = + withContext(Dispatchers.Default) { + val tileable = data.filter(PolygonTileSet::canTile) + tileable to options.shouldUseTiles(tileable.size, PolygonTileSet.vertexCount(tileable)) + } + val template = if (useTiles) tileSet.setPolygons(tileable) else null + if (template == null) { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } else { + tiled = withContext(Dispatchers.Default) { tileable.mapTo(HashSet(tileable.size * 2)) { it.id } } + states = tileable + } + onTemplate?.invoke(template, tileSet.tileSize) + return tiled + } + + /** The states drawn as tiles, as of the last [route]. */ + fun tiledStates(): List = states + + fun isTiled(id: String): Boolean = tiled.contains(id) + + /** An in-place change of a tiled polygon; only the tiles it touches are drawn again. */ + fun update(state: PolygonState) { + tileSet.update(state) + } + + /** + * Takes one polygon out of the tiles, for a change that left it no longer + * [PolygonTileSet.canTile]; only the tiles it was in are drawn again. The + * controller then hands it to the map SDK. + */ + fun remove(id: String) { + if (id !in tiled) return + tiled = tiled - id + states = states.filter { it.id != id } + tileSet.remove(id) + } + + /** + * Releases the tiles when the map goes away. Unlike [clear], it does not + * call [onTemplate]: there is no map left to remove the layer from. + */ + fun dispose() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } + + suspend fun clear() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + onTemplate?.invoke(null, tileSet.tileSize) + } +} diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt new file mode 100644 index 0000000..8f6644e --- /dev/null +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTilingOptions.kt @@ -0,0 +1,31 @@ +package com.mapconductor.core.polygon + +/** + * Options for polygon tiling. + * + * When enabled and a map holds enough polygons, they are drawn into raster + * tiles by the SDK's local tile server instead of being handed to the map SDK + * one by one. Taps are still resolved against the original shapes in + * [PolygonManager], so tiling changes how polygons look, never what a tap hits. + * + * The counterpart of [com.mapconductor.core.marker.MarkerTilingOptions], and + * the same options as js-sdk-core's and ios-sdk-core's `PolygonTilingOptions`. + */ +data class PolygonTilingOptions( + val enabled: Boolean = true, + /** Tile once a map holds at least this many polygons… */ + val minPolygonCount: Int = 2000, + /** …or at least this many vertices across them, whichever comes first. */ + val minVertexCount: Int = 50_000, +) { + /** The one place the threshold is decided; callers must not restate it. */ + fun shouldUseTiles( + polygonCount: Int, + vertexCount: Int, + ): Boolean = enabled && (polygonCount >= minPolygonCount || vertexCount >= minVertexCount) + + companion object { + val Default: PolygonTilingOptions = PolygonTilingOptions() + val Disabled: PolygonTilingOptions = PolygonTilingOptions(enabled = false) + } +} diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt new file mode 100644 index 0000000..1339257 --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonHitToleranceTest.kt @@ -0,0 +1,65 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * A tap counts as on a polygon's edge only when it is within `eps` (1e-6°, + * about 0.1 m) of that edge. Taps outside the polygon's bounding box never get + * that far, so the tolerance only matters on and just inside the outline. + * + * js-sdk-core の `PolygonHitTolerance.test.mjs`、ios-sdk の `PolygonHitToleranceTests` と + * 1 対 1 で対応する。 + * + * 辺上の判定が度の 2 乗の量(内積)を 1e-6 と比べていて、短い辺から約 100 m 以内の + * タップをすべて「辺上」として拾っていた。建物の輪郭のように辺が数 m のポリゴンを + * 並べると、タップした建物ではなく、リストで先にある近所の建物が返る。大きな図形では + * 起きないので、サンプルの六角形では見えなかった。 + */ +class PolygonHitToleranceTest { + // ボストン中心部の、一辺が約 10 m の建物 + private val building = + listOf( + GeoPoint(42.3550, -71.0600), + GeoPoint(42.3550, -71.0599), + GeoPoint(42.3551, -71.0599), + GeoPoint(42.3551, -71.0600), + GeoPoint(42.3550, -71.0600), + ) + + private fun managerWith(vararg states: PolygonState): PolygonManager = + PolygonManager().apply { + states.forEach { registerEntity(PolygonEntity(polygon = Unit, state = it)) } + } + + @Test + fun `50 m 離れたタップは、辺の短い建物に当たらない`() { + val manager = managerWith(PolygonState(points = building, id = "small")) + // 建物の東の辺から東へ約 50 m(経度 0.0006°) + assertNull(manager.find(GeoPoint(42.35505, -71.0593))) + // 北へ約 50 m + assertNull(manager.find(GeoPoint(42.3556, -71.05995))) + } + + @Test + fun `内側のタップと辺の上のタップは当たり、0点6 m 外れたタップは当たらない`() { + val manager = managerWith(PolygonState(points = building, id = "small")) + assertEquals("small", manager.find(GeoPoint(42.35505, -71.05995))?.state?.id) + assertEquals("南の辺の上", "small", manager.find(GeoPoint(42.3550, -71.05995))?.state?.id) + assertNull("南の辺から外へ約 0.6 m", manager.find(GeoPoint(42.354995, -71.05995))) + } + + @Test + fun `近所の建物ではなく、タップした建物が返る`() { + // 西隣(約 20 m 西)にもう 1 棟。リストでは先に入れる + val west = building.map { GeoPoint(it.latitude, it.longitude - 0.00025) } + val manager = + managerWith( + PolygonState(points = west, id = "west"), + PolygonState(points = building, id = "tapped"), + ) + assertEquals("tapped", manager.find(GeoPoint(42.35505, -71.05995))?.state?.id) + } +} diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt new file mode 100644 index 0000000..f30811f --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTileGeometryTest.kt @@ -0,0 +1,146 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * タイルに描く前の幾何: 巻きの正規化、ズームごとの簡略化、グリッド索引。 + * + * js-sdk-core の tile-sw.js「Polygon tiles」節と同じ手順であることを固定する。 + * 画素そのものは端末で見る(androidTest の `PolygonTileRendererDeviceTest`)。 + */ +class PolygonTileGeometryTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun square( + lat: Double, + lng: Double, + size: Double, + ): List = listOf(p(lat, lng), p(lat, lng + size), p(lat + size, lng + size), p(lat + size, lng)) + + @Test + fun `外周は正、穴は負の巻きにそろう(与えた向きによらない)`() { + val outer = square(0.0, 0.0, 4.0) + val hole = square(1.0, 1.0, 1.0) + for (o in listOf(outer, outer.reversed())) { + for (h in listOf(hole, hole.reversed())) { + val polygon = TilePolygon.from(PolygonState(points = o, holes = listOf(h)))!! + assertTrue(signedArea(polygon.rings[0]) > 0) + assertTrue(signedArea(polygon.rings[1]) < 0) + } + } + } + + @Test + fun `外接矩形は Web Mercator の 0 から 1 で、北が小さい y`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 10.0)))!! + assertEquals(0.5, polygon.bbox[0], 1e-12) + assertEquals((10.0 + 180) / 360, polygon.bbox[2], 1e-12) + assertEquals(0.5, polygon.bbox[3], 1e-12) + assertTrue(polygon.bbox[1] < 0.5) + } + + @Test + fun `外周が 3 点未満なら描かない、穴が 3 点未満なら穴だけ捨てる`() { + assertNull(TilePolygon.from(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 4.0), holes = listOf(listOf(p(1.0, 1.0)))))!! + assertEquals(1, polygon.rings.size) + } + + @Test + fun `頂点は画素格子に丸め、続く重複を捨て、半画素の Douglas-Peucker で間引く`() { + val scale = 1000.0 + // 1 画素幅の正方形の辺上に、同じ画素に丸まる点と、直線上の点を足す + val ring = + doubleArrayOf( + 0.0, + 0.0, + 0.0004, + 0.0001, // (0, 0) に丸まる + 0.005, + 0.0, // 直線上 + 0.010, + 0.0, + 0.010, + 0.010, + 0.0, + 0.010, + ) + val simplified = simplifyRing(ring, scale)!! + assertArrayEquals(doubleArrayOf(0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0), simplified, 0.0) + } + + @Test + fun `半画素未満のずれは捨て、それを超えるずれは残す`() { + val straight = douglasPeucker(doubleArrayOf(0.0, 0.0, 5.0, 0.4, 10.0, 0.0), 3, DP_TOLERANCE_PX) + assertArrayEquals(doubleArrayOf(0.0, 0.0, 10.0, 0.0), straight, 0.0) + val bent = douglasPeucker(doubleArrayOf(0.0, 0.0, 5.0, 1.0, 10.0, 0.0), 3, DP_TOLERANCE_PX) + assertEquals(6, bent.size) + } + + @Test + fun `1 画素に満たないポリゴンは 1 画素の点になる`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 0.001)))!! + // z0: 世界が 256 画素。0.001 度は 0.0007 画素 + val shape = simplifyPolygon(polygon, 256.0) + assertTrue(shape is TileShape.Dot) + shape as TileShape.Dot + assertEquals(128.0, shape.x, 0.0) + assertEquals(127.0, shape.y, 0.0) + } + + @Test + fun `画面上 4 画素未満のポリゴンは枠線を描かない`() { + val polygon = TilePolygon.from(PolygonState(points = square(0.0, 0.0, 1.0)))!! + // 1 度は 256 * 2^z / 360 画素。z2: 2.8 px、z3: 5.7 px + val small = simplifyPolygon(polygon, 256.0 * 4) as TileShape.Rings + val large = simplifyPolygon(polygon, 256.0 * 8) as TileShape.Rings + assertFalse(small.outlined) + assertTrue(large.outlined) + } + + @Test + fun `索引は外接矩形が重なるものだけを、重複なく描く順に返す`() { + val states = + listOf( + square(0.0, 0.0, 0.01), // 0 + square(0.0, 1.0, 0.01), // 1: 離れている + square(0.0, 0.005, 0.2), // 2: 複数のセルにまたがる + square(-60.0, -100.0, 120.0), // 3: 大きい(every query) + ) + val polygons = states.map { TilePolygon.from(PolygonState(points = it)) }.toTypedArray() + val index = PolygonGridIndex.build(polygons) + val b0 = polygons[0]!!.bbox + assertArrayEquals(intArrayOf(0, 2, 3), index.query(polygons, b0[0], b0[1], b0[2], b0[3])) + val b1 = polygons[1]!!.bbox + assertArrayEquals(intArrayOf(1, 3), index.query(polygons, b1[0], b1[1], b1[2], b1[3])) + // 消したポリゴン(null)は返さない + polygons[0] = null + assertArrayEquals(intArrayOf(2, 3), index.query(polygons, b0[0], b0[1], b0[2], b0[3])) + } + + @Test + fun `測地線と、経度の幅が 180 度以上のポリゴンはタイルにしない`() { + assertTrue(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0), geodesic = true))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, -90.0, 180.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + } + + @Test + fun `頂点数は外周と穴の合計`() { + val state = PolygonState(points = square(0.0, 0.0, 4.0), holes = listOf(square(1.0, 1.0, 1.0))) + assertEquals(16, PolygonTileSet.vertexCount(listOf(state, state))) + assertNotNull(TilePolygon.from(state)) + } +} diff --git a/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt new file mode 100644 index 0000000..fe5eadd --- /dev/null +++ b/src/test/java/com/mapconductor/core/polygon/PolygonTilingTest.kt @@ -0,0 +1,102 @@ +package com.mapconductor.core.polygon + +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * ポリゴンのタイル描画を支える部品。 + * + * js-sdk-core の `PolygonTiling.test.mjs`、ios-sdk の `PolygonTilingTests` と 1 対 1 で対応する。 + * web の Encoded Polyline の 2 件は、サービスワーカーへ渡すための変換なので + * Android には無い(タイルサーバーが同じプロセスにあり、状態をそのまま読む)。 + * + * - しきい値: ポリゴン数か頂点数のどちらかが届けばタイル。判定はここ 1 か所。 + * - 穴のユニオンの近道: 交わらない穴はそのまま返す。道路網の区画(穴 80 個・穴の頂点 + * 8,000)で 1 個数秒かかっていた二乗の計算を、交わる穴があるときだけに絞る。 + */ +class PolygonTilingTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private fun square( + lat: Double, + lng: Double, + size: Double, + ): List = listOf(p(lat, lng), p(lat, lng + size), p(lat + size, lng + size), p(lat + size, lng)) + + /** The same square wound clockwise, the way holes come out of the union. */ + private fun clockwise(ring: List): List = ring.reversed() + + @Test + fun `ポリゴン数か頂点数のどちらかが届けばタイルにする`() { + val o = PolygonTilingOptions.Default + assertFalse(o.shouldUseTiles(1999, 49_999)) + assertTrue(o.shouldUseTiles(2000, 0)) + assertTrue(o.shouldUseTiles(10, 50_000)) + assertFalse(PolygonTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) + } + + @Test + fun `geodesic・経度の幅 180 度以上・頂点 3 未満はタイルにしない`() { + assertTrue(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0)))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = square(0.0, 0.0, 1.0), geodesic = true))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -100.0), p(0.0, 80.0), p(10.0, 0.0))))) + assertTrue(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, -90.0), p(0.0, 89.0), p(10.0, 0.0))))) + assertFalse(PolygonTileSet.canTile(PolygonState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + } + + @Test + fun `交わらない穴は計算せずにそのまま返す`() { + val holes = listOf(square(0.0, 0.0, 1.0), square(0.0, 2.0, 1.0), square(2.0, 0.0, 1.0)).map(::clockwise) + assertSame(holes, unionHoleRings(holes)) + } + + /** + * Android の union は穴を時計回りにそろえて返す(レンダラーが外周と逆巻きを期待する。 + * `PolygonHoleUnionTest.disjointSquares_remainSeparateWithSameAreas`)。近道でもそれは守る。 + * web の近道は向きをそろえずに返す。 + */ + @Test + fun `近道でも、反時計回りの穴は時計回りにして返す`() { + val holes = listOf(square(0.0, 0.0, 1.0), clockwise(square(0.0, 2.0, 1.0))) + val result = unionHoleRings(holes) + assertEquals(2, result.size) + assertEquals(holes[0].reversed(), result[0]) + assertSame(holes[1], result[1]) + } + + @Test + fun `重なる穴・中に入った穴・接する穴は従来どおり 1 つにまとめる`() { + assertEquals("重なる", 1, unionHoleRings(listOf(square(0.0, 0.0, 2.0), square(1.0, 1.0, 2.0))).size) + assertEquals("中に入った", 1, unionHoleRings(listOf(square(0.0, 0.0, 4.0), square(1.0, 1.0, 1.0))).size) + assertEquals("辺で接する", 1, unionHoleRings(listOf(square(0.0, 0.0, 1.0), square(0.0, 1.0, 1.0))).size) + } + + @Test + fun `交わらない穴が多くても速い(穴 80 個 x 100 頂点)`() { + val holes = + List(80) { i -> + val cx = (i % 10) * 3.0 + val cy = (i / 10) * 3.0 + List(100) { k -> + val a = -2 * PI * k / 100 + p(cy + sin(a), cx + cos(a)) + } + } + val start = System.nanoTime() + val result = unionHoleRings(holes) + val ms = (System.nanoTime() - start) / 1e6 + assertSame(holes, result) + assertTrue("$ms ms", ms < 200) + } +} diff --git a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt new file mode 100644 index 0000000..93d3f6d --- /dev/null +++ b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt @@ -0,0 +1,82 @@ +package com.mapconductor.core.raster + +import com.mapconductor.core.polygon.polygonTileRasterLayerState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * 1 枚の地図に載るラスターレイヤーは 3 系統ある。アプリの RasterLayer(add = composition)、 + * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)。 + * + * js-sdk-core の `RasterLayerCoexistence.test.mjs`、ios-sdk の `RasterLayerCoexistenceTests` と + * 1 対 1 で対応する。 + * + * ## なぜここを固定するか + * + * マーカータイルを composition / clear で載せ外ししていたプロバイダでは、composition が + * 「渡されなかったレイヤーを消す」ので、マーカータイルを載せるとアプリの RasterLayer が、 + * アプリの RasterLayer が変わるとマーカータイルが消え、どちらも自分のデータが次に変わるまで + * 戻らなかった(web で起きた)。SDK が自分で載せるレイヤーは upsert / removeById で扱い、 + * add はアプリのレイヤーだけを入れ替える。 + */ +class RasterLayerCoexistenceTest { + private class Renderer( + val onMap: MutableSet, + ) : RasterLayerOverlayRendererInterface { + override val coroutine: CoroutineScope = CoroutineScope(Dispatchers.Unconfined) + + override suspend fun onAdd(data: List): List = + data.map { it.state.id.also(onMap::add) } + + override suspend fun onChange(data: List>): List = + data.map { it.current.state.id } + + override suspend fun onRemove(data: List>) { + data.forEach { onMap.remove(it.state.id) } + } + + override suspend fun onPostProcess() {} + } + + private class Controller( + renderer: Renderer, + ) : RasterLayerController(RasterLayerManager(), renderer) + + private fun layer(id: String) = + RasterLayerState( + id = id, + source = RasterLayerSource.UrlTemplate(template = "https://example.com/$id/{z}/{x}/{y}.png"), + ) + + private fun markerTiles(version: Int) = + RasterLayerState( + id = "marker-tile-m", + source = RasterLayerSource.UrlTemplate(template = "http://127.0.0.1/tiles/m/256/$version/{z}/{x}/{y}.png"), + ) + + @Test + fun `アプリの RasterLayer・マーカータイル・ポリゴンタイルは互いを消さない`() = + runBlocking { + val onMap = mutableSetOf() + val controller = Controller(Renderer(onMap)) + controller.add(listOf(layer("relief"))) + controller.upsert(polygonTileRasterLayerState("http://127.0.0.1/tiles/p/256/1/{z}/{x}/{y}.png")) + controller.upsert(markerTiles(1)) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "relief"), onMap.sorted()) + + // アプリの RasterLayer が変わっても、SDK のタイルは残る + controller.add(listOf(layer("photo"))) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + + // マーカータイルの版が変わっても、ほかは残る + controller.upsert(markerTiles(2)) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + + // マーカータイルを外しても、ほかは残る + controller.removeById("marker-tile-m") + assertEquals(listOf("mc-polygon-tiles", "photo"), onMap.sorted()) + } +}