Skip to content

Commit b98138f

Browse files
committed
Precompute linestring label placements
1 parent dbd30eb commit b98138f

File tree

1 file changed

+104
-69
lines changed

1 file changed

+104
-69
lines changed

app/src/main/java/com/kylecorry/trail_sense/shared/map_layers/ui/layers/geojson/features/GeoJsonLineStringRenderer.kt

Lines changed: 104 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,18 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
4747
private var backgroundColor: Int? = null
4848
private var filterEpsilon = 0f
4949
private var reducedPaths = emptyList<PrecomputedLineString>()
50-
private var labelGrid = PrecomputedLabelGrid(emptyList(), emptyList())
5150
private val lock = Any()
5251

5352
init {
5453
setRunInBackgroundWhenChanged(this::renderFeaturesInBackground)
5554
}
5655

5756
private fun render(
58-
points: List<Coordinate>,
57+
pixels: List<PixelCoordinate>,
58+
originPixel: PixelCoordinate,
5959
path: Path,
60-
bounds: Rectangle,
61-
projection: IMapViewProjection
60+
bounds: Rectangle
6261
): FloatArray {
63-
val originPixel = projection.toPixels(projection.center)
64-
val pixels = points.map { projection.toPixels(it) }
6562
val segments = mutableListOf<Float>()
6663

6764
clipper.clip(pixels, bounds, segments, originPixel, rdpFilterEpsilon = filterEpsilon)
@@ -120,6 +117,8 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
120117
viewBounds.bottom - margin
121118
)
122119

120+
val originPixel = projection.toPixels(projection.center)
121+
123122
val precomputed = features.mapNotNull {
124123
val geometry = it.geometry as GeoJsonLineString
125124
val line = geometry.line
@@ -137,22 +136,35 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
137136
return@mapNotNull null
138137
}
139138

139+
val pixelPoints = coordinates.map { coordinate -> projection.toPixels(coordinate) }
140140
val path = pathPool.get()
141-
val lineSegments = render(coordinates, path, actualViewBounds, projection)
141+
val lineSegments = render(pixelPoints, originPixel, path, actualViewBounds)
142142
if (lineSegments.isEmpty()) {
143143
pathPool.release(path)
144144
return@mapNotNull null
145145
}
146146

147+
val name = it.getName()
148+
val labelSegments = if (shouldRenderLabels && name != null) {
149+
pixelPoints.windowed(2).map { segment ->
150+
val p1 = segment[0]
151+
val p2 = segment[1]
152+
LabelSegment(p1.midpoint(p2), p1.angleTo(p2))
153+
}
154+
} else {
155+
emptyList()
156+
}
157+
147158
PrecomputedLineString(
148159
it,
149160
coordinates,
150161
lineSegments,
151-
it.getName(),
162+
name,
152163
it.getColor() ?: Color.TRANSPARENT,
153164
it.getLineStyle() ?: LineStyle.Solid,
154165
(it.getStrokeWeight()
155166
?: DEFAULT_LINE_STRING_STROKE_WEIGHT_DP) / DEFAULT_LINE_STRING_STROKE_WEIGHT_DP,
167+
labelSegments,
156168
path,
157169
projection.center,
158170
projection.metersPerPixel
@@ -164,8 +176,7 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
164176
bounds.center.latitude
165177
)
166178

167-
// Only show labels at zoom level 13+
168-
if (zoomLevel >= 13) {
179+
val gridPoints = if (shouldRenderLabels && zoomLevel >= 13) {
169180
// Grid spacing based on zoom level (degrees)
170181
val resolution = when (zoomLevel) {
171182
13 -> 0.048
@@ -177,7 +188,6 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
177188
else -> 0.001
178189
}
179190

180-
// Grid
181191
val latitudes = Interpolation.getMultiplesBetween(
182192
bounds.south,
183193
bounds.north,
@@ -189,9 +199,23 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
189199
resolution
190200
)
191201

192-
labelGrid = PrecomputedLabelGrid(latitudes, longitudes)
202+
val points = mutableListOf<PixelCoordinate>()
203+
for (lat in latitudes) {
204+
for (lon in longitudes) {
205+
points.add(projection.toPixels(Coordinate(lat, lon)))
206+
}
207+
}
208+
points
193209
} else {
194-
labelGrid = PrecomputedLabelGrid(emptyList(), emptyList())
210+
emptyList()
211+
}
212+
213+
if (shouldRenderLabels && gridPoints.isNotEmpty()) {
214+
val canvasWidth = (viewBounds.right - viewBounds.left).absoluteValue
215+
val canvasHeight = (viewBounds.bottom - viewBounds.top).absoluteValue
216+
val minSeparationPx = max(canvasWidth, canvasHeight) / 4f
217+
val maxLabels = 5
218+
computeLabelPlacements(precomputed, gridPoints, minSeparationPx, maxLabels, originPixel)
195219
}
196220

197221
synchronized(lock) {
@@ -271,68 +295,29 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
271295
// TODO: Adjust text size / wrapping based on name length
272296
drawer.textSize(drawer.sp(10f * map.layerScale))
273297
val strokeWeight = drawer.dp(2.5f * map.layerScale)
274-
val minSeparationPx = max(drawer.canvas.width, drawer.canvas.height) / 4f
275298
val maxLabels = 5
276299

277-
// World-aligned grid for label selection
278-
val bounds = map.mapBounds
279-
val zoomLevel = TileMath.distancePerPixelToZoom(
280-
map.metersPerPixel.toDouble(),
281-
bounds.center.latitude
282-
)
283-
284-
// Only show labels at zoom level 13+
285-
if (zoomLevel < 13) return
286-
287-
// TODO: Precompute all of this
288-
val segmentCenters = ArrayList<PixelCoordinate>(path.points.size - 1)
289-
val segmentAngles = ArrayList<Float>(path.points.size - 1)
290-
for (i in 0 until (path.points.size - 1)) {
291-
val p1 = map.toPixel(path.points[i])
292-
val p2 = map.toPixel(path.points[i + 1])
293-
segmentCenters.add(p1.midpoint(p2))
294-
segmentAngles.add(p1.angleTo(p2))
295-
}
296-
297-
val chosenSegments = HashSet<Int>()
298-
val placedCenters = mutableListOf<PixelCoordinate>()
299-
300300
drawer.strokeWeight(strokeWeight)
301301
drawer.textMode(TextMode.Center)
302302
drawer.stroke(Color.WHITE)
303303
drawer.fill(Color.BLACK)
304304
drawer.noPathEffect()
305305

306-
val grid = labelGrid
306+
val placements = path.labelPlacements
307+
if (placements.isEmpty()) return
307308

309+
val centerPixel = map.toPixel(path.origin)
308310
var labelsDrawn = 0
309-
for (lat in grid.latitudes) {
311+
for (placement in placements) {
310312
if (labelsDrawn >= maxLabels) break
311-
for (lon in grid.longitudes) {
312-
if (labelsDrawn >= maxLabels) break
313-
val gridPixel = map.toPixel(Coordinate(lat, lon))
314-
315-
// Find nearest segment center to this grid point
316-
val closestIndex =
317-
SolMath.argmin(segmentCenters.map { it.distanceTo(gridPixel) })
318-
319-
if (closestIndex >= 0) {
320-
val center = segmentCenters[closestIndex]
321-
if (chosenSegments.contains(closestIndex)) continue
322-
if (placedCenters.any { it.distanceTo(center) < minSeparationPx }) continue
323-
324-
chosenSegments.add(closestIndex)
325-
placedCenters.add(center)
326-
val angle = segmentAngles[closestIndex]
327-
val drawAngle = if (angle.absoluteValue > 90) angle + 180 else angle
328-
329-
drawer.push()
330-
drawer.rotate(drawAngle, center.x, center.y)
331-
drawer.text(path.name, center.x, center.y)
332-
drawer.pop()
333-
labelsDrawn++
334-
}
335-
}
313+
val center = placement.center + centerPixel
314+
val drawAngle = placement.angle
315+
316+
drawer.push()
317+
drawer.rotate(drawAngle, center.x, center.y)
318+
drawer.text(path.name, center.x, center.y)
319+
drawer.pop()
320+
labelsDrawn++
336321
}
337322
}
338323
}
@@ -343,7 +328,7 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
343328
(this.y + other.y) / 2
344329
)
345330
}
346-
331+
347332
private fun PixelCoordinate.angleTo(other: PixelCoordinate): Float {
348333
return atan2(
349334
other.y - this.y,
@@ -359,14 +344,64 @@ class GeoJsonLineStringRenderer : FeatureRenderer() {
359344
val color: Int,
360345
val lineStyle: LineStyle,
361346
val thicknessScale: Float,
347+
var labelSegments: List<LabelSegment>,
362348
val path: Path,
363349
val origin: Coordinate,
364-
val renderedScale: Float
350+
val renderedScale: Float,
351+
var labelPlacements: List<LabelPlacement> = emptyList()
352+
)
353+
class LabelSegment(
354+
val center: PixelCoordinate,
355+
val angle: Float
365356
)
366357

367-
class PrecomputedLabelGrid(
368-
val latitudes: List<Double>,
369-
val longitudes: List<Double>,
358+
class LabelPlacement(
359+
val center: PixelCoordinate,
360+
val angle: Float
370361
)
371362

363+
private fun computeLabelPlacements(
364+
paths: List<PrecomputedLineString>,
365+
gridPoints: List<PixelCoordinate>,
366+
minSeparationPx: Float,
367+
maxLabels: Int,
368+
originPixel: PixelCoordinate
369+
) {
370+
for (path in paths) {
371+
val segments = path.labelSegments
372+
if (segments.isEmpty()) {
373+
path.labelPlacements = emptyList()
374+
continue
375+
}
376+
377+
val placements = mutableListOf<LabelPlacement>()
378+
val chosenSegments = HashSet<Int>()
379+
val placedCenters = mutableListOf<PixelCoordinate>()
380+
381+
for (gridPixel in gridPoints) {
382+
if (placements.size >= maxLabels) break
383+
384+
val closestIndex =
385+
SolMath.argmin(segments.map { it.center.distanceTo(gridPixel) })
386+
387+
if (closestIndex >= 0) {
388+
val center = segments[closestIndex].center - originPixel
389+
if (chosenSegments.contains(closestIndex)) continue
390+
if (placedCenters.any { it.distanceTo(center) < minSeparationPx }) continue
391+
392+
chosenSegments.add(closestIndex)
393+
placedCenters.add(center)
394+
395+
val angle = segments[closestIndex].angle
396+
val drawAngle = if (angle.absoluteValue > 90) angle + 180 else angle
397+
398+
placements.add(LabelPlacement(center, drawAngle))
399+
}
400+
}
401+
402+
path.labelPlacements = placements
403+
path.labelSegments = emptyList()
404+
}
405+
}
406+
372407
}

0 commit comments

Comments
 (0)