From cc213dda3c59de8ff37cbd23fd576219b34f80b3 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Mon, 9 Nov 2026 09:37:58 -0800 Subject: [PATCH] Draw large polyline sets as raster tiles (PolylineTiler) PolylineTilingOptions / PolylineTileSet / PolylineTiler / POLYLINE_TILE_LAYER_ID / polylineTileRasterLayerState and PolylineController.tiler, the same shape as the polygon tiling: tiled polylines stay in PolylineManager for taps and give the renderer nothing (their entity's polyline is null). The tiles share everything below the polygon ones: ShapeTileSet (route, version in the URL, 16 ms coalescing, removal slots), ShapeTiler (routing), ShapeTileRenderer (grid index, per-zoom simplification, LRU cache and stroke-wide invalidation, pooled scratch bitmaps, PNG, cancellation) and ShapeGridIndex. PolygonTileSet, PolygonTiler and PolygonTileRenderer are thin layers over them now. A polyline tile is an open path: snapped to the zoom's pixel grid, Douglas-Peucker at half a pixel with both ends kept, round caps and joins, the stroke as wide in dp at every zoom, a dot as wide as the line when it collapses to a point. Candidates are taken half the widest stroke beyond the tile and drawn whole, so a line crossing a tile edge is the same on both sides. Taps: tapTolerance + strokeWidth / 2, nearest line, with each line's bounding box (made on registration) checked first. PolylineOverlay takes an optional per-map PolylineTilingOptions flow, applied to the map's tilers before every hand-over. --- api/android-sdk-core.api | 77 +++- .../PolylineTileRendererDeviceTest.kt | 281 ++++++++++++ .../core/polyline/PolylineTilerDeviceTest.kt | 381 ++++++++++++++++ .../core/controller/BaseMapViewController.kt | 6 + .../core/polygon/PolygonTileGeometry.kt | 57 +-- .../core/polygon/PolygonTileRenderer.kt | 397 ++++------------- .../core/polygon/PolygonTileSet.kt | 137 ++---- .../mapconductor/core/polygon/PolygonTiler.kt | 73 +-- .../core/polyline/PolylineController.kt | 84 +++- .../core/polyline/PolylineManager.kt | 70 ++- .../core/polyline/PolylineOverlay.kt | 41 +- .../core/polyline/PolylineTileGeometry.kt | 192 ++++++++ .../core/polyline/PolylineTileSet.kt | 103 +++++ .../core/polyline/PolylineTiler.kt | 90 ++++ .../core/polyline/PolylineTilingOptions.kt | 32 ++ .../core/shapetile/ShapeGridIndex.kt | 90 ++++ .../core/shapetile/ShapeTileRenderer.kt | 417 ++++++++++++++++++ .../core/shapetile/ShapeTileSet.kt | 166 +++++++ .../mapconductor/core/shapetile/ShapeTiler.kt | 82 ++++ .../core/controller/OverlayStackingTest.kt | 53 +++ .../core/polyline/PolylineHitToleranceTest.kt | 169 +++++++ .../core/polyline/PolylineTilingTest.kt | 144 ++++++ .../core/raster/RasterLayerCoexistenceTest.kt | 21 +- 23 files changed, 2595 insertions(+), 568 deletions(-) create mode 100644 src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt create mode 100644 src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt create mode 100644 src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt create mode 100644 src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt create mode 100644 src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt create mode 100644 src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt diff --git a/api/android-sdk-core.api b/api/android-sdk-core.api index 755ef29..ed427f5 100644 --- a/api/android-sdk-core.api +++ b/api/android-sdk-core.api @@ -2373,16 +2373,6 @@ public final class com.mapconductor.core.polygon.PolygonTileRenderer implements } 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 { @@ -2540,6 +2530,8 @@ public abstract class com.mapconductor.core.polyline.PolylineController getClickListener(); public final void setClickListener(kotlin.jvm.functions.Function1); + public final com.mapconductor.core.polyline.PolylineTiler getTiler(); + public final void setTiler(com.mapconductor.core.polyline.PolylineTiler); public final void dispatchClick(com.mapconductor.core.polyline.PolylineEvent); public java.lang.Object add(java.util.List, kotlin.coroutines.Continuation); public java.lang.Object update(com.mapconductor.core.polyline.PolylineState, kotlin.coroutines.Continuation); @@ -2634,9 +2626,11 @@ public interface com.mapconductor.core.polyline.PolylineManagerInterface { - public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>, kotlinx.coroutines.flow.StateFlow); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow, kotlinx.coroutines.flow.StateFlow, int, kotlin.jvm.internal.DefaultConstructorMarker); public kotlinx.coroutines.flow.StateFlow> getFlow(); public java.lang.Object render(java.util.Map, com.mapconductor.core.controller.MapViewControllerInterface, kotlin.coroutines.Continuation); + public com.mapconductor.core.polyline.PolylineOverlay(kotlinx.coroutines.flow.StateFlow>); } public interface com.mapconductor.core.polyline.PolylineOverlayRendererInterface { public abstract java.lang.Object onAdd(java.util.List, kotlin.coroutines.Continuation>); @@ -2682,6 +2676,67 @@ public final class com.mapconductor.core.polyline.PolylineState implements com.m public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); public com.mapconductor.core.polyline.PolylineState(java.util.List, java.lang.String, long, float, boolean, boolean, int, java.io.Serializable, kotlin.jvm.functions.Function1, kotlin.jvm.internal.DefaultConstructorMarker); } +public final class com.mapconductor.core.polyline.PolylineTileSet { + public static final com.mapconductor.core.polyline.PolylineTileSet$Companion Companion; + public com.mapconductor.core.polyline.PolylineTileSet(com.mapconductor.core.tileserver.LocalTileServer, kotlinx.coroutines.CoroutineScope); + public com.mapconductor.core.polyline.PolylineTileSet(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 setPolylines(java.util.List, kotlin.coroutines.Continuation); + public final boolean has(java.lang.String); + public final void update(com.mapconductor.core.polyline.PolylineState); + public final void remove(java.lang.String); + public final java.lang.String template(); + public final void dispose(); + public com.mapconductor.core.polyline.PolylineTileSet(); +} +public final class com.mapconductor.core.polyline.PolylineTileSet$Companion { + public final boolean canTile(com.mapconductor.core.polyline.PolylineState); + public final int vertexCount(java.util.List); +} +public final class com.mapconductor.core.polyline.PolylineTiler { + public com.mapconductor.core.polyline.PolylineTiler(com.mapconductor.core.polyline.PolylineTilingOptions); + public com.mapconductor.core.polyline.PolylineTiler(com.mapconductor.core.polyline.PolylineTilingOptions, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final com.mapconductor.core.polyline.PolylineTilingOptions getOptions(); + public final void setOptions(com.mapconductor.core.polyline.PolylineTilingOptions); + 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.polyline.PolylineState); + public final void remove(java.lang.String); + public final void dispose(); + public final java.lang.Object clear(kotlin.coroutines.Continuation); + public com.mapconductor.core.polyline.PolylineTiler(); +} +public final class com.mapconductor.core.polyline.PolylineTilerKt { + public static final java.lang.String POLYLINE_TILE_LAYER_ID; + public static final com.mapconductor.core.raster.RasterLayerState polylineTileRasterLayerState(java.lang.String, int); +} +public final class com.mapconductor.core.polyline.PolylineTilingOptions { + public static final com.mapconductor.core.polyline.PolylineTilingOptions$Companion Companion; + public com.mapconductor.core.polyline.PolylineTilingOptions(boolean, int, int); + public com.mapconductor.core.polyline.PolylineTilingOptions(boolean, int, int, int, kotlin.jvm.internal.DefaultConstructorMarker); + public final boolean getEnabled(); + public final int getMinPolylineCount(); + 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.polyline.PolylineTilingOptions copy(boolean, int, int); + public java.lang.String toString(); + public int hashCode(); + public boolean equals(java.lang.Object); + public com.mapconductor.core.polyline.PolylineTilingOptions(); +} +public final class com.mapconductor.core.polyline.PolylineTilingOptions$Companion { + public final com.mapconductor.core.polyline.PolylineTilingOptions getDefault(); + public final com.mapconductor.core.polyline.PolylineTilingOptions getDisabled(); +} public final class com.mapconductor.core.projection.Earth { public static final com.mapconductor.core.projection.Earth INSTANCE; public static final double RADIUS_METERS; diff --git a/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt new file mode 100644 index 0000000..029ca43 --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTileRendererDeviceTest.kt @@ -0,0 +1,281 @@ +package com.mapconductor.core.polyline + +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.shapetile.ShapeTileRenderer +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 +import kotlin.math.PI +import kotlin.math.ln +import kotlin.math.sin +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import kotlinx.coroutines.runBlocking + +/** + * ポリラインタイルの画素。tile-sw.js の「Shape tiles」節のポリラインと同じ描き方であることを固定する。 + * PolygonTileRendererDeviceTest と同じく、1 単位 = 1 画素(pixelScale = 1)で描かせる。 + * + * z0 のタイルは世界全体が 256 画素。経度 λ は x = (λ + 180) / 360 * 256、赤道は y = 128。 + * js-sdk-core の `PolylineTileRendering.test.mjs`、ios-sdk の `PolylineTileRendererTests` と 1 対 1。 + */ +@RunWith(AndroidJUnit4::class) +class PolylineTileRendererDeviceTest { + 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 renderer(vararg states: PolylineState) = + ShapeTileRenderer(tileSize = 256, pixelScale = { 1f }, painter = PolylineTilePainter).apply { + setItems(states.map(TilePolyline::from)) + } + + private fun alpha(pixel: Int) = pixel ushr 24 + + private fun rgb(pixel: Int) = pixel and 0xFFFFFF + + private fun centre(bitmap: Bitmap) = bitmap.getPixel(bitmap.width / 2, bitmap.height / 2) + + /** The rows of column [x] that are painted (alpha over half), top to bottom. */ + private fun paintedRows( + bitmap: Bitmap, + x: Int, + ): List = (0 until bitmap.height).filter { alpha(bitmap.getPixel(x, it)) > 0x80 } + + /** World y (in pixels of a 256 * 2^z world) of [lat]. */ + private fun worldY( + lat: Double, + z: Int, + ): Double { + val s = sin(lat * PI / 180) + return (0.5 - ln((1 + s) / (1 - s)) / (4 * PI)) * 256.0 * (1 shl z) + } + + /** 水平線は期待した行に、線幅ぶんの太さで出る */ + @Test + fun horizontalLineIsOnItsRowAsWideAsItsStroke() { + val bitmap = + renderer( + PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 90.0)), strokeColor = red, strokeWidth = 4.dp), + ).renderBitmap(0, 0, 0)!! + // 赤道は y = 128。幅 4 なら 126..129 + assertEquals(listOf(126, 127, 128, 129), paintedRows(bitmap, 128)) + assertEquals(0xFF0000, rgb(bitmap.getPixel(128, 127))) + // 端は丸く、線幅の半分だけ先まで: 経度 -90 度は x = 64、その 2 画素手前まで + assertTrue(alpha(bitmap.getPixel(63, 128)) > 0x80) + assertEquals(0, alpha(bitmap.getPixel(60, 128))) + // 塗らない(線の外は透明) + assertEquals(0, alpha(bitmap.getPixel(128, 100))) + } + + /** タイルの境目にある線は、両側のタイルに同じ行・太さで切れ目なく出る */ + @Test + fun lineOnATileEdgeIsDrawnOnBothSidesWithoutAGap() { + // z1: 赤道は y 方向のタイルの境目(世界 512 画素の y = 256) + val r = + renderer( + PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 90.0)), strokeColor = red, strokeWidth = 4.dp), + ) + val north = r.renderBitmap(0, 0, 1)!! + val south = r.renderBitmap(0, 1, 1)!! + // 経度 -45 度は z1 の x = 192 + assertEquals("北のタイルの下 2 行", listOf(254, 255), paintedRows(north, 192)) + assertEquals("南のタイルの上 2 行", listOf(0, 1), paintedRows(south, 192)) + // 東西のタイルの境目(経度 0 度)を横切っても、両側の端の列で同じ行 + val east = r.renderBitmap(1, 0, 1)!! + assertEquals(paintedRows(north, 255), paintedRows(east, 0)) + assertEquals(listOf(254, 255), paintedRows(east, 0)) + } + + /** 斜めの線もタイルの境目で切れない: 境目の両側の端の列で、塗った行がつながる */ + @Test + fun slantedLineContinuesAcrossTheTileEdge() { + val r = + renderer( + PolylineState(points = listOf(p(30.0, -20.0), p(10.0, 20.0)), strokeColor = blue, strokeWidth = 3.dp), + ) + // z2: 経度 0 度は x 方向のタイル 1 と 2 の境目 + val y = (worldY(20.0, 2) / 256).toInt() + val west = r.renderBitmap(1, y, 2)!! + val east = r.renderBitmap(2, y, 2)!! + val westRows = paintedRows(west, 255) + val eastRows = paintedRows(east, 0) + assertTrue(westRows.isNotEmpty() && eastRows.isNotEmpty()) + // 1 列ぶん右へ下がる線なので、重なるか 1 行ずれるだけ + assertTrue( + "$westRows / $eastRows", + westRows.first() <= eastRows.last() && eastRows.first() <= westRows.last() + 1, + ) + } + + /** ズームを変えても線幅(画素)は同じ */ + @Test + fun strokeKeepsItsWidthAcrossZooms() { + val r = + renderer( + PolylineState(points = listOf(p(20.0, -170.0), p(20.0, 170.0)), strokeColor = blue, strokeWidth = 4.dp), + ) + val widths = + (2..5).map { z -> + val y = (worldY(20.0, z) / 256).toInt() + val bitmap = r.renderBitmap(1, y, z)!! + paintedRows(bitmap, 128).size + } + assertEquals(listOf(4, 4, 4, 4), widths) + } + + /** 1 画素に満たない線は線幅の丸(点)になる */ + @Test + fun subPixelLineIsADotAsWideAsTheLine() { + val bitmap = + renderer( + PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 0.001)), strokeColor = green, strokeWidth = 4.dp), + ).renderBitmap(0, 0, 0)!! + var painted = 0 + for (py in 120..136) { + for (px in 120..136) { + val pixel = bitmap.getPixel(px, py) + if (alpha(pixel) > 0x80) { + painted++ + assertEquals(0x00FF00, rgb(pixel)) + } + } + } + // 直径 4 の丸: 12 画素前後 + assertTrue("painted=$painted", painted in 8..16) + } + + /** zIndex の大きい線が上、同じなら後に渡した線が上 */ + @Test + fun higherZIndexIsOnTop_thenLaterInTheList() = + runBlocking { + val set = PolylineTileSet() + try { + val over = + PolylineState(points = listOf(p(-10.0, 0.0), p(10.0, 0.0)), strokeColor = red, strokeWidth = 6.dp, zIndex = 1) + val under = + PolylineState(points = listOf(p(0.0, -10.0), p(0.0, 10.0)), strokeColor = blue, strokeWidth = 6.dp, zIndex = 0) + set.setPolylines(listOf(over, under)) + // The set draws at the device's density: the crossing is the bitmap's centre. + assertEquals("zIndex 1 が上", 0xFF0000, rgb(centre(set.renderer.renderBitmap(0, 0, 0)!!))) + + val first = + PolylineState(points = listOf(p(-10.0, 0.0), p(10.0, 0.0)), strokeColor = red, strokeWidth = 6.dp) + val second = + PolylineState(points = listOf(p(0.0, -10.0), p(0.0, 10.0)), strokeColor = green, strokeWidth = 6.dp) + set.setPolylines(listOf(first, second)) + assertEquals("後に渡した線が上", 0x00FF00, rgb(centre(set.renderer.renderBitmap(0, 0, 0)!!))) + } finally { + set.dispose() + } + } + + /** 透明な線・幅 0 の線は描かない。何も無いタイルは null(空) */ + @Test + fun invisibleLinesDrawNothing_andEmptyTilesAreNull() { + val r = + renderer( + PolylineState( + points = listOf(p(10.0, 10.0), p(20.0, 20.0)), strokeColor = Color.Transparent, + strokeWidth = + 4 + .dp, + ), + PolylineState(points = listOf(p(10.0, 10.0), p(20.0, 30.0)), strokeColor = red, strokeWidth = 0.dp), + ) + // 北東のタイルにはあるが、見えないものだけ + assertNull(r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + // 北西のタイルには何も無い + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + } + + /** 色を変えたら、その線のタイルだけ描き直す(他はキャッシュ) */ + @Test + fun inPlaceChangeRedrawsOnlyTheTilesItTouches() { + val west = + PolylineState( + points = listOf(p(10.0, -100.0), p(20.0, -90.0)), strokeColor = blue, id = "west", + strokeWidth = + 3 + .dp, + ) + val east = + PolylineState( + points = listOf(p(10.0, 90.0), p(20.0, 100.0)), strokeColor = blue, id = "east", + strokeWidth = + 3 + .dp, + ) + 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.strokeColor = green + r.update(listOf(0 to TilePolyline.from(west))) + + val westAfter = r.renderTile(westTile)!! + assertNotSame("変えた線のタイルは描き直す", westBefore, westAfter) + assertSame("触れていないタイルはキャッシュから返す", eastBefore, r.renderTile(eastTile)) + val decoded = BitmapFactory.decodeByteArray(westAfter, 0, westAfter.size) + // 線の中点(経度 -95 度、北緯 ~15 度)は z1 で x = 120 + val y = worldY(15.0, 1).toInt() + val mid = (y - 3..y + 3).map { decoded.getPixel(120, it) }.firstOrNull { alpha(it) > 0x80 } + assertNotNull(mid) + assertEquals(0x00FF00, rgb(mid!!)) + } + + /** 外した線(null)は、重なっていたタイルだけ描き直して消える */ + @Test + fun removedLineRedrawsOnlyItsTiles() { + val west = PolylineState(points = listOf(p(10.0, -100.0), p(20.0, -90.0)), strokeColor = blue, id = "west") + val east = PolylineState(points = listOf(p(10.0, 90.0), p(20.0, 100.0)), strokeColor = blue, id = "east") + val r = renderer(west, east) + assertNotNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + val eastBefore = r.renderTile(TileRequest(x = 1, y = 0, z = 1))!! + r.update(listOf(0 to null)) + assertNull(r.renderTile(TileRequest(x = 0, y = 0, z = 1))) + assertSame(eastBefore, r.renderTile(TileRequest(x = 1, y = 0, z = 1))) + } + + /** 太い線の色を変えたら、線幅ぶん隣のタイルにはみ出した所も描き直す */ + @Test + fun aChangeRedrawsTheNeighbourTheStrokeReachesInto() { + // z1 の北のタイルの下端に沿う太い線: 南のタイルの上端にもはみ出す + val line = + PolylineState(points = listOf(p(0.5, -90.0), p(0.5, -10.0)), strokeColor = blue, strokeWidth = 10.dp, id = "l") + val r = renderer(line) + val south = TileRequest(x = 0, y = 1, z = 1) + val before = r.renderTile(south)!! + line.strokeColor = red + r.update(listOf(0 to TilePolyline.from(line))) + val after = r.renderTile(south)!! + assertNotSame(before, after) + val decoded = BitmapFactory.decodeByteArray(after, 0, after.size) + assertEquals(0xFF0000, rgb(decoded.getPixel(192, 0))) + } + + /** 取りやめられたタイルは空ではなく失敗にする */ + @Test + fun cancelledTileFailsInsteadOfComingBackEmpty() { + val r = renderer(PolylineState(points = listOf(p(0.0, 0.0), p(10.0, 10.0)), strokeColor = blue)) + val thrown = runCatching { r.renderTile(TileRequest(x = 0, y = 0, z = 0)) { true } }.exceptionOrNull() + assertTrue(thrown is ShapeTileRenderer.TileRenderCancelled) + } +} diff --git a/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt new file mode 100644 index 0000000..894180b --- /dev/null +++ b/src/androidTest/java/com/mapconductor/core/polyline/PolylineTilerDeviceTest.kt @@ -0,0 +1,381 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapconductor.core.controller.BaseMapViewController +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.features.GeoRectBounds +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.tileserver.TileServerRegistry +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 +import kotlin.math.ln +import kotlin.math.sin +import android.graphics.BitmapFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * PolylineController と PolylineTiler の振り分け。PolygonTilerDeviceTest と同じ規則: + * タイルにした線は PolylineManager に残し(タップ用、元の点のまま)、レンダラには渡さない。 + * js-sdk-core の PolylineController(tiler)、ios-sdk-core の PolylineTilerTests と同じ内容。 + * + * PolylineTileSet が本物のタイルサーバーに経路を作るので、端末で動かす。 + */ +@RunWith(AndroidJUnit4::class) +class PolylineTilerDeviceTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + private val events = mutableListOf() + + /** 東西に 0.001 度の道を n 本、ボストンに並べる。 */ + private fun roads( + n: Int, + color: Color = Color(0xFF2563EB), + ): List = + List(n) { i -> + val lat = 42.30 + (i / 100) * 0.0012 + val lng = -71.10 + (i % 100) * 0.0012 + PolylineState( + points = listOf(p(lat, lng), p(lat, lng + 0.0005), p(lat, lng + 0.001)), + id = "road-$i", + strokeColor = color, + strokeWidth = 4.dp, + onAdded = { events += "onAdded:${it.id}" }, + ) + } + + private inner class Renderer : PolylineOverlayRendererInterface { + val onMap = mutableSetOf() + val added = mutableListOf() + val changed = mutableListOf() + val removed = mutableListOf() + + override suspend fun onAdd(data: List): List = + data.map { params -> + events += "native:${params.state.id}" + 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: PolylineOverlayRendererInterface, + ) : PolylineController(PolylineManager(), renderer) + + private class Templates { + val seen = mutableListOf() + + fun last() = synchronized(this) { seen.lastOrNull() } + } + + private fun controller( + options: PolylineTilingOptions = PolylineTilingOptions.Default, + ): Triple { + val renderer = Renderer() + val controller = Controller(renderer) + val templates = Templates() + controller.tiler = + PolylineTiler(options).apply { + onTemplate = { template, _ -> synchronized(templates) { templates.seen.add(template) } } + } + return Triple(controller, renderer, templates) + } + + /** z16 のボストン(タップの許容距離を画面の大きさで測るため)。 */ + private suspend fun Controller.atZoom16() = + onCameraChanged(MapCameraPosition(position = p(42.30, -71.10), zoom = 16.0)) + + /** しきい値を超えたらネイティブには何も渡さず、タップ用にマネージャへ残す */ + @Test + fun pastThresholdNothingGoesNative_andTapsStillHit() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.atZoom16() + controller.add(roads(2000)) + assertTrue(renderer.onMap.isEmpty()) + assertEquals(2000, controller.polylineManager.allEntities().size) + assertNotNull(templates.last()) + // タップ判定は元の線で: road-101 は北緯 42.3012、経度 -71.0988..-71.0978 + val hit = controller.findWithClosestPoint(p(42.30125, -71.0983)) + assertEquals("road-101", hit?.entity?.state?.id) + assertEquals(42.3012, hit!!.closestPoint.latitude, 1e-7) + // 道と道の間(南北に 0.0012 度 ≒ 133 m 離れている)の真ん中は外れ + assertNull(controller.find(p(42.3018, -71.0983))) + } + + /** しきい値を下回ったらネイティブに戻し、上回ったらネイティブから外す */ + @Test + fun crossingThresholdMovesPolylinesBetweenNativeAndTiles() = + runBlocking { + val (controller, renderer, templates) = controller() + controller.add(roads(1999)) + assertEquals(1999, renderer.onMap.size) + assertNull(templates.last()) + + controller.add(roads(2000)) + assertTrue("タイルへ移ったらネイティブから消す", renderer.onMap.isEmpty()) + assertEquals(2000, controller.polylineManager.allEntities().size) + assertNotNull(templates.last()) + + controller.add(roads(10)) + assertEquals(10, renderer.onMap.size) + assertEquals("リストから消えたものは忘れる", 10, controller.polylineManager.allEntities().size) + assertNull(templates.last()) + } + + /** タイルの線のその場の変更は、版を上げたテンプレートで知らせる */ + @Test + fun inPlaceChangeOfTiledPolylineAnnouncesNewTemplate() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = roads(2000) + controller.add(states) + val before = templates.last()!! + states[5].strokeColor = Color(0xFF16A34A) + controller.update(states[5]) + assertTrue(renderer.onMap.isEmpty()) + withTimeout(2000) { + while (templates.last() == before) delay(5) + } + assertTrue(templates.last()!! != before) + assertEquals( + Color(0xFF16A34A), + controller.polylineManager + .getEntity("road-5") + ?.state + ?.strokeColor, + ) + } + + /** タイルはタイルサーバーから PNG で届き、何も無い場所は透明の 200 */ + @Test + fun tilesAreServedAsPng_andEmptyPlacesAsTransparent200() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(roads(2000)) + val template = templates.last()!! + assertTrue("版が入る: $template", Regex("""/polylines-\w+/256/\d+/\{z\}""").containsMatchIn(template)) + assertTrue(alphaAt(template, 42.3012, -71.0983, 17) > 0) + assertEquals("道と道の間", 0, alphaAt(template, 42.3018, -71.0983, 17)) + val empty = get(template, 14, 100, 12000) + assertEquals(200, empty.first) + } + + /** clear でテンプレートを外し、タイル側の線も忘れる */ + @Test + fun clearRemovesTemplateAndForgetsTiledPolylines() = + runBlocking { + val (controller, _, templates) = controller() + controller.add(roads(2000)) + controller.clear() + assertNull(templates.last()) + assertTrue(controller.polylineManager.allEntities().isEmpty()) + assertTrue(TileServerRegistry.get().isRunning()) + } + + /** 混在リストでは geodesic だけネイティブ、タイルには描かれない */ + @Test + fun inAMixedListOnlyTheGeodesicOneGoesNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val g = + PolylineState( + points = listOf(p(42.2905, -71.10), p(42.2905, -71.099)), + id = "g", + strokeColor = Color(0xFFDC2626), + strokeWidth = 4.dp, + geodesic = true, + ) + controller.add(roads(2000) + g) + assertEquals(setOf("g"), renderer.onMap) + val template = templates.last()!! + assertEquals("タイルには描かない", 0, alphaAt(template, 42.2905, -71.0995, 17)) + assertTrue("道はタイルに描く", alphaAt(template, 42.3012, -71.0983, 17) > 0) + } + + /** その場で geodesic にしたらネイティブへ移り(add ではない)、タイルから消える。隣はタイルに残る */ + @Test + fun settingGeodesicInPlaceMovesItToNative() = + runBlocking { + val (controller, renderer, templates) = controller() + val states = roads(2001) + controller.add(states) + val before = templates.last()!! + assertTrue(alphaAt(before, 42.3012, -71.0983, 17) > 0) + + states[101].geodesic = true + controller.update(states[101]) + withTimeout(2000) { + while (templates.last() == before) delay(5) + } + assertEquals(listOf("road-101"), renderer.added) + assertEquals("road-101", controller.polylineManager.getEntity("road-101")?.polyline) + val after = templates.last()!! + assertEquals("タイルから消える", 0, alphaAt(after, 42.3012, -71.0983, 17)) + assertTrue("隣はタイルに残る", alphaAt(after, 42.3012, -71.0971, 17) > 0) + + // 同じリストを渡し直しても、ネイティブのまま(足しも消しもしない) + controller.add(states) + assertEquals(listOf("road-101"), renderer.added) + assertTrue(renderer.removed.isEmpty()) + } + + // ── onAdded と地図ごとの指定(PolylineOverlay 経由、本物の BaseMapViewController) ────── + + private class Driver( + val polylines: Controller, + ) : BaseMapViewController() { + override val holder get() = throw UnsupportedOperationException() + override val defaultCoroutine = CoroutineScope(Dispatchers.Unconfined) + override val mainCoroutine = defaultCoroutine + + init { + registerOverlayController(polylines) + } + + override fun moveCamera(position: MapCameraPosition) = Unit + + override fun animateCamera( + position: MapCameraPosition, + duration: Long, + ) = Unit + + override fun fitBounds( + bounds: GeoRectBounds, + padding: Int, + ) = Unit + + override suspend fun clearOverlays() = polylines.clear() + } + + private fun PolylineOverlay.show( + map: Driver, + states: List, + ) = runBlocking { render(states.associateBy { it.id }.toMutableMap(), map) } + + /** タイルで地図に載った線にも onAdded。その場の変更とネイティブへの移動では呼ばない */ + @Test + fun onAddedOnTheTilePath() { + // 4 本で 3 本以上ならタイル: 1 本がネイティブへ移っても、残りはタイルのまま + val (controller, renderer, _) = controller(PolylineTilingOptions(minPolylineCount = 3)) + val map = Driver(controller) + val overlay = PolylineOverlay(MutableStateFlow(mutableMapOf())) + val states = roads(4) + overlay.show(map, states) + assertEquals(listOf("onAdded:road-0", "onAdded:road-1", "onAdded:road-2", "onAdded:road-3"), events) + assertTrue(renderer.onMap.isEmpty()) + events.clear() + + states[0].strokeColor = Color.Red + runBlocking { map.updatePolyline(states[0]) } + states[1].geodesic = true + runBlocking { map.updatePolyline(states[1]) } + overlay.show(map, states) + assertEquals(listOf("native:road-1"), events) + controller.destroy() + } + + /** 地図ごとの指定(PolylineOverlay の tilingOptions)は、次に線を渡すときに効く */ + @Test + fun perMapOptionsApplyOnTheNextHandOver() { + val (controller, renderer, templates) = controller() + val map = Driver(controller) + val options = MutableStateFlow(PolylineTilingOptions.Disabled) + val overlay = PolylineOverlay(MutableStateFlow(mutableMapOf()), options) + val states = roads(2000) + + overlay.show(map, states) + assertEquals("Disabled: 全部ネイティブ", 2000, renderer.onMap.size) + assertNull(templates.last()) + + options.value = PolylineTilingOptions.Default + overlay.show(map, states) + assertTrue("Default: 全部タイル", renderer.onMap.isEmpty()) + assertNotNull(templates.last()) + assertEquals(PolylineTilingOptions.Default, controller.tiler?.options) + controller.destroy() + } + + /** 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 = sin(Math.toRadians(lat)) + val px = (lng + 180) / 360 * scale - x * 256 + val py = (0.5 - 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 = sin(Math.toRadians(lat)) + val y = ((0.5 - 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/controller/BaseMapViewController.kt b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt index 007a285..aaa2a7c 100644 --- a/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt +++ b/src/main/java/com/mapconductor/core/controller/BaseMapViewController.kt @@ -25,7 +25,9 @@ import com.mapconductor.core.polygon.PolygonCapableInterface import com.mapconductor.core.polygon.PolygonState import com.mapconductor.core.polyline.OnPolylineEventHandler import com.mapconductor.core.polyline.PolylineCapableInterface +import com.mapconductor.core.polyline.PolylineController import com.mapconductor.core.polyline.PolylineState +import com.mapconductor.core.polyline.PolylineTiler import com.mapconductor.core.raster.RasterLayerCapableInterface import com.mapconductor.core.raster.RasterLayerController import com.mapconductor.core.raster.RasterLayerState @@ -280,6 +282,10 @@ abstract class BaseMapViewController : .filterIsInstance>() .filter { it.kind == kind } + /** The tilers of this map's polyline controllers, for [com.mapconductor.core.polyline.PolylineOverlay]'s per-map options. */ + internal fun polylineTilers(): List = + overlayControllersOf(OverlayKind.Polyline).mapNotNull { (it as? PolylineController<*>)?.tiler } + /** * [kind] のいずれかのコントローラがこの id を持っているか。`hasXxx` の既定実装。 * diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt index 2dc9ae6..8d1b826 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileGeometry.kt @@ -2,6 +2,7 @@ package com.mapconductor.core.polygon import androidx.compose.ui.graphics.toArgb import com.mapconductor.core.features.GeoPointInterface +import com.mapconductor.core.shapetile.ShapeGridIndex import kotlin.math.PI import kotlin.math.abs import kotlin.math.floor @@ -136,11 +137,16 @@ internal fun reversedRing(ring: DoubleArray): DoubleArray { return out } -/** The polygon index: polygons by the grid cells their bounding box covers. */ +/** + * The polygon index: polygons by the grid cells their bounding box covers. + * The grid itself is [ShapeGridIndex], shared with the polyline tiles. + */ internal class PolygonGridIndex( private val cells: HashMap, private val large: IntArray, ) { + private val grid = ShapeGridIndex(cells, large) + /** * The polygons whose bounding box meets the given box, each once, in * ascending index order -- which is draw order. @@ -151,56 +157,13 @@ internal class PolygonGridIndex( 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() - } + ): IntArray = grid.query({ polygons[it]?.bbox }, minX, minY, maxX, maxY) 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()) + val grid = ShapeGridIndex.build(polygons.size) { polygons[it]?.bbox } + return PolygonGridIndex(grid.cells, grid.large) } - - private fun cellKey( - cx: Long, - cy: Long, - ): Long = cx * 1_048_576L + cy } } diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt index 5467497..a1eed80 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileRenderer.kt @@ -1,271 +1,126 @@ 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.InternalMapConductorApi import com.mapconductor.core.ResourceProvider -import com.mapconductor.core.tileserver.ScratchPool -import com.mapconductor.core.tileserver.TilePngEncoder +import com.mapconductor.core.shapetile.ShapeTileRenderer 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 +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path /** - * 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]. + * Draws a set of polygons into raster tiles: the counterpart of the polygon + * part of js-sdk-core's tile-sw.js "Shape tiles", served by + * [com.mapconductor.core.tileserver.LocalTileServer]. + * + * The tiles themselves (index, per-zoom simplification, cache, invalidation, + * scratch bitmaps, encoding, cancellation) are [ShapeTileRenderer]'s, shared + * with the polyline tiles; this is how a polygon is drawn in one: filled + * nonzero, the outline only stroked (never the holes), a polygon under + * [MIN_OUTLINED_PX] without its outline, and one under a pixel as the pixel. * * 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() }, + 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() + /** The shared renderer this one draws with; what the tile server is given. */ + internal val shapes: ShapeTileRenderer = + ShapeTileRenderer(tileSize, cacheSizeBytes, pixelScale, PolygonTilePainter) { TileRenderCancelled() } /** 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() - } - } + fun setPolygons(polygons: List) = shapes.setItems(polygons) /** * 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 update(updates: List>) = shapes.update(updates) - fun clear() { - model = null - synchronized(cacheLock) { - generation.incrementAndGet() - tileCache.evictAll() - } - } + fun clear() = shapes.clear() - override fun renderTile(request: TileRequest): ByteArray? = renderTile(request) { false } + override fun renderTile(request: TileRequest): ByteArray? = shapes.renderTile(request) 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 } + ): ByteArray? = shapes.renderTile(request, isCancelled) - 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]. */ + /** The tile as a bitmap, or null when nothing of any polygon is in it. For tests. */ 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) - } - } + ): Bitmap? = shapes.renderBitmap(x, y, z, pixelRatio) - 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 + /** 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") - 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 - } + companion object { + const val DEFAULT_CACHE_BYTES = ShapeTileRenderer.DEFAULT_CACHE_BYTES } +} + +/** How a polygon is drawn in a shape tile. */ +@InternalMapConductorApi +internal object PolygonTilePainter : ShapeTileRenderer.Painter { + override fun bbox(item: TilePolygon): DoubleArray = item.bbox - /** Draws into [bitmap]; null (and the bitmap back in the pool) when nothing was drawn. */ - private fun drawInto( - bitmap: Bitmap, - m: Model, - candidates: IntArray, + override fun strokeWidth(item: TilePolygon): Float = if (item.strokeVisible) item.strokeWidth else 0f + + override fun simplify( + item: TilePolygon, + scale: Double, + ): Any = simplifyPolygon(item, scale) + + override fun draw( + canvas: Canvas, + item: TilePolygon, + shape: Any, 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) + paints: ShapeTileRenderer.Paints, + ): Boolean { + val fillVisible = item.fillVisible 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) + when (shape) { + is TileShape.Dot -> { + if (!fillVisible && !item.strokeVisible) return false + paints.dot.color = if (fillVisible) item.fill else item.stroke + val left = ((shape.x - originX) * r).toFloat() + val top = ((shape.y - originY) * r).toFloat() + canvas.drawRect(left, top, left + r, top + r, paints.dot) + drew = true + } + is TileShape.Rings -> { + val path = paints.path + if (fillVisible) { + path.rewind() + path.fillType = Path.FillType.WINDING + for (ring in shape.rings) addRing(path, ring, originX, originY, r) + paints.fill.color = item.fill + canvas.drawPath(path, paints.fill) 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 (item.strokeVisible && shape.outlined) { + // Outline only, as the map SDKs draw it. + path.rewind() + addRing(path, shape.rings[0], originX, originY, r) + paints.stroke.color = item.stroke + paints.stroke.strokeWidth = item.strokeWidth * r + canvas.drawPath(path, paints.stroke) + drew = true } } } - if (!drew) releaseScratch(bitmap) - return if (drew) bitmap else null + return drew } private fun addRing( @@ -283,106 +138,4 @@ internal class PolygonTileRenderer( } 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 index e835325..15f8417 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTileSet.kt @@ -1,16 +1,11 @@ package com.mapconductor.core.polygon +import com.mapconductor.core.shapetile.ShapeTileSet 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 @@ -24,141 +19,67 @@ import kotlin.time.Duration.Companion.milliseconds * [onTemplateChanged]. Tiles the change did not touch are still cached by the * renderer and come back without being drawn again. * + * The route, its version and the coalescing of changes are + * [ShapeTileSet]'s, shared with [com.mapconductor.core.polyline.PolylineTileSet]. + * * 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), + server: LocalTileServer = TileServerRegistry.get(), + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) { - val routeId: String = "polygons-${UUID.randomUUID().toString().take(8)}" + internal val shapes: ShapeTileSet = + ShapeTileSet( + routePrefix = "polygons", + server = server, + scope = scope, + renderer = PolygonTileRenderer(TILE_SIZE).shapes, + idOf = { it.id }, + zIndexOf = { it.zIndex }, + toTile = TilePolygon::from, + ) + + val routeId: String get() = shapes.routeId /** Called with the new template after an [update]. */ - var onTemplateChanged: (suspend (String) -> Unit)? = null + var onTemplateChanged: (suspend (String) -> Unit)? + get() = shapes.onTemplateChanged + set(value) { + shapes.onTemplateChanged = value + } 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() - } + suspend fun setPolygons(states: List): String = shapes.setShapes(states) - fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + fun has(id: String): Boolean = shapes.has(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() - } - } + fun update(state: PolygonState) = shapes.update(state) /** * 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() - } - } + fun remove(id: String) = shapes.remove(id) - /** 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 template(): String = shapes.template() - 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() - } - } + fun dispose() = shapes.dispose() 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 diff --git a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt index 97ed549..4f6d185 100644 --- a/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt +++ b/src/main/java/com/mapconductor/core/polygon/PolygonTiler.kt @@ -2,8 +2,7 @@ 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 +import com.mapconductor.core.shapetile.ShapeTiler /** The id of the raster layer that shows tiled polygons. */ const val POLYGON_TILE_LAYER_ID: String = "mc-polygon-tiles" @@ -34,6 +33,8 @@ fun polygonTileRasterLayerState( * controller hands [polygonTileRasterLayerState] to its `upsert`, and * removes it with `removeById(POLYGON_TILE_LAYER_ID)`. * + * The routing is [ShapeTiler]'s, shared with [com.mapconductor.core.polyline.PolylineTiler]. + * * Same as js-sdk-core's and ios-sdk-core's `PolygonTiler`. */ class PolygonTiler( @@ -41,75 +42,39 @@ class PolygonTiler( ) { 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) } - } + private val tiler = + ShapeTiler( + tileSet = PolygonTileSet().shapes, + canTile = PolygonTileSet::canTile, + vertexCount = PolygonTileSet::vertexCount, + shouldUseTiles = { count, vertices -> options.shouldUseTiles(count, vertices) }, + idOf = { it.id }, + onTemplate = { template, tileSize -> onTemplate?.invoke(template, 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 - } + suspend fun route(data: List): Set = tiler.route(data) /** The states drawn as tiles, as of the last [route]. */ - fun tiledStates(): List = states + fun tiledStates(): List = tiler.tiledStates() - fun isTiled(id: String): Boolean = tiled.contains(id) + fun isTiled(id: String): Boolean = tiler.isTiled(id) /** An in-place change of a tiled polygon; only the tiles it touches are drawn again. */ - fun update(state: PolygonState) { - tileSet.update(state) - } + fun update(state: PolygonState) = tiler.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) - } + fun remove(id: String) = tiler.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() - } + fun dispose() = tiler.dispose() - suspend fun clear() { - tileSet.dispose() - tiled = emptySet() - states = emptyList() - onTemplate?.invoke(null, tileSet.tileSize) - } + suspend fun clear() = tiler.clear() } diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineController.kt b/src/main/java/com/mapconductor/core/polyline/PolylineController.kt index 0b585b6..761ba9f 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineController.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineController.kt @@ -6,8 +6,10 @@ import com.mapconductor.core.controller.OverlayKind import com.mapconductor.core.controller.SlottedOverlayController import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.map.MapCameraPosition +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext abstract class PolylineController( val polylineManager: PolylineManagerInterface, @@ -23,6 +25,15 @@ abstract class PolylineController( var clickListener: OnPolylineEventHandler? = null + /** + * Set by a provider that can show polyline tiles (see [PolylineTiler]). + * Polylines it tiles stay in [polylineManager], for taps, with no native + * overlay: their entity's `polyline` is null and the renderer never sees + * them. A renderer that redraws from the manager must skip those. + */ + var tiler: PolylineTiler? = null + private var tiledIds: Set = emptySet() + fun dispatchClick(event: PolylineEvent) { // 配送座標の wrap は PolylineEvent の生成時に一元化済み。 event.state.onClick?.invoke(event) @@ -31,13 +42,22 @@ abstract class PolylineController( 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 = polylineManager.allEntities().map { it.state.id }.toMutableSet() + // Only natively drawn polylines are diffed against the renderer. + val previous = + polylineManager + .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 = polylineManager.getEntity(state.id) ?: return@withPermit @@ -112,13 +132,64 @@ abstract class PolylineController( } } + // Tiled polylines: 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) polylineManager.removeEntity(id) + } + } + if (tiled.isNotEmpty()) { + val tiledStates = tiler!!.tiledStates() + withContext(Dispatchers.Default) { + for (state in tiledStates) { + polylineManager.registerEntity(PolylineEntity(polyline = inTiles(), state = state)) + } + } + } + renderer.onPostProcess() } } + /** What a tiled polyline's entity holds instead of a native polyline. */ + @Suppress("UNCHECKED_CAST") + private fun inTiles(): ActualPolyline = null as ActualPolyline + override suspend fun update(state: PolylineState) { semaphore.withPermit { val prevEntity = polylineManager.getEntity(state.id) ?: return + if (state.id in tiledIds) { + if (PolylineTileSet.canTile(state)) { + polylineManager.registerEntity(PolylineEntity(polyline = prevEntity.polyline, state = state)) + tiler?.update(state) + return + } + // No longer drawable in a tile (geodesic, half the world wide, or + // under 2 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 polylines = + renderer.onAdd( + listOf( + object : PolylineOverlayRendererInterface.AddParamsInterface { + override val state: PolylineState = state + }, + ), + ) + val polyline = polylines.firstOrNull() + if (polyline != null) { + polylineManager.registerEntity(PolylineEntity(polyline = polyline, state = state)) + } else { + polylineManager.removeEntity(state.id) + } + renderer.onPostProcess() + return + } val currentFinger = state.fingerPrint() val prevFinger = prevEntity.fingerPrint if (currentFinger == prevFinger) { @@ -152,7 +223,11 @@ abstract class PolylineController( override suspend fun clear() { semaphore.withPermit { - val entities: List> = polylineManager.allEntities() + val tiled = tiledIds + tiledIds = emptySet() + tiler?.clear() + val entities: List> = + polylineManager.allEntities().filter { it.state.id !in tiled } renderer.onRemove(entities) // manager を空にしてから onPostProcess を呼ぶ。GL 系の描画担当は remove では何もせず、 // onPostProcess で manager の全部を描き直すので、逆の順だと clear() 後も地図に残る。 @@ -172,6 +247,9 @@ abstract class PolylineController( } override fun destroy() { + // The tile server outlives the map; without this its route keeps every + // tiled polyline (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. polylineManager.clear() diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt b/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt index a973408..8bc15a5 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineManager.kt @@ -1,5 +1,6 @@ package com.mapconductor.core.polyline +import com.mapconductor.core.InternalMapConductorApi import com.mapconductor.core.ResourceProvider import com.mapconductor.core.features.GeoPointInterface import com.mapconductor.core.features.GeoRectBounds @@ -9,6 +10,7 @@ import com.mapconductor.core.spherical.WGS84Geodesic import com.mapconductor.core.spherical.calculateMetersPerPixel import com.mapconductor.settings.Settings import java.util.concurrent.ConcurrentHashMap +import kotlin.math.cos import android.util.Log data class PolylineHitResult( @@ -45,6 +47,9 @@ class PolylineManager : PolylineManagerInterface private const val DEBUG_FIND = false private const val TAG = "PolylineManagerInterface" + /** Meters per degree of latitude, rounded up: the box check may only ever let more through. */ + private const val METERS_PER_DEGREE = 111_000.0 + private fun d(msg: String) { if (DEBUG_FIND) Log.d(TAG, msg) } @@ -52,11 +57,34 @@ class PolylineManager : PolylineManagerInterface private val entities = ConcurrentHashMap>() + /** + * Each line's bounding box, made when it is registered (every change of a + * state registers it again). A tap first skips the lines whose box, grown + * by the tolerance, it is not in: a city of roads is 50,000 lines and + * 230,000 points, too many to measure on every tap. Null for a line the + * box would not hold (geodesic, or half the world wide); those are always + * measured. + */ + private val boxes = ConcurrentHashMap() + + @InternalMapConductorApi + private class LineBox( + val minLat: Double, + val maxLat: Double, + val minLng: Double, + val maxLng: Double, + ) + override fun registerEntity(entity: PolylineEntityInterface) { entities[entity.state.id] = entity + val box = boxOf(entity.state) + if (box != null) boxes[entity.state.id] = box else boxes.remove(entity.state.id) } - override fun removeEntity(id: String): PolylineEntityInterface? = entities.remove(id) + override fun removeEntity(id: String): PolylineEntityInterface? { + boxes.remove(id) + return entities.remove(id) + } override fun getEntity(id: String): PolylineEntityInterface? = entities[id] @@ -67,8 +95,18 @@ class PolylineManager : PolylineManagerInterface override fun clear() { entities.clear() + boxes.clear() } + /** + * The line nearest [position] that it is on, with the point on the line + * nearest it (the contract on all three platforms). + * + * A tap is on a line when it is within the finger's size + * ([Settings.tapTolerance]) of the line's edge: of its centre line plus + * half its [PolylineState.strokeWidth], both on screen at the camera's + * zoom. The same whether the line is drawn natively or in tiles. + */ override fun find( position: GeoPointInterface, cameraPosition: MapCameraPosition?, @@ -77,11 +115,21 @@ class PolylineManager : PolylineManagerInterface val candidates = mutableListOf, GeoPointInterface, Double>>() val fingerSize = ResourceProvider.dpToPx(Settings.Default.tapTolerance) val zoom = cameraPosition?.zoom ?: 0.0 - val threshold = calculateMetersPerPixel(position.latitude, zoom) * fingerSize + val metersPerPixel = calculateMetersPerPixel(position.latitude, zoom) + val lngScale = cos(Math.toRadians(position.latitude)).coerceAtLeast(0.01) + // dp → px as ResourceProvider.dpToPx does, without asking for every line. + val density = ResourceProvider.dpToPx(1.0) entities.values.forEach { entity -> // clickable=false のポリラインはタップに対して透過する(CircleManager と同じ方針)。 if (!entity.state.clickable) return@forEach + val threshold = metersPerPixel * (fingerSize + entity.state.strokeWidth.value * density / 2.0) + boxes[entity.state.id]?.let { box -> + val dLat = threshold / METERS_PER_DEGREE + val dLng = dLat / lngScale + if (position.latitude < box.minLat - dLat || position.latitude > box.maxLat + dLat) return@forEach + if (position.longitude < box.minLng - dLng || position.longitude > box.maxLng + dLng) return@forEach + } // 補間せず、元の線分を直接使う for (i in 0 until entity.state.points.size - 1) { val box = GeoRectBounds() @@ -113,7 +161,25 @@ class PolylineManager : PolylineManagerInterface val closest = candidates.minByOrNull { it.third } return closest?.let { (entity, closestPoint, distance) -> + d("hit ${entity.state.id} at $distance m") PolylineHitResult(entity = entity, closestPoint = closestPoint) } } + + private fun boxOf(state: PolylineState): LineBox? { + if (state.geodesic || state.points.isEmpty()) return null + var minLat = Double.POSITIVE_INFINITY + var maxLat = Double.NEGATIVE_INFINITY + var minLng = Double.POSITIVE_INFINITY + var maxLng = Double.NEGATIVE_INFINITY + for (p in state.points) { + if (p.latitude < minLat) minLat = p.latitude + if (p.latitude > maxLat) maxLat = p.latitude + if (p.longitude < minLng) minLng = p.longitude + if (p.longitude > maxLng) maxLng = p.longitude + } + // Across the antimeridian the box would be the rest of the world. + if (maxLng - minLng >= 180) return null + return LineBox(minLat, maxLat, minLng, maxLng) + } } diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt index 01203f7..0d5ee8d 100644 --- a/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt +++ b/src/main/java/com/mapconductor/core/polyline/PolylineOverlay.kt @@ -1,23 +1,36 @@ package com.mapconductor.core.polyline import com.mapconductor.core.OverlayAddedTracker +import com.mapconductor.core.controller.BaseMapViewController import com.mapconductor.core.controller.MapViewControllerInterface import com.mapconductor.core.map.MapOverlayInterface import kotlinx.coroutines.flow.StateFlow -class PolylineOverlay( - override val flow: StateFlow>, -) : MapOverlayInterface { - /** Calls [PolylineState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ - private val added = OverlayAddedTracker { it.onAdded } +class PolylineOverlay + @JvmOverloads + constructor( + override val flow: StateFlow>, + /** + * This map's polyline tiling, when the app sets one (null keeps the + * provider's [PolylineTilingOptions.Default]). Applied to the map's + * [PolylineTiler] before every hand-over, so a change takes effect the + * next time the polylines are handed over. + */ + private val tilingOptions: StateFlow? = null, + ) : MapOverlayInterface { + /** Calls [PolylineState.onAdded] once the map has the state. See [OverlayAddedTracker]. */ + private val added = OverlayAddedTracker { it.onAdded } - override suspend fun render( - data: MutableMap, - controller: MapViewControllerInterface, - ) { - val capable = controller as? PolylineCapableInterface ?: return - val states = data.values.toList() - capable.compositionPolylines(states) - added.synced(controller, states, capable::hasPolyline) + override suspend fun render( + data: MutableMap, + controller: MapViewControllerInterface, + ) { + val capable = controller as? PolylineCapableInterface ?: return + tilingOptions?.value?.let { options -> + (controller as? BaseMapViewController)?.polylineTilers()?.forEach { it.options = options } + } + val states = data.values.toList() + capable.compositionPolylines(states) + added.synced(controller, states, capable::hasPolyline) + } } -} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt new file mode 100644 index 0000000..d25aea3 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTileGeometry.kt @@ -0,0 +1,192 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.toArgb +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.polygon.DP_TOLERANCE_PX +import com.mapconductor.core.polygon.douglasPeucker +import com.mapconductor.core.polygon.isVisibleColor +import com.mapconductor.core.polygon.toMercatorRing +import com.mapconductor.core.shapetile.ShapeTileRenderer +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import android.graphics.Canvas + +// The geometry of polyline tiles: the same steps as the polyline part of the +// "Shape tiles" section of js-sdk-core's tile-sw.js and ios-sdk-core's +// PolylineTileGeometry, so the three platforms draw the same pixels from the +// same lines. +// +// A line becomes a Web Mercator path in [0, 1]. Each zoom gets its own +// simplified copy, built the first time a tile at that zoom needs it: points +// snapped to that zoom's pixel grid, repeats dropped, Douglas-Peucker at half +// a pixel with both ends kept. A line that collapses to one point (or whose +// box is under a pixel) is a dot as wide as the line. Taps never see any of +// this -- they are resolved against the original points in PolylineManager. + +/** + * One polyline as the tiles draw it. + * + * [path] is Web Mercator `[x0, y0, x1, y1, ...]` in [0, 1], open (never + * closed by the tiles). [strokeWidth] is in dp, the unit of + * [PolylineState.strokeWidth], which is also the tile unit: the line is as + * wide on screen at every zoom. + */ +@InternalMapConductorApi +internal class TilePolyline( + val path: DoubleArray, + /** minX, minY, maxX, maxY. */ + val bbox: DoubleArray, + val color: Int, + val strokeWidth: Float, +) { + val visible: Boolean get() = strokeWidth > 0f && isVisibleColor(color) + + @InternalMapConductorApi + companion object { + /** Null when the line has fewer than two points. */ + fun from(state: PolylineState): TilePolyline? { + if (state.points.size < 2) return null + val path = toMercatorRing(state.points) + 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 < path.size) { + minX = min(minX, path[i]) + maxX = max(maxX, path[i]) + minY = min(minY, path[i + 1]) + maxY = max(maxY, path[i + 1]) + i += 2 + } + return TilePolyline( + path = path, + bbox = doubleArrayOf(minX, minY, maxX, maxY), + color = state.strokeColor.toArgb(), + strokeWidth = state.strokeWidth.value, + ) + } + } +} + +/** What one polyline looks like at one zoom. */ +@InternalMapConductorApi +internal sealed class PolylineTileShape { + /** World-pixel points at the zoom, `[x0, y0, ...]`, two or more. */ + @InternalMapConductorApi + class Path( + val points: DoubleArray, + ) : PolylineTileShape() + + /** A line that is one point at this zoom, drawn as a dot as wide as the line, centred at [x], [y]. */ + @InternalMapConductorApi + class Dot( + val x: Double, + val y: Double, + ) : PolylineTileShape() +} + +/** + * The line as drawn at a zoom whose world is [scale] pixels wide: a path of + * world-pixel points, or a dot for a line that is one point there; never + * nothing. + */ +@InternalMapConductorApi +internal fun simplifyPolyline( + line: TilePolyline, + scale: Double, +): PolylineTileShape { + val w = (line.bbox[2] - line.bbox[0]) * scale + val h = (line.bbox[3] - line.bbox[1]) * scale + if (w < 1 && h < 1) { + return PolylineTileShape.Dot( + ((line.bbox[0] + line.bbox[2]) / 2) * scale, + ((line.bbox[1] + line.bbox[3]) / 2) * scale, + ) + } + val path = line.path + val snapped = DoubleArray(path.size) + var n = 0 + var px = Double.NaN + var py = Double.NaN + var i = 0 + while (i < path.size) { + val x = jsRound(path[i] * scale) + val y = jsRound(path[i + 1] * scale) + i += 2 + if (x == px && y == py) continue + snapped[n++] = x + snapped[n++] = y + px = x + py = y + } + if (n < 4) return PolylineTileShape.Dot(snapped[0], snapped[1]) + // Douglas-Peucker keeps both ends, so the line still starts and ends where it did. + return PolylineTileShape.Path(douglasPeucker(snapped, n / 2, DP_TOLERANCE_PX)) +} + +/** `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) + +/** Total points across [states]. */ +@InternalMapConductorApi +internal fun polylineVertexCountOf(states: List): Int { + var n = 0 + for (s in states) n += s.points.size + return n +} + +/** + * How a polyline is drawn in a shape tile: stroked, never filled, with round + * caps and joins (as the map SDKs' lines and the polygon tiles' outlines are), + * the stroke as wide in dp as [PolylineState.strokeWidth] at every zoom. + */ +@InternalMapConductorApi +internal object PolylineTilePainter : ShapeTileRenderer.Painter { + override fun bbox(item: TilePolyline): DoubleArray = item.bbox + + override fun strokeWidth(item: TilePolyline): Float = if (item.visible) item.strokeWidth else 0f + + override fun simplify( + item: TilePolyline, + scale: Double, + ): Any = simplifyPolyline(item, scale) + + override fun draw( + canvas: Canvas, + item: TilePolyline, + shape: Any, + originX: Double, + originY: Double, + r: Float, + paints: ShapeTileRenderer.Paints, + ): Boolean { + if (!item.visible) return false + when (shape) { + is PolylineTileShape.Dot -> { + paints.fill.color = item.color + val radius = max(item.strokeWidth * r, 1f) / 2f + val cx = ((shape.x - originX) * r).toFloat() + val cy = ((shape.y - originY) * r).toFloat() + canvas.drawCircle(cx, cy, radius, paints.fill) + } + is PolylineTileShape.Path -> { + val path = paints.path + path.rewind() + val p = shape.points + path.moveTo(((p[0] - originX) * r).toFloat(), ((p[1] - originY) * r).toFloat()) + var i = 2 + while (i < p.size) { + path.lineTo(((p[i] - originX) * r).toFloat(), ((p[i + 1] - originY) * r).toFloat()) + i += 2 + } + paints.stroke.color = item.color + paints.stroke.strokeWidth = item.strokeWidth * r + canvas.drawPath(path, paints.stroke) + } + else -> return false + } + return true + } +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt new file mode 100644 index 0000000..25296db --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTileSet.kt @@ -0,0 +1,103 @@ +package com.mapconductor.core.polyline + +import com.mapconductor.core.shapetile.ShapeTileRenderer +import com.mapconductor.core.shapetile.ShapeTileSet +import com.mapconductor.core.tileserver.LocalTileServer +import com.mapconductor.core.tileserver.TileServerRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +/** + * The polylines of one map that are drawn as raster tiles, and their route on + * the local tile server. + * + * Provider-independent: a provider decides which polylines to tile + * ([PolylineTilingOptions.shouldUseTiles], [canTile]), hands them to + * [setPolylines], 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. + * + * The same as [com.mapconductor.core.polygon.PolygonTileSet] for lines: both + * are [ShapeTileSet]s, with their own route, version and tile cache. Same + * shape as js-sdk-core's and ios-sdk-core's `PolylineTileSet`. + */ +class PolylineTileSet( + server: LocalTileServer = TileServerRegistry.get(), + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + internal val renderer: ShapeTileRenderer = + ShapeTileRenderer(tileSize = TILE_SIZE, painter = PolylineTilePainter) + + internal val shapes: ShapeTileSet = + ShapeTileSet( + routePrefix = "polylines", + server = server, + scope = scope, + renderer = renderer, + idOf = { it.id }, + zIndexOf = { it.zIndex }, + toTile = TilePolyline::from, + ) + + val routeId: String get() = shapes.routeId + + /** Called with the new template after an [update] or [remove]. */ + var onTemplateChanged: (suspend (String) -> Unit)? + get() = shapes.onTemplateChanged + set(value) { + shapes.onTemplateChanged = value + } + + val tileSize: Int get() = TILE_SIZE + + /** + * Registers [states] (replacing whatever was registered) and returns the + * URL template of their tiles. Draw order is zIndex, then the order given. + */ + suspend fun setPolylines(states: List): String = shapes.setShapes(states) + + fun has(id: String): Boolean = shapes.has(id) + + /** + * One polyline changed in place. A change of zIndex reorders the list and + * needs [setPolylines] instead; everything else is applied here. + */ + fun update(state: PolylineState) = shapes.update(state) + + /** + * Takes one polyline out of the tiles. Only the tiles it was in are drawn + * again. + */ + fun remove(id: String) = shapes.remove(id) + + fun template(): String = shapes.template() + + fun dispose() = shapes.dispose() + + companion object { + private const val TILE_SIZE = 256 + + /** + * Whether a polyline can be drawn in a tile. Geodesic lines are curves + * the tile does not trace, a line spanning half the world or more + * crosses the antimeridian, and a line needs two points; the map SDK + * draws the rest. + */ + fun canTile(state: PolylineState): Boolean { + if (state.geodesic || state.points.size < 2) 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 + } + + /** Points across [states]. */ + fun vertexCount(states: List): Int = polylineVertexCountOf(states) + } +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt new file mode 100644 index 0000000..3f289c6 --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTiler.kt @@ -0,0 +1,90 @@ +package com.mapconductor.core.polyline + +import com.mapconductor.core.raster.RasterLayerSource +import com.mapconductor.core.raster.RasterLayerState +import com.mapconductor.core.shapetile.ShapeTiler + +/** The id of the raster layer that shows tiled polylines. */ +const val POLYLINE_TILE_LAYER_ID: String = "mc-polyline-tiles" + +/** The raster layer state for tiled polylines at [template]. */ +fun polylineTileRasterLayerState( + template: String, + tileSize: Int = 256, +): RasterLayerState = + RasterLayerState( + id = POLYLINE_TILE_LAYER_ID, + source = RasterLayerSource.UrlTemplate(template = template, tileSize = tileSize), + ) + +/** + * Decides which of a map's polylines are drawn as tiles, keeps them + * registered with the local tile server, and tells the provider where the + * tiles are. Used exactly as [com.mapconductor.core.polygon.PolygonTiler]: + * + * 1. [route] with the full list on every composition. It returns the ids now + * in tiles; the controller keeps those in its [PolylineManager] (taps are + * resolved there, against the original lines) but gives the map SDK + * nothing for them, and removes any native polyline it had drawn for one. + * 2. [update] for an in-place change of a tiled polyline, or [remove] when the + * change means it can no longer be tiled (it became geodesic, say). + * 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 [polylineTileRasterLayerState] to its `upsert`, and + * removes it with `removeById(POLYLINE_TILE_LAYER_ID)`. The layer belongs + * to the polyline band: over circles, under native polylines and markers. + * + * [options] is read at every [route]: setting it decides again the next time + * the polylines are handed over. + * + * Same as js-sdk-core's and ios-sdk-core's `PolylineTiler`. + */ +class PolylineTiler( + options: PolylineTilingOptions = PolylineTilingOptions.Default, +) { + @Volatile + var options: PolylineTilingOptions = options + + var onTemplate: (suspend (template: String?, tileSize: Int) -> Unit)? = null + + private val tileSet = PolylineTileSet() + + private val tiler = + ShapeTiler( + tileSet = tileSet.shapes, + canTile = PolylineTileSet::canTile, + vertexCount = PolylineTileSet::vertexCount, + shouldUseTiles = { count, vertices -> this.options.shouldUseTiles(count, vertices) }, + idOf = { it.id }, + onTemplate = { template, tileSize -> onTemplate?.invoke(template, tileSize) }, + ) + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set = tiler.route(data) + + /** The states drawn as tiles, as of the last [route]. */ + fun tiledStates(): List = tiler.tiledStates() + + fun isTiled(id: String): Boolean = tiler.isTiled(id) + + /** An in-place change of a tiled polyline; only the tiles it touches are drawn again. */ + fun update(state: PolylineState) = tiler.update(state) + + /** + * Takes one polyline out of the tiles, for a change that left it no longer + * [PolylineTileSet.canTile]; only the tiles it was in are drawn again. The + * controller then hands it to the map SDK. + */ + fun remove(id: String) = tiler.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() = tiler.dispose() + + suspend fun clear() = tiler.clear() + + /** The tile set, for tests that look at the pixels. */ + internal val tiles: PolylineTileSet get() = tileSet +} diff --git a/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt b/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt new file mode 100644 index 0000000..53da4de --- /dev/null +++ b/src/main/java/com/mapconductor/core/polyline/PolylineTilingOptions.kt @@ -0,0 +1,32 @@ +package com.mapconductor.core.polyline + +/** + * Options for polyline tiling. + * + * When enabled and a map holds enough polylines, 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 lines in + * [PolylineManager], so tiling changes how polylines look, never what a tap + * hits. + * + * The counterpart of [com.mapconductor.core.polygon.PolygonTilingOptions], and + * the same options as js-sdk-core's and ios-sdk-core's `PolylineTilingOptions`. + */ +data class PolylineTilingOptions( + val enabled: Boolean = true, + /** Tile once a map holds at least this many polylines… */ + val minPolylineCount: Int = 2000, + /** …or at least this many points across them, whichever comes first. */ + val minVertexCount: Int = 50_000, +) { + /** The one place the threshold is decided; callers must not restate it. */ + fun shouldUseTiles( + polylineCount: Int, + vertexCount: Int, + ): Boolean = enabled && (polylineCount >= minPolylineCount || vertexCount >= minVertexCount) + + companion object { + val Default: PolylineTilingOptions = PolylineTilingOptions() + val Disabled: PolylineTilingOptions = PolylineTilingOptions(enabled = false) + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt new file mode 100644 index 0000000..c015e46 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeGridIndex.kt @@ -0,0 +1,90 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.polygon.IntList +import com.mapconductor.core.polygon.POLYGON_CELL +import com.mapconductor.core.polygon.POLYGON_MAX_CELLS +import kotlin.math.floor + +/** + * The index the shape tiles (polygons and polylines) draw from: shapes by the + * grid cells their bounding box covers. A cell is one z12 tile + * ([POLYGON_CELL]); a shape covering more than [POLYGON_MAX_CELLS] cells is kept + * in a list every query checks. + * + * A shape is its index in the renderer's list, and [bboxOf] gives its + * Web Mercator `minX, minY, maxX, maxY` (null for an empty slot). + */ +@InternalMapConductorApi +internal class ShapeGridIndex( + val cells: HashMap, + val large: IntArray, +) { + /** + * The shapes whose bounding box meets the given box, each once, in + * ascending index order -- which is draw order. + */ + inline fun query( + bboxOf: (Int) -> DoubleArray?, + minX: Double, + minY: Double, + maxX: Double, + maxY: Double, + ): IntArray { + val out = IntList() + for (i in large) { + val b = bboxOf(i) ?: continue + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(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) { + val inCell = cells[cellKey(cx, cy)] ?: continue + for (i in inCell) { + val b = bboxOf(i) ?: continue + if (b[0] <= maxX && b[2] >= minX && b[1] <= maxY && b[3] >= minY) out.add(i) + } + } + } + // A shape in several cells was taken once per cell. + return out.toSortedDistinctArray() + } + + @InternalMapConductorApi + companion object { + inline fun build( + count: Int, + bboxOf: (Int) -> DoubleArray?, + ): ShapeGridIndex { + val cells = HashMap() + val large = IntList() + for (i in 0 until count) { + val b = bboxOf(i) ?: continue + val cx0 = floor(b[0] / POLYGON_CELL).toLong() + val cx1 = floor(b[2] / POLYGON_CELL).toLong() + val cy0 = floor(b[1] / POLYGON_CELL).toLong() + val cy1 = floor(b[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 ShapeGridIndex(packed, large.toArray()) + } + + fun cellKey( + cx: Long, + cy: Long, + ): Long = cx * 1_048_576L + cy + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt new file mode 100644 index 0000000..9c99c50 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTileRenderer.kt @@ -0,0 +1,417 @@ +package com.mapconductor.core.shapetile + +import androidx.core.graphics.createBitmap +import com.mapconductor.core.InternalMapConductorApi +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 +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Path +import android.util.LruCache + +/** + * Draws a list of shapes into raster tiles, served by + * [com.mapconductor.core.tileserver.LocalTileServer]: the part the polygon + * tiles and the polyline tiles share. What a shape is and how it is drawn is + * the [Painter]'s; everything else is here -- the grid index, a simplified copy + * per zoom, the tile cache and its invalidation, the scratch bitmaps, PNG + * encoding and cancellation. The counterpart of the "Shape tiles" section of + * js-sdk-core's tile-sw.js and ios-sdk-core's `ShapeTileRenderer`. + * + * 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, and stays that wide at + * every zoom. + * + * 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, + * widened by the widest stroke; 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 list. + */ +@InternalMapConductorApi +internal class ShapeTileRenderer( + val tileSize: Int = 256, + cacheSizeBytes: Int = DEFAULT_CACHE_BYTES, + private val pixelScale: () -> Float = { ResourceProvider.getDensity() }, + private val painter: Painter, + /** What a cancelled tile throws: a failure the map may retry, never an empty tile. */ + private val cancelled: () -> Exception = { TileRenderCancelled() }, +) : TileProviderInterface { + /** What a kind of shape is to the tiles. */ + @InternalMapConductorApi + interface Painter { + /** Web Mercator `minX, minY, maxX, maxY`. */ + fun bbox(item: T): DoubleArray + + /** + * The widest the shape is drawn past its bounding box, as a stroke + * width in tile units (dp): half of it reaches past each side. 0 when + * nothing is drawn outside the box. + */ + fun strokeWidth(item: T): Float + + /** The shape at a zoom whose world is [scale] units wide. Cached per zoom. */ + fun simplify( + item: T, + scale: Double, + ): Any + + /** + * Draws [shape] (from [simplify]) with its origin at [originX], [originY] + * world units and [r] pixels per unit. True if anything was drawn. + */ + fun draw( + canvas: Canvas, + item: T, + shape: Any, + originX: Double, + originY: Double, + r: Float, + paints: Paints, + ): Boolean + } + + /** Paints and a path, one set per tile drawn. */ + @InternalMapConductorApi + class Paints { + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + val stroke = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeJoin = Paint.Join.ROUND + strokeCap = Paint.Cap.ROUND + } + + /** Not anti-aliased: a polygon under a pixel is one whole pixel. */ + val dot = Paint().apply { style = Paint.Style.FILL } + val path = Path() + } + + @InternalMapConductorApi + private class Simplified( + val source: Any, + val shape: Any, + ) + + /** + * What tiles are drawn from. [perZoom] is shared by the models an + * [update] derives from one [setItems]: an entry is used only while its + * source is still the item at that index, so a changed item is + * simplified again without clearing anything. + */ + @InternalMapConductorApi + private class Model( + val items: Array, + val bboxes: Array, + val index: ShapeGridIndex, + val maxStroke: 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 shape. Draw order is list order. */ + fun setItems(items: List) { + val array: Array = items.toTypedArray() + val bboxes = Array(array.size) { i -> bboxAt(array, i) } + val index = ShapeGridIndex.build(bboxes.size) { bboxes[it] } + model = Model(array, bboxes, index, maxStrokeOf(array), LinkedHashMap(8, 0.75f, true)) + synchronized(cacheLock) { + generation.incrementAndGet() + tileCache.evictAll() + } + } + + /** + * Replaces the shapes 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 items = current.items.copyOf() + val bboxes = current.bboxes.copyOf() + val touched = ArrayList(updates.size * 2) + var maxStroke = current.maxStroke + var reindex = false + for ((index, after) in updates) { + if (index !in items.indices) continue + val before = bboxes[index] + items[index] = after + val afterBox = after?.let(painter::bbox) + bboxes[index] = afterBox + before?.let(touched::add) + afterBox?.let(touched::add) + if (after != null) maxStroke = max(maxStroke, painter.strokeWidth(after)) + // A shape that moved needs the index rebuilt; a restyle does not. + if (before == null || afterBox == null || !before.contentEquals(afterBox)) reindex = true + } + val index = if (reindex) ShapeGridIndex.build(bboxes.size) { bboxes[it] } else current.index + model = Model(items, bboxes, 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 shapes 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 shape 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 + // A stroke reaches half its width past the shape: take the shapes that + // close to the tile too, and draw them whole (the canvas cuts them), so + // the tile next door draws the same line at the same place. + val pad = m.maxStroke / 2.0 + 1.0 + val bboxes = m.bboxes + val candidates = + m.index.query( + { bboxes[it] }, + (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. */ + @Suppress("UNCHECKED_CAST") + 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 paints = Paints() + val perZoom = perZoomOf(m, z) + var drew = false + for ((k, index) in candidates.withIndex()) { + if (k % CANCEL_CHECK_EVERY == 0 && isCancelled()) throw cancelled() + val item = (m.items[index] ?: continue) as T + val shape = simplifiedAt(perZoom, index, item, scale) + if (painter.draw(canvas, item, shape, originX, originY, r, paints)) drew = true + } + if (!drew) releaseScratch(bitmap) + return if (drew) bitmap else null + } + + 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.items.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, + item: T, + scale: Double, + ): Any { + val cached = perZoom.get(index) + if (cached != null && cached.source === item) return cached.shape + val shape = painter.simplify(item, scale) + perZoom.set(index, Simplified(item, 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() + + @Suppress("UNCHECKED_CAST") + private fun bboxAt( + items: Array, + i: Int, + ): DoubleArray? = (items[i] as T?)?.let(painter::bbox) + + @Suppress("UNCHECKED_CAST") + private fun maxStrokeOf(items: Array): Float { + var maxStroke = 0f + for (item in items) if (item != null) maxStroke = max(maxStroke, painter.strokeWidth(item as T)) + return maxStroke + } + + /** Thrown when the map stops wanting a tile mid-draw: a failure it may retry, never an empty tile. */ + @InternalMapConductorApi + class TileRenderCancelled : Exception("shape tile no longer wanted") + + @InternalMapConductorApi + 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 + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt new file mode 100644 index 0000000..29aa718 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTileSet.kt @@ -0,0 +1,166 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import com.mapconductor.core.tileserver.LocalTileServer +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The shapes of one map that are drawn as raster tiles, and their route on the + * local tile server: the part `PolygonTileSet` and `PolylineTileSet` share. + * Each of those adds what a state is as a tile ([toTile]) and which states can + * be tiled. + * + * [setShapes] registers the list and returns the URL template of its tiles; + * [update] and [remove] change one shape in place, coalesced over + * [UPDATE_COALESCE], and each batch 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. + * + * The same as js-sdk-core's and ios-sdk-core's `ShapeTileSet`. + */ +@InternalMapConductorApi +internal class ShapeTileSet( + routePrefix: String, + private val server: LocalTileServer, + private val scope: CoroutineScope, + private val renderer: ShapeTileRenderer, + private val idOf: (S) -> String, + private val zIndexOf: (S) -> Int, + private val toTile: (S) -> T?, +) { + val routeId: String = "$routePrefix-${UUID.randomUUID().toString().take(8)}" + + /** Called with the new template after an [update] or [remove]. */ + var onTemplateChanged: (suspend (String) -> Unit)? = null + + val tileSize: Int get() = renderer.tileSize + + 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 setShapes(states: List): String { + cancelPending() + val (shapes, ids) = + withContext(Dispatchers.Default) { + // Draw order is list order: zIndex, then the order given (sortedBy is stable). + val ordered = states.sortedBy(zIndexOf) + val ids = HashMap(ordered.size * 2) + ordered.forEachIndexed { index, state -> ids[idOf(state)] = index } + ordered.map(toTile) to ids + } + synchronized(lock) { + renderer.setItems(shapes) + if (!registered) server.register(routeId, renderer) + indexById = ids + registered = true + version++ + } + return template() + } + + fun has(id: String): Boolean = synchronized(lock) { indexById.containsKey(id) } + + /** + * One shape changed in place. A change of zIndex reorders the list and + * needs [setShapes] instead; everything else is applied here. + */ + fun update(state: S) { + synchronized(lock) { + val id = idOf(state) + if (!indexById.containsKey(id)) return + pending[id] = state + scheduleFlushLocked() + } + } + + /** + * Takes one shape 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() + } + } + } + + /** The URL template, with the version that changes on every change. */ + fun template(): String = server.urlTemplate(routeId, renderer.tileSize, 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[idOf(state)]?.let { index -> index to toTile(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() + } + } + + @InternalMapConductorApi + companion object { + /** In-place changes arriving within this window are drawn as one. */ + val UPDATE_COALESCE = 16.milliseconds + } +} diff --git a/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt b/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt new file mode 100644 index 0000000..a1d6049 --- /dev/null +++ b/src/main/java/com/mapconductor/core/shapetile/ShapeTiler.kt @@ -0,0 +1,82 @@ +package com.mapconductor.core.shapetile + +import com.mapconductor.core.InternalMapConductorApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Decides which of a map's shapes are drawn as tiles and keeps them registered + * in [tileSet]: the part `PolygonTiler` and `PolylineTiler` share. See + * `PolygonTiler` for how a provider's controller uses it. + * + * [shouldUseTiles] is read at every [route], so a tiler whose options change + * decides again on the next one. + */ +@InternalMapConductorApi +internal class ShapeTiler( + private val tileSet: ShapeTileSet, + private val canTile: (S) -> Boolean, + private val vertexCount: (List) -> Int, + private val shouldUseTiles: (count: Int, vertices: Int) -> Boolean, + private val idOf: (S) -> String, + private val onTemplate: suspend (template: String?, tileSize: Int) -> Unit, +) { + @Volatile + private var tiled: Set = emptySet() + + @Volatile + private var states: List = emptyList() + + init { + tileSet.onTemplateChanged = { template -> onTemplate(template, tileSet.tileSize) } + } + + /** Re-decides tiling for [data] and returns the ids drawn as tiles. */ + suspend fun route(data: List): Set { + // A city of shapes is a second of work; none of it belongs on the map's thread. + val (tileable, useTiles) = + withContext(Dispatchers.Default) { + val tileable = data.filter(canTile) + tileable to shouldUseTiles(tileable.size, vertexCount(tileable)) + } + val template = if (useTiles) tileSet.setShapes(tileable) else null + if (template == null) { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } else { + tiled = withContext(Dispatchers.Default) { tileable.mapTo(HashSet(tileable.size * 2), idOf) } + states = tileable + } + onTemplate(template, tileSet.tileSize) + return tiled + } + + fun tiledStates(): List = states + + fun isTiled(id: String): Boolean = tiled.contains(id) + + fun update(state: S) { + tileSet.update(state) + } + + fun remove(id: String) { + if (id !in tiled) return + tiled = tiled - id + states = states.filter { idOf(it) != id } + tileSet.remove(id) + } + + fun dispose() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + } + + suspend fun clear() { + tileSet.dispose() + tiled = emptySet() + states = emptyList() + onTemplate(null, tileSet.tileSize) + } +} diff --git a/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt index a3e21f8..fe6ac20 100644 --- a/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt +++ b/src/test/java/com/mapconductor/core/controller/OverlayStackingTest.kt @@ -107,6 +107,59 @@ class OverlayStackingTest { } } +/** + * SDK が描くタイルの置き場所。プロバイダはこの鍵(GL 系)と flatZIndex(Google など)で + * タイルのレイヤーを置く。ポリラインのタイルはポリラインの帯の一番下: ポリゴンのタイルと + * サークルの上、ネイティブのポリラインとマーカー(のタイル)の下。 + */ +class ShapeTileStackingTest { + private val polygonTile = OverlayStacking.Key(OverlayKind.Polygon, Int.MIN_VALUE) + private val polylineTile = OverlayStacking.Key(OverlayKind.Polyline, Int.MIN_VALUE) + private val markerTile = OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE) + + @Test + fun `ポリラインのタイルはサークルの上、ネイティブのポリラインとマーカーの下`() { + val keys = + listOf( + OverlayStacking.Key(OverlayKind.Marker, Int.MIN_VALUE + 1), + markerTile, + OverlayStacking.Key(OverlayKind.Polyline, -1_000_000), + polylineTile, + OverlayStacking.Key(OverlayKind.Circle, Int.MAX_VALUE), + OverlayStacking.Key(OverlayKind.Polygon, Int.MAX_VALUE), + polygonTile, + ) + assertEquals(keys.reversed(), keys.sorted()) + } + + @Test + fun `flatZIndex でも同じ順`() { + val polylineTileZ = OverlayStacking.flatZIndex(OverlayKind.Polyline, Int.MIN_VALUE) + assertTrue(polylineTileZ > OverlayStacking.flatZIndex(OverlayKind.Circle, Int.MAX_VALUE)) + assertTrue(polylineTileZ > OverlayStacking.flatZIndex(OverlayKind.Polygon, Int.MIN_VALUE)) + assertTrue(polylineTileZ < OverlayStacking.flatZIndex(OverlayKind.Polyline, -1_000)) + assertTrue(polylineTileZ < OverlayStacking.flatZIndex(OverlayKind.Marker, Int.MIN_VALUE)) + } + + @Test + fun `GL 系の層の並びでは、ポリゴンのタイルとサークルの上、ネイティブのポリラインの下へ入る`() { + val stack = OverlayLayerStack() + stack.put("raster-layer-mc-polygon-tiles", polygonTile) + stack.put("circle-layer", OverlayStacking.Key(OverlayKind.Circle)) + stack.put("polyline-layer", OverlayStacking.Key(OverlayKind.Polyline)) + stack.put("raster-layer-marker-tile-1", markerTile) + val layers = + listOf( + "water", + "raster-layer-mc-polygon-tiles", + "circle-layer", + "polyline-layer", + "raster-layer-marker-tile-1", + ) + assertEquals("polyline-layer", stack.beforeId(layers, polylineTile)) + } +} + class OverlayLayerStackTest { @Test fun `台帳に無い層は無視し、スタイルを読み直しても同じ位置を返す`() { diff --git a/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt b/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt new file mode 100644 index 0000000..c0289dc --- /dev/null +++ b/src/test/java/com/mapconductor/core/polyline/PolylineHitToleranceTest.kt @@ -0,0 +1,169 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.unit.dp +import com.mapconductor.core.features.GeoPoint +import com.mapconductor.core.map.MapCameraPosition +import com.mapconductor.core.spherical.Planar +import com.mapconductor.core.spherical.calculateMetersPerPixel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import kotlin.random.Random + +/** + * ポリラインのタップ判定。タイルで描いていてもネイティブで描いていても同じ。 + * + * 当たるのは「線の縁から指の大きさ(tapTolerance)以内」: 中心線から tapTolerance + 線幅 / 2 + * (どちらも画面上の大きさ)。複数当たれば一番近い線。clickable = false の線は素通り。 + * 5 万本を毎回測らないよう、外接矩形(許容ぶん広げる)に入らない線は先に飛ばす。飛ばしても結果は変わらない。 + * + * js-sdk-core の `PolylineHitTolerance.test.mjs`、ios-sdk の `PolylineHitToleranceTests` と 1 対 1 で対応する。 + * ユニットテストでは density = 1(ResourceProvider のフォールバック)、tapTolerance は 14 dp = 14 px。 + */ +class PolylineHitToleranceTest { + private val lat = 42.3550 + private val camera = MapCameraPosition(position = GeoPoint.fromLatLong(lat, -71.06), zoom = 16.0) + private val metersPerPixel = calculateMetersPerPixel(lat, 16.0) + + /** 北へ [meters] m。 */ + private fun north(meters: Double) = meters / 111_320.0 + + private fun line( + id: String, + offsetMeters: Double = 0.0, + width: Float = 1f, + clickable: Boolean = true, + ) = PolylineState( + points = listOf(GeoPoint(lat + north(offsetMeters), -71.0610), GeoPoint(lat + north(offsetMeters), -71.0590)), + id = id, + strokeWidth = width.dp, + clickable = clickable, + ) + + private fun managerWith(vararg states: PolylineState): PolylineManager = + PolylineManager().apply { + states.forEach { registerEntity(PolylineEntity(polyline = Unit, state = it)) } + } + + private fun tapNorth(meters: Double) = GeoPoint(lat + north(meters), -71.0600) + + @Test + fun `太い線は縁から指の大きさ以内なら当たり、細い線は同じ距離で外れる`() { + // z16 で約 1.77 m/px。細い線(1 px): 14.5 px ≒ 25.6 m、太い線(10 px): 19 px ≒ 33.5 m + val thirty = 30.0 + assertEquals( + "wide", + managerWith(line("wide", width = 10f)) + .find(tapNorth(thirty), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("thin", width = 1f)).find(tapNorth(thirty), camera)) + // 境目の内と外 + val edge = (14.0 + 5.0) * metersPerPixel + assertEquals( + "wide", + managerWith(line("wide", width = 10f)) + .find(tapNorth(edge - 1.0), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("wide", width = 10f)).find(tapNorth(edge + 1.0), camera)) + } + + @Test + fun `当たった点は線上の最近点`() { + val hit = managerWith(line("a", width = 4f)).find(tapNorth(10.0), camera)!! + assertEquals(lat, hit.closestPoint.latitude, 1e-7) + assertEquals(-71.0600, hit.closestPoint.longitude, 1e-7) + } + + @Test + fun `複数当たれば一番近い線`() { + val manager = managerWith(line("south", 0.0, width = 6f), line("north", 20.0, width = 2f)) + assertEquals( + "north", + manager + .find(tapNorth(15.0), camera) + ?.entity + ?.state + ?.id, + ) + assertEquals( + "south", + manager + .find(tapNorth(5.0), camera) + ?.entity + ?.state + ?.id, + ) + } + + @Test + fun `clickable = false の線は素通りして下の線に当たる`() { + val manager = managerWith(line("under", 0.0), line("over", 2.0, clickable = false)) + assertEquals( + "under", + manager + .find(tapNorth(2.0), camera) + ?.entity + ?.state + ?.id, + ) + assertNull(managerWith(line("over", 2.0, clickable = false)).find(tapNorth(2.0), camera)) + } + + @Test + fun `外接矩形で飛ばしても、全部測ったときと同じ線が当たる`() { + val random = Random(7) + val states = + List(300) { i -> + val lat0 = lat + random.nextDouble(-0.01, 0.01) + val lng0 = -71.06 + random.nextDouble(-0.01, 0.01) + val points = + List(2 + random.nextInt(4)) { + GeoPoint(lat0 + random.nextDouble(-0.001, 0.001), lng0 + random.nextDouble(-0.001, 0.001)) + } + PolylineState(points = points, id = "l$i", strokeWidth = random.nextInt(1, 8).dp) + } + val manager = managerWith(*states.toTypedArray()) + var hits = 0 + repeat(400) { + val tap = GeoPoint(lat + random.nextDouble(-0.011, 0.011), -71.06 + random.nextDouble(-0.011, 0.011)) + val expected = bruteForce(states, tap) + val actual = + manager + .find(tap, camera) + ?.entity + ?.state + ?.id + assertEquals(expected, actual) + if (actual != null) hits++ + } + // 当たりも外れも十分に含む + assert(hits in 40..360) { "hits=$hits" } + } + + /** 外接矩形を使わず、全部の線の全部の辺を測る。 */ + private fun bruteForce( + states: List, + tap: GeoPoint, + ): String? { + var best: String? = null + var bestDistance = Double.POSITIVE_INFINITY + val mpp = calculateMetersPerPixel(tap.latitude, 16.0) + for (s in states) { + val threshold = mpp * (14.0 + s.strokeWidth.value / 2.0) + for (i in 0 until s.points.size - 1) { + val hit = Planar.pointOnLineOrNull(s.points[i], s.points[i + 1], tap, threshold) ?: continue + if (hit.second < bestDistance) { + bestDistance = hit.second + best = s.id + } + } + } + return best + } +} diff --git a/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt b/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt new file mode 100644 index 0000000..35d5937 --- /dev/null +++ b/src/test/java/com/mapconductor/core/polyline/PolylineTilingTest.kt @@ -0,0 +1,144 @@ +package com.mapconductor.core.polyline + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +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 の `PolylineTiling.test.mjs`、ios-sdk の `PolylineTilingTests` と 1 対 1 で対応する。 + * 画素そのものは端末で見る(androidTest の `PolylineTileRendererDeviceTest`)。 + * + * - しきい値: 本数か点の数のどちらかが届けばタイル。判定はここ 1 か所。 + * - タイルにできないもの: geodesic、経度の幅 180 度以上、2 点未満。 + * - ズームごとの簡略化: 画素格子に丸め、続く重複を捨て、半画素の Douglas-Peucker(両端は残す)。 + * 1 点に潰れたら点。 + */ +class PolylineTilingTest { + private fun p( + latitude: Double, + longitude: Double, + ): GeoPointInterface = GeoPoint.fromLatLong(latitude, longitude) + + @Test + fun `本数か点の数のどちらかが届けばタイルにする`() { + val o = PolylineTilingOptions.Default + assertFalse(o.shouldUseTiles(1999, 49_999)) + assertTrue(o.shouldUseTiles(2000, 0)) + assertTrue(o.shouldUseTiles(10, 50_000)) + assertFalse(PolylineTilingOptions.Disabled.shouldUseTiles(100_000, 10_000_000)) + } + + @Test + fun `geodesic・経度の幅 180 度以上・2 点未満はタイルにしない`() { + assertTrue(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), geodesic = true))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, -100.0), p(0.0, 80.0))))) + assertTrue(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, -90.0), p(0.0, 89.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = listOf(p(0.0, 0.0))))) + assertFalse(PolylineTileSet.canTile(PolylineState(points = emptyList()))) + } + + @Test + fun `点の数は全ポリラインの合計`() { + val a = PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0), p(2.0, 2.0))) + val b = PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0))) + assertEquals(5, PolylineTileSet.vertexCount(listOf(a, b))) + } + + @Test + fun `タイル用の線は開いた経路のまま、外接矩形を持つ`() { + assertNull(TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0))))) + val line = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 90.0), p(0.0, 0.0))))!! + // 閉じない: 3 点のまま + assertEquals(6, line.path.size) + assertArrayEquals(doubleArrayOf(0.5, 0.5, 0.75, 0.5), line.bbox, 1e-12) + } + + @Test + fun `点は画素格子に丸め、続く重複と直線上の点を捨て、両端は残す`() { + // 世界 1000 画素。経度 0 度 = x 500 + val scale = 1000.0 + val line = + TilePolyline( + path = + doubleArrayOf( + 0.500, + 0.500, + 0.5004, + 0.5001, // (500, 500) に丸まる + 0.505, + 0.500, // 直線上 + 0.510, + 0.500, + 0.510, + 0.510, // 曲がる + ), + bbox = doubleArrayOf(0.5, 0.5, 0.51, 0.51), + color = 0xFF000000.toInt(), + strokeWidth = 2f, + ) + val shape = simplifyPolyline(line, scale) + assertTrue(shape is PolylineTileShape.Path) + assertArrayEquals( + doubleArrayOf(500.0, 500.0, 510.0, 500.0, 510.0, 510.0), + (shape as PolylineTileShape.Path) + .points, + 0.0, + ) + } + + @Test + fun `直線は両端の 2 点になる`() { + val line = TilePolyline.from(PolylineState(points = List(20) { i -> p(0.0, i * 0.5) }))!! + val shape = simplifyPolyline(line, 256.0 * 64) as PolylineTileShape.Path + assertEquals(4, shape.points.size) + } + + @Test + fun `1 画素に満たない線は点になる`() { + val line = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(0.0, 0.001))))!! + // z0: 世界が 256 画素。0.001 度は 0.0007 画素 + val shape = simplifyPolyline(line, 256.0) + assertTrue(shape is PolylineTileShape.Dot) + shape as PolylineTileShape.Dot + assertEquals(128.0, shape.x, 0.01) + assertEquals(128.0, shape.y, 0.01) + } + + @Test + fun `往復する短い線は、外接矩形が 1 画素以上なら経路、未満なら点`() { + val line = + TilePolyline( + path = doubleArrayOf(0.5, 0.5, 0.5012, 0.5, 0.5001, 0.5), + bbox = doubleArrayOf(0.5, 0.5, 0.5012, 0.5), + color = 0xFF000000.toInt(), + strokeWidth = 2f, + ) + // 世界 1000 画素: 500.0 → 500, 501.2 → 501, 500.1 → 500。2 画素なので経路 + assertTrue(simplifyPolyline(line, 1000.0) is PolylineTileShape.Path) + // 世界 800 画素: 外接矩形 0.96 px < 1 なので点 + assertTrue(simplifyPolyline(line, 800.0) is PolylineTileShape.Dot) + } + + @Test + fun `見えない線(透明・幅 0)は余白にも数えない`() { + val transparent = + TilePolyline + .from(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), strokeColor = Color.Transparent))!! + assertFalse(transparent.visible) + assertEquals(0f, PolylineTilePainter.strokeWidth(transparent)) + val visible = TilePolyline.from(PolylineState(points = listOf(p(0.0, 0.0), p(1.0, 1.0)), strokeWidth = 6.dp))!! + assertNotNull(visible) + assertEquals(6f, PolylineTilePainter.strokeWidth(visible)) + } +} diff --git a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt index 93d3f6d..7b880fc 100644 --- a/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt +++ b/src/test/java/com/mapconductor/core/raster/RasterLayerCoexistenceTest.kt @@ -1,6 +1,7 @@ package com.mapconductor.core.raster import com.mapconductor.core.polygon.polygonTileRasterLayerState +import com.mapconductor.core.polyline.polylineTileRasterLayerState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -8,8 +9,8 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * 1 枚の地図に載るラスターレイヤーは 3 系統ある。アプリの RasterLayer(add = composition)、 - * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)。 + * 1 枚の地図に載るラスターレイヤーは 4 系統ある。アプリの RasterLayer(add = composition)、 + * マーカータイル(marker-tile-*)、ポリゴンタイル(mc-polygon-tiles)、ポリラインタイル(mc-polyline-tiles)。 * * js-sdk-core の `RasterLayerCoexistence.test.mjs`、ios-sdk の `RasterLayerCoexistenceTests` と * 1 対 1 で対応する。 @@ -58,25 +59,31 @@ class RasterLayerCoexistenceTest { ) @Test - fun `アプリの RasterLayer・マーカータイル・ポリゴンタイルは互いを消さない`() = + 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(polylineTileRasterLayerState("http://127.0.0.1/tiles/l/256/1/{z}/{x}/{y}.png")) controller.upsert(markerTiles(1)) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "relief"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "relief"), onMap.sorted()) // アプリの RasterLayer が変わっても、SDK のタイルは残る controller.add(listOf(layer("photo"))) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) // マーカータイルの版が変わっても、ほかは残る controller.upsert(markerTiles(2)) - assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "photo"), onMap.sorted()) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) - // マーカータイルを外しても、ほかは残る + // ポリラインタイルの版が変わっても、ほかは残る + controller.upsert(polylineTileRasterLayerState("http://127.0.0.1/tiles/l/256/2/{z}/{x}/{y}.png")) + assertEquals(listOf("marker-tile-m", "mc-polygon-tiles", "mc-polyline-tiles", "photo"), onMap.sorted()) + + // マーカータイルとポリラインタイルを外しても、ほかは残る controller.removeById("marker-tile-m") + controller.removeById("mc-polyline-tiles") assertEquals(listOf("mc-polygon-tiles", "photo"), onMap.sorted()) } }