BMAP handling
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import java.io.InputStream
|
||||
|
||||
private const val VERSION = 2
|
||||
|
||||
/**
|
||||
* Reads the packed map assets produced by `packmap.js`.
|
||||
*
|
||||
* The maps used to ship as SVG, which spends around four megabytes per
|
||||
* projection spelling coordinates out in decimal ASCII inside XML. The packed
|
||||
* form says the same thing in a fraction of that, and reading it is a straight
|
||||
* scan over a byte array instead of an XML parse with a float scanner on top.
|
||||
*
|
||||
* The format is documented in `packmap.js`; the two have to agree.
|
||||
*/
|
||||
object MapReader {
|
||||
|
||||
fun read(input: InputStream): MapWorld {
|
||||
val reader = ByteReader(input.readBytes())
|
||||
reader.readHeader()
|
||||
val width = reader.unsigned() * reader.scale
|
||||
val height = reader.unsigned() * reader.scale
|
||||
val count = reader.unsigned()
|
||||
val shapes = ArrayList<MapShape>(count)
|
||||
repeat(count) { shapes.add(reader.shape()) }
|
||||
return MapWorld(width, height, shapes)
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteReader(private val data: ByteArray) {
|
||||
|
||||
private var pos = 0
|
||||
|
||||
/** One grid step in user units, read from the header. */
|
||||
var scale = 1f
|
||||
private set
|
||||
|
||||
fun readHeader() {
|
||||
require(
|
||||
data.size > 5 &&
|
||||
data[0] == 'B'.code.toByte() && data[1] == 'M'.code.toByte() &&
|
||||
data[2] == 'A'.code.toByte() && data[3] == 'P'.code.toByte()
|
||||
) { "not a packed map" }
|
||||
require(data[4].toInt() == VERSION) { "unsupported packed map version ${data[4]}" }
|
||||
pos = 5
|
||||
scale = 1f / unsigned()
|
||||
}
|
||||
|
||||
fun unsigned(): Int {
|
||||
var result = 0
|
||||
var shift = 0
|
||||
while (true) {
|
||||
val byte = data[pos++].toInt()
|
||||
result = result or ((byte and 0x7F) shl shift)
|
||||
if (byte and 0x80 == 0) return result
|
||||
shift += 7
|
||||
}
|
||||
}
|
||||
|
||||
/** Zigzag, because deltas run both ways and are almost always tiny. */
|
||||
fun signed(): Int {
|
||||
val value = unsigned()
|
||||
return if (value and 1 == 1) -((value ushr 1) + 1) else value ushr 1
|
||||
}
|
||||
|
||||
fun flag(): Boolean = data[pos++].toInt() != 0
|
||||
|
||||
fun text(): String {
|
||||
val length = unsigned()
|
||||
val value = String(data, pos, length, Charsets.UTF_8)
|
||||
pos += length
|
||||
return value
|
||||
}
|
||||
|
||||
fun shape(): MapShape {
|
||||
val code = text()
|
||||
val isState = flag()
|
||||
val ringCount = unsigned()
|
||||
|
||||
val rings = ArrayList<FloatArray>(ringCount)
|
||||
var nonZero: Path? = null
|
||||
var evenOdd: Path? = null
|
||||
var minX = Float.MAX_VALUE
|
||||
var minY = Float.MAX_VALUE
|
||||
var maxX = -Float.MAX_VALUE
|
||||
var maxY = -Float.MAX_VALUE
|
||||
|
||||
repeat(ringCount) {
|
||||
val points = unsigned()
|
||||
val useEvenOdd = flag()
|
||||
val ring = FloatArray(points * 2)
|
||||
// Each point is a delta from the one before it, the first from the
|
||||
// origin, so the whole ring decodes in one pass.
|
||||
var x = 0
|
||||
var y = 0
|
||||
for (i in 0 until points) {
|
||||
x += signed()
|
||||
y += signed()
|
||||
val fx = x * scale
|
||||
val fy = y * scale
|
||||
ring[i * 2] = fx
|
||||
ring[i * 2 + 1] = fy
|
||||
if (fx < minX) minX = fx
|
||||
if (fx > maxX) maxX = fx
|
||||
if (fy < minY) minY = fy
|
||||
if (fy > maxY) maxY = fy
|
||||
}
|
||||
rings.add(ring)
|
||||
|
||||
val path = if (useEvenOdd) {
|
||||
evenOdd ?: Path().also { it.fillType = Path.FillType.EVEN_ODD; evenOdd = it }
|
||||
} else {
|
||||
nonZero ?: Path().also { nonZero = it }
|
||||
}
|
||||
path.moveTo(ring[0], ring[1])
|
||||
var i = 2
|
||||
while (i < ring.size) {
|
||||
path.lineTo(ring[i], ring[i + 1])
|
||||
i += 2
|
||||
}
|
||||
path.close()
|
||||
}
|
||||
|
||||
return MapShape(
|
||||
code, isState, rings,
|
||||
RectF(minX, minY, maxX, maxY),
|
||||
nonZero, evenOdd,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package net.helcel.beans.map
|
||||
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* One named piece of map geometry: a country outline or a single region.
|
||||
*
|
||||
* The geometry is kept twice on purpose. [nonZero] and [evenOdd] are what the
|
||||
* renderer hands to the canvas, while [rings] keeps the raw points because
|
||||
* hit testing needs the distance to an edge, which a [Path] cannot give us.
|
||||
*/
|
||||
class MapShape(
|
||||
val code: String,
|
||||
val isState: Boolean,
|
||||
val rings: List<FloatArray>,
|
||||
val bounds: RectF,
|
||||
val nonZero: Path?,
|
||||
val evenOdd: Path?,
|
||||
) {
|
||||
|
||||
/** True when ([x], [y]) falls inside the shape. Holes and lakes count as outside. */
|
||||
fun contains(x: Float, y: Float): Boolean {
|
||||
if (x < bounds.left || x > bounds.right || y < bounds.top || y > bounds.bottom) return false
|
||||
// Even-odd crossing count over every ring at once: rings never overlap, so
|
||||
// an island still reads as inside and a hole still reads as outside.
|
||||
var inside = false
|
||||
for (ring in rings) {
|
||||
var j = ring.size - 2
|
||||
var i = 0
|
||||
while (i < ring.size) {
|
||||
val yi = ring[i + 1]
|
||||
val yj = ring[j + 1]
|
||||
if ((yi > y) != (yj > y)) {
|
||||
val xi = ring[i]
|
||||
val xj = ring[j]
|
||||
if (x < xi + (y - yi) * (xj - xi) / (yj - yi)) inside = !inside
|
||||
}
|
||||
j = i
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/** Distance from ([x], [y]) to the shape, `0` when the point is inside it. */
|
||||
fun distanceTo(x: Float, y: Float): Float {
|
||||
if (contains(x, y)) return 0f
|
||||
var best = Float.MAX_VALUE
|
||||
for (ring in rings) {
|
||||
var j = ring.size - 2
|
||||
var i = 0
|
||||
while (i < ring.size) {
|
||||
val d = segmentDistanceSq(x, y, ring[j], ring[j + 1], ring[i], ring[i + 1])
|
||||
if (d < best) best = d
|
||||
j = i
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
return if (best == Float.MAX_VALUE) Float.MAX_VALUE else sqrt(best)
|
||||
}
|
||||
|
||||
private fun segmentDistanceSq(
|
||||
px: Float, py: Float,
|
||||
ax: Float, ay: Float,
|
||||
bx: Float, by: Float,
|
||||
): Float {
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val len = dx * dx + dy * dy
|
||||
val t = if (len <= 0f) 0f else (((px - ax) * dx + (py - ay) * dy) / len).coerceIn(0f, 1f)
|
||||
val cx = px - (ax + t * dx)
|
||||
val cy = py - (ay + t * dy)
|
||||
return cx * cx + cy * cy
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole map, in SVG user units, as a flat set of named shapes. */
|
||||
class MapWorld(val width: Float, val height: Float, val shapes: List<MapShape>) {
|
||||
|
||||
val byCode: Map<String, MapShape> = shapes.associateBy { it.code }
|
||||
|
||||
/** Country outlines, keyed by ISO code. Drawn as borders when regions are on. */
|
||||
val countries: List<MapShape> = shapes.filter { !it.isState }
|
||||
}
|
||||
Reference in New Issue
Block a user