Migrate JS from SVG to BMAP

This commit is contained in:
2026-09-06 19:40:50 +02:00
parent a9c8a89aac
commit 02b92308d0
13 changed files with 257 additions and 696 deletions
+232
View File
@@ -0,0 +1,232 @@
#!/bin/node
/**
* Packs the generated map data into the compact binary the app reads.
*
* The app used to ship SVG, which spends about four megabytes per projection
* spelling coordinates out in decimal ASCII inside XML, and needed the map
* parsed as a document at startup. Nothing needs a document: the app wants a
* list of places and the outlines that belong to them, which is what this
* writes.
*
* Input is mapshaper's projected GeoJSON, where each feature carries:
* CODE the place it belongs to, e.g. "FRA" or "FRA_CE"
* LEVEL 0 for a country outline, 1 for one of its regions
*
* Output format. All integers are LEB128 varints and coordinates are in grid
* steps, on a canvas MAP_WIDTH user units across:
*
* "BMAP" 0x02
* precision uvarint, grid steps per user unit
* width, height uvarint, in grid steps
* shapeCount uvarint
* per shape:
* codeLength, code uvarint + UTF-8 bytes
* flags 1 byte, bit 0 = is a region
* ringCount uvarint
* per ring:
* pointCount uvarint
* flags 1 byte, bit 0 = even-odd fill
* pointCount pairs of dx, dy zigzag varint, from the previous point,
* starting from the origin on each ring
*/
import { readFileSync, writeFileSync } from 'fs'
/** Canvas width in user units. Stroke widths in the app are relative to it. */
const MAP_WIDTH = 800
/** Blank edge left around the map, as mapshaper's own SVG output used to. */
const MARGIN = 1
/**
* Grid steps per user unit. Coordinates are snapped to this, so it sets how
* fine the map can be; it is written into the header rather than agreed by
* convention, so raising it only means regenerating the assets.
*/
const PRECISION = 20
function uvarint(bytes, value) {
while (value >= 0x80) {
bytes.push((value & 0x7f) | 0x80)
value >>>= 7
}
bytes.push(value)
}
function svarint(bytes, value) {
uvarint(bytes, value < 0 ? -value * 2 - 1 : value * 2)
}
/** Every polygon of a feature, outer rings and holes alike. */
function ringsOf(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return [geometry.coordinates]
if (geometry.type === 'MultiPolygon') return geometry.coordinates
return []
}
/**
* Groups features by the place they belong to. A country outline and its
* regions arrive as separate features; the code is what ties them together.
*/
function collect(features) {
const shapes = []
const byCode = new Map()
for (const feature of features) {
const code = feature.properties?.CODE
if (!code) continue
const polygons = ringsOf(feature.geometry)
if (polygons.length === 0) continue
let shape = byCode.get(code)
if (!shape) {
shape = { code, isState: feature.properties.LEVEL === 1, rings: [], seen: new Set() }
byCode.set(code, shape)
shapes.push(shape)
}
// A polygon with holes only reads correctly under an even-odd fill, which
// is the rule mapshaper's SVG output used to pick for the same reason.
const evenOdd = polygons.some((polygon) => polygon.length > 1)
for (const polygon of polygons) {
for (const ring of polygon) {
if (ring.length >= 3) shape.rings.push({ ring, evenOdd })
}
}
}
return shapes
}
/** Twice the signed area of a closed ring of `x, y` pairs. */
function shoelace(points) {
let sum = 0
const count = points.length / 2
for (let i = 0, j = count - 1; i < count; j = i++) {
sum += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1]
}
return sum
}
/**
* Snaps every ring to the output grid and throws away what that makes
* redundant: points that land on the one before them, and rings left too small
* to enclose anything. The simplification upstream works to about this
* precision, so a good share of the raw points say nothing once rounded.
*/
function quantize(shapes, canvas) {
const kept = []
for (const shape of shapes) {
const seen = new Set()
const rings = []
for (const { ring, evenOdd } of shape.rings) {
const points = []
let px = null
let py = null
for (const [rx, ry] of ring) {
const x = canvas.x(rx)
const y = canvas.y(ry)
if (x === px && y === py) continue
points.push(x, y)
px = x
py = y
}
if (points.length < 6) continue
// Coordinates are integers now, so any ring that still encloses area
// encloses at least half a grid square. One that comes out at exactly
// zero is a sliver the rounding flattened, and it would draw nothing.
if (shoelace(points) === 0) continue
// Antarctica arrives twice over, once per admin level, because its source
// carries no country code and both levels land on ATA. A ring counted
// twice cancels itself out in the even-odd test the app hit tests with,
// which would leave the shape impossible to tap.
const key = points.join(',')
if (seen.has(key)) continue
seen.add(key)
rings.push({ points, evenOdd })
}
if (rings.length > 0) kept.push({ code: shape.code, isState: shape.isState, rings })
}
// By code, which puts a country next to its own regions. They are neighbours
// on the map, so their coordinates are similar, and the compressor in the APK
// gets noticeably more out of the file for it.
return kept.sort((a, b) => (a.code < b.code ? -1 : a.code > b.code ? 1 : 0))
}
/** Projected metres in, user units on a MAP_WIDTH canvas out, y flipped. */
function fitToCanvas(shapes) {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const shape of shapes) {
for (const { ring } of shape.rings) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
}
const scale = (MAP_WIDTH - 2 * MARGIN) / (maxX - minX)
return {
width: MAP_WIDTH,
height: 2 * MARGIN + (maxY - minY) * scale,
x: (v) => Math.round((MARGIN + (v - minX) * scale) * PRECISION),
y: (v) => Math.round((MARGIN + (maxY - v) * scale) * PRECISION),
}
}
function pack(path) {
const geojson = JSON.parse(readFileSync(path, 'utf8'))
const collected = collect(geojson.features ?? [])
const canvas = fitToCanvas(collected)
const shapes = quantize(collected, canvas)
const bytes = [0x42, 0x4d, 0x41, 0x50, 0x02] // "BMAP" v2
uvarint(bytes, PRECISION)
uvarint(bytes, Math.round(canvas.width * PRECISION))
uvarint(bytes, Math.round(canvas.height * PRECISION))
uvarint(bytes, shapes.length)
let rings = 0
let points = 0
for (const shape of shapes) {
const code = Buffer.from(shape.code, 'utf8')
uvarint(bytes, code.length)
for (const byte of code) bytes.push(byte)
bytes.push(shape.isState ? 1 : 0)
uvarint(bytes, shape.rings.length)
for (const { points: ring, evenOdd } of shape.rings) {
uvarint(bytes, ring.length / 2)
bytes.push(evenOdd ? 1 : 0)
let px = 0
let py = 0
for (let i = 0; i < ring.length; i += 2) {
svarint(bytes, ring[i] - px)
svarint(bytes, ring[i + 1] - py)
px = ring[i]
py = ring[i + 1]
}
rings++
points += ring.length / 2
}
}
const out = path.replace(/^.*\//, './app/src/main/assets/').replace(/\.geojson$/, '.bmap')
writeFileSync(out, Buffer.from(bytes))
console.log(
`${out}: ${shapes.length} shapes, ${rings} rings, ${points} points, ` +
`${canvas.width}x${canvas.height.toFixed(1)}, ${bytes.length} bytes`,
)
}
const inputs = process.argv.slice(2)
if (inputs.length === 0) {
console.error('usage: node packmap.js <map>.geojson ...')
process.exit(1)
}
inputs.forEach(pack)