Compare commits
5
Commits
car-2.39.0
...
car-2.40.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b1adee2b | ||
|
|
90c2c75fc6 | ||
|
|
2d7b324899 | ||
|
|
09cbd21634 | ||
|
|
6be2ba5193 |
@@ -21,7 +21,7 @@ MG4 head unit (Android 9, AAOS, MT2712)
|
|||||||
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
|
||||||
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
|
||||||
app releases from the private forge, and serves the roads for the Grid's map from
|
app releases from the private forge, and serves the roads for the Grid's map from
|
||||||
OpenStreetMap (Overpass). It does **not** talk to ABRP. See `server/README.md`.
|
OpenStreetMap vector tiles (OpenFreeMap), fetched per tile on demand and cached on a volume. It does **not** talk to ABRP. See `server/README.md`.
|
||||||
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
|
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
|
||||||
dashboards, Traefik routes). See `k8s/README.md`.
|
dashboards, Traefik routes). See `k8s/README.md`.
|
||||||
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
|
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
|
||||||
|
|||||||
+4
-2
@@ -21,8 +21,10 @@ software. `LICENSE` names all of them; keep every line.
|
|||||||
## Map data
|
## Map data
|
||||||
|
|
||||||
The roads of the "Grid with map" style are © OpenStreetMap contributors, under the
|
The roads of the "Grid with map" style are © OpenStreetMap contributors, under the
|
||||||
[Open Database Licence](https://opendatacommons.org/licenses/odbl/); the style shows that line
|
[Open Database Licence](https://opendatacommons.org/licenses/odbl/), in vector tiles
|
||||||
whenever it draws them. The server fetches them from an Overpass API (`server/maps.go`).
|
© OpenMapTiles served by [OpenFreeMap](https://openfreemap.org). The style shows
|
||||||
|
"OpenFreeMap © OpenMapTiles · © OpenStreetMap contributors" whenever it draws them. The server
|
||||||
|
fetches the tiles (`server/maps.go`).
|
||||||
|
|
||||||
## The EVHardware library
|
## The EVHardware library
|
||||||
|
|
||||||
|
|||||||
@@ -82,14 +82,14 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
|
|||||||
|
|
||||||
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
The car sends live data to ABRP itself, with the ABRP credentials set on the Live data tab, so
|
||||||
ABRP and the server do not depend on each other. The server stores, relays the app's updates
|
ABRP and the server do not depend on each other. The server stores, relays the app's updates
|
||||||
from the forge and serves the roads for the Grid's map (from OpenStreetMap). The car authenticates
|
from the forge and serves the roads for the Grid's map (OpenStreetMap, through OpenFreeMap). The car authenticates
|
||||||
with the server using a token of its own, obtained by pairing with a phone (below).
|
with the server using a token of its own, obtained by pairing with a phone (below).
|
||||||
|
|
||||||
## The app
|
## The app
|
||||||
|
|
||||||
| Tab | What it shows |
|
| Tab | What it shows |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original"). Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches the roads from OpenStreetMap's Overpass API and keeps them, and the GPS runs for it only while that style is on screen colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Grid with map, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. Grid with map lays the roads round the car on the Grid's floor: the app asks the car's server for the map tile it is in (not its position), the server fetches only that tile from OpenFreeMap's OpenStreetMap vector tiles and keeps it, and the GPS runs for it only while that style is on screen. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
||||||
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
||||||
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
||||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||||
|
|||||||
@@ -138,6 +138,46 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-10-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2
|
||||||
|
"does not look right"; wanted the map over the whole height and fully top-down). The map is
|
||||||
|
the whole background in map mode: orthographic, heading up, 1.2 layout units per metre (about
|
||||||
|
400 m ahead, 125 m behind, 450 m to each side on the car's screen), the car 150 units above
|
||||||
|
the bottom edge; ground grid every 50 m fixed to the ground; roads from 250 m ahead fainter;
|
||||||
|
the camera, the sky and the floor are gone there. The speed moves to a panel under the gears
|
||||||
|
(120 units), and the battery wall, the clock, the gears and the disc get dark panels. The
|
||||||
|
plain Grid is unchanged. Emulator with the Berlin tiles, both layouts. Not tried on the car.
|
||||||
|
- 2026-10-27 — App 2.39.2 published (`car-2.39.2`).
|
||||||
|
- 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a
|
||||||
|
fish-eye; wanted it more top-down). The map has its own pinhole camera now: 45° down, 300 m
|
||||||
|
up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead
|
||||||
|
(scale there ~60 % of the car's, before ~1/20 at 900 m); the floor starts at half height in
|
||||||
|
map mode and the speed is 170 instead of 210 units; the floor's grid becomes a ground grid
|
||||||
|
(a line every 50 m, fixed to the ground, turning with the roads); roads from 250 m ahead
|
||||||
|
fainter; the power disc gets a dark backing over the map. Emulator with the Berlin tiles,
|
||||||
|
both layouts. Not tried on the car.
|
||||||
|
- 2026-10-26 — Server 2.8.0 image pushed and pinned in `k8s/server.yaml` by digest
|
||||||
|
`sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1` (checked with skopeo);
|
||||||
|
app 2.39.1 published (`car-2.39.1`). Not applied yet: the owner applies it.
|
||||||
|
- 2026-10-26 — App 2.39.1 and server 2.8.0: **the map's roads come from OpenFreeMap, on
|
||||||
|
demand.** In the car, server 2.7.0 logged `overpass answered 502`, `overpass could not be
|
||||||
|
reached`, and the owner saw 429s: the public Overpass limits per IP, and a tile takes it ~40 s
|
||||||
|
while the car gives up after 8 s (`ServerHttp.TIMEOUT_MS`) — the server then cancelled the
|
||||||
|
query, threw the work away, and the car's retries added to the load. Owner's choice: fetch
|
||||||
|
only the tiles the car asks for (a self-hosted Overpass would need a whole region imported).
|
||||||
|
Server: the zoom-14 OpenStreetMap vector tile (OpenMapTiles schema) from the TileJSON at
|
||||||
|
`MAP_TILES_URL` (default OpenFreeMap, free, no key, no limits; its template re-read every 6 h,
|
||||||
|
https only), decoded by our own minimal protobuf/MVT reader (`mvt.go`, the `transportation`
|
||||||
|
layer; classes motorway/trunk, primary/secondary, tertiary, minor), simplified as before. A
|
||||||
|
fetch runs to its end without the car (requests wait ≤ 6 s, then 503 with `Retry-After: 10`),
|
||||||
|
at most two at once, one per tile; tiles kept 30 days in `MAP_CACHE_DIR` (a 1 Gi volume
|
||||||
|
`mg4-server-maps`, pod `fsGroup` 65532) and the last 300 in memory; `OVERPASS_URL` is gone.
|
||||||
|
Tests with an encoded vector tile and a fake tile host (classes, split lines, scaled extent,
|
||||||
|
malformed tiles, gzip from the host, fetched once and kept across a restart, a slow fetch
|
||||||
|
finished for the next try, failures not cached and no tile in the log, http template refused,
|
||||||
|
config). Real: the 9 Berlin tiles in 0.7 s (9–15 KB each). App 2.39.1: "OpenFreeMap ©
|
||||||
|
OpenMapTiles · © OpenStreetMap contributors" on the map, a tile the server did not have yet is
|
||||||
|
asked for again after 15 s (was a minute). Emulator with those tiles: same roads as before.
|
||||||
|
Not tried: the deployed server and the car.
|
||||||
- 2026-10-26 — Server 2.7.0 image pushed (`git.stinnesbeck.com/nils/mg4-server:2.7.0`, Go 1.27.3,
|
- 2026-10-26 — Server 2.7.0 image pushed (`git.stinnesbeck.com/nils/mg4-server:2.7.0`, Go 1.27.3,
|
||||||
govulncheck clean) and pinned in `k8s/server.yaml` by digest
|
govulncheck clean) and pinned in `k8s/server.yaml` by digest
|
||||||
`sha256:e5da9452a9dbdfabe3340766cc51f18bb1418b2588ab534714550470abe1ce29` (checked with skopeo);
|
`sha256:e5da9452a9dbdfabe3340766cc51f18bb1418b2588ab534714550470abe1ce29` (checked with skopeo);
|
||||||
|
|||||||
@@ -165,11 +165,13 @@ films' other side.
|
|||||||
|
|
||||||
## Grid with map
|
## Grid with map
|
||||||
|
|
||||||
The Grid, with the roads round the car from OpenStreetMap laid on its floor as light lanes, in
|
The Grid over a map: the roads round the car from OpenStreetMap (through OpenFreeMap) fill the
|
||||||
the floor's perspective and turned with the direction you drive: motorways glow brightest, then
|
whole screen as light lanes, straight from above and turned with the direction you drive, about
|
||||||
main roads, then the smaller streets, fading towards the horizon. The car is the chevron on the
|
400 m ahead and 450 m to each side: motorways glow brightest, then main roads, then the smaller
|
||||||
|
streets, the far ones fainter, over a ground grid every 50 m that turns and slides with them.
|
||||||
|
The speed, the battery wall, the power disc and the clock stand on dark panels over the map. The car is the chevron on the
|
||||||
floor, and between GPS fixes it runs on with the speed. The roads come through the car's own
|
floor, and between GPS fixes it runs on with the speed. The roads come through the car's own
|
||||||
server, which fetches them from OpenStreetMap and keeps them (see the README); the car also keeps
|
server, which fetches only the map tiles you drive through and keeps them (see the README); the car also keeps
|
||||||
the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and
|
the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and
|
||||||
stays the plain grid. The light-cycle trail makes way for the map here; power stays on the disc.
|
stays the plain grid. The light-cycle trail makes way for the map here; power stays on the disc.
|
||||||
Shown with sample values over real roads in central Berlin.
|
Shown with sample values over real roads in central Berlin.
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 142
|
versionCode 145
|
||||||
versionName "2.39.0"
|
versionName "2.40.0"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import android.os.SystemClock;
|
|||||||
* the trail; the floor stands still when the speed is unread.
|
* the trail; the floor stands still when the speed is unread.
|
||||||
*
|
*
|
||||||
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
|
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
|
||||||
* on the floor in its perspective, heading up, the bigger ones glowing brighter, with the car
|
* under everything, heading up, the bigger ones glowing brighter, with the car as a light
|
||||||
* as a light marker a little way up the floor; between GPS fixes the car's position runs on
|
* marker low on the screen; between GPS fixes the car's position runs on
|
||||||
* with the speed. The floor's tiles are then 40 m, so they move with the roads, and the trail
|
* with the speed. The map is then the whole background, straight from above (about
|
||||||
* makes way for the map. Without a fix, a server or the roads, the plain floor with a line
|
* 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
|
||||||
|
* with the roads; the speed, the battery wall, the disc and the clock stand on dark panels over
|
||||||
|
* it, and the trail makes way for the map. Without a fix, a server or the roads, the plain floor with a line
|
||||||
* saying why.
|
* saying why.
|
||||||
*
|
*
|
||||||
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
|
||||||
@@ -40,10 +42,10 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private static final int WALL = 20, DISC = 60;
|
private static final int WALL = 20, DISC = 60;
|
||||||
private static final float TILE_M = 4f;
|
private static final float TILE_M = 4f;
|
||||||
/**
|
/**
|
||||||
* The map: metres per depth step of the floor, the car's depth (just above the read-outs),
|
* The map, straight from above: pixels (in layout units) per metre, the car's height above
|
||||||
* the nearest depth drawn, how far ahead, and the depth from which roads are drawn fainter.
|
* the bottom edge, roads from FADE_M ahead fainter, a ground-grid line every GRID_M.
|
||||||
*/
|
*/
|
||||||
private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f;
|
private static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f, FADE_M = 250f, GRID_M = 50f;
|
||||||
|
|
||||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
@@ -58,13 +60,12 @@ final class GridView extends ClusterCanvasView {
|
|||||||
private final int accent, accentLight;
|
private final int accent, accentLight;
|
||||||
/** Null for the plain Grid. */
|
/** Null for the plain Grid. */
|
||||||
private final MapRoads map;
|
private final MapRoads map;
|
||||||
/** Per road class, near and far (from {@link #FADE_DEPTH}). */
|
/** Per road class, near and far (from {@link #FADE_M} ahead). */
|
||||||
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
|
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
|
||||||
private float heading = Float.NaN;
|
private float heading = Float.NaN;
|
||||||
/** The pen while a road is added: the last point, and whether it continues from there. */
|
private final Path gridPath = new Path();
|
||||||
private float penX, penY;
|
/** This frame's map: pixels per metre and where the car is on screen. */
|
||||||
private boolean penDown;
|
private float mapPx, carSx, carSy;
|
||||||
private Path penPath;
|
|
||||||
|
|
||||||
private int main, light, dim, alert;
|
private int main, light, dim, alert;
|
||||||
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
|
||||||
@@ -107,16 +108,24 @@ final class GridView extends ClusterCanvasView {
|
|||||||
advance();
|
advance();
|
||||||
c.drawColor(0xFF000000);
|
c.drawColor(0xFF000000);
|
||||||
|
|
||||||
|
// With a map, the map is the whole background, seen from straight above, and the rest
|
||||||
|
// stands on dark panels over it.
|
||||||
|
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
|
||||||
float horizon = h * 0.55f;
|
float horizon = h * 0.55f;
|
||||||
drawSky(c, w, horizon);
|
if (mapShown) {
|
||||||
drawFloor(c, w, h, horizon, u);
|
drawMap(c, w, h, u);
|
||||||
boolean mapShown = map != null && drawMap(c, w, h, horizon, u);
|
} else {
|
||||||
|
drawSky(c, w, horizon);
|
||||||
|
drawFloor(c, w, h, horizon, u);
|
||||||
|
}
|
||||||
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
|
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
|
||||||
|
if (mapShown) drawPanels(c, w, horizon, u);
|
||||||
drawGears(c, w, u);
|
drawGears(c, w, u);
|
||||||
drawClock(c, w, u);
|
drawClock(c, w, u);
|
||||||
drawSpeed(c, w, horizon, u);
|
if (mapShown) drawMapSpeed(c, w, u);
|
||||||
|
else drawSpeed(c, w, horizon, u);
|
||||||
drawWall(c, horizon, u);
|
drawWall(c, horizon, u);
|
||||||
drawDisc(c, w, u);
|
drawDisc(c, w, mapShown, u);
|
||||||
if (!mapShown) drawTrail(c, w, h, u);
|
if (!mapShown) drawTrail(c, w, h, u);
|
||||||
drawReadouts(c, w, h, u);
|
drawReadouts(c, w, h, u);
|
||||||
if (mapShown) drawAttribution(c, w, h, u);
|
if (mapShown) drawAttribution(c, w, h, u);
|
||||||
@@ -132,8 +141,7 @@ final class GridView extends ClusterCanvasView {
|
|||||||
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
lastFrame = now;
|
lastFrame = now;
|
||||||
if (v.speedKmh != null && v.speedKmh > 0.5f) {
|
if (v.speedKmh != null && v.speedKmh > 0.5f) {
|
||||||
float tile = map != null ? MAP_UNIT_M : TILE_M;
|
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
|
||||||
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f;
|
|
||||||
}
|
}
|
||||||
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
|
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
|
||||||
}
|
}
|
||||||
@@ -172,11 +180,11 @@ final class GridView extends ClusterCanvasView {
|
|||||||
|
|
||||||
// ---------- Map ----------
|
// ---------- Map ----------
|
||||||
|
|
||||||
/** The roads round the car on the floor; false when there is nothing to draw. */
|
/** The roads round the car from straight above, heading up, over a ground grid. */
|
||||||
private boolean drawMap(Canvas c, float w, float h, float horizon, float u) {
|
private void drawMap(Canvas c, float w, float h, float u) {
|
||||||
MapRoads.Fix fix = map.fix();
|
MapRoads.Fix fix = map.fix();
|
||||||
java.util.Collection<RoadTile> tiles = map.tiles();
|
java.util.Collection<RoadTile> tiles = map.tiles();
|
||||||
if (fix == null || tiles.isEmpty()) return false;
|
if (fix == null) return;
|
||||||
// Heading up: ease round the shorter way; north up until the car has moved.
|
// Heading up: ease round the shorter way; north up until the car has moved.
|
||||||
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
|
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
|
||||||
if (Float.isNaN(heading)) heading = target;
|
if (Float.isNaN(heading)) heading = target;
|
||||||
@@ -192,13 +200,30 @@ final class GridView extends ClusterCanvasView {
|
|||||||
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
|
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
|
||||||
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
|
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
|
||||||
|
|
||||||
|
mapPx = MAP_SCALE * u;
|
||||||
|
carSx = w / 2f;
|
||||||
|
carSy = h - CAR_FROM_BOTTOM * u;
|
||||||
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
|
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
|
||||||
float lane = w * 0.09f, depth = h - horizon, vx = w / 2f, m = (float) mpu;
|
float m = (float) mpu;
|
||||||
double range = FAR_M / mpu;
|
// Everything on screen is within this many metres of the car.
|
||||||
|
float reach = (float) Math.hypot(w / 2f, carSy) / mapPx + GRID_M;
|
||||||
|
double range = reach / mpu;
|
||||||
|
|
||||||
|
// The ground grid, fixed to the ground: it turns and slides with the roads.
|
||||||
|
gridPath.reset();
|
||||||
|
double carEast = carX * mpu, carNorth = -carY * mpu;
|
||||||
|
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
|
||||||
|
int lines = (int) (reach / GRID_M) + 1;
|
||||||
|
for (int k = -lines; k <= lines; k++) {
|
||||||
|
float e = k * GRID_M - offE, n = k * GRID_M - offN;
|
||||||
|
gridLine(e, -reach, e, reach, sin, cos);
|
||||||
|
gridLine(-reach, n, reach, n, sin, cos);
|
||||||
|
}
|
||||||
|
|
||||||
for (Path p : roads) p.reset();
|
for (Path p : roads) p.reset();
|
||||||
for (RoadTile tile : tiles) {
|
for (RoadTile tile : tiles) {
|
||||||
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
|
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
|
||||||
if (ox > range || oy > range || ox + 2 * MapGeometry.EXTENT < -range || oy + 2 * MapGeometry.EXTENT < -range) {
|
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + MapGeometry.EXTENT < -range) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
float fx = (float) ox, fy = (float) oy;
|
float fx = (float) ox, fy = (float) oy;
|
||||||
@@ -207,83 +232,95 @@ final class GridView extends ClusterCanvasView {
|
|||||||
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
|
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
|
||||||
int cls = tile.classes[i];
|
int cls = tile.classes[i];
|
||||||
int[] pts = tile.points[i];
|
int[] pts = tile.points[i];
|
||||||
penDown = false;
|
|
||||||
float pa = 0f, pf = 0f;
|
float pa = 0f, pf = 0f;
|
||||||
|
Path prev = null;
|
||||||
for (int k = 0; k < pts.length; k += 2) {
|
for (int k = 0; k < pts.length; k += 2) {
|
||||||
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
|
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
|
||||||
float fwd = east * sin + north * cos, side = east * cos - north * sin;
|
float fwd = east * sin + north * cos, side = east * cos - north * sin;
|
||||||
if (k > 0) segment(cls, pa, pf, side, fwd, vx, horizon, lane, depth);
|
if (k > 0) {
|
||||||
|
Path p = roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)];
|
||||||
|
if (p != prev) p.moveTo(screenX(pa), screenY(pf));
|
||||||
|
p.lineTo(screenX(side), screenY(fwd));
|
||||||
|
prev = p;
|
||||||
|
}
|
||||||
pa = side;
|
pa = side;
|
||||||
pf = fwd;
|
pf = fwd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.save();
|
|
||||||
c.clipRect(0, horizon, w, h);
|
|
||||||
line.setStrokeJoin(Paint.Join.ROUND);
|
line.setStrokeJoin(Paint.Join.ROUND);
|
||||||
line.setStrokeCap(Paint.Cap.ROUND);
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
// Far roads thin and faint, so the horizon stays a line rather than a tangle.
|
line.setColor(AccentColor.withAlpha(main, 0x26));
|
||||||
|
line.setStrokeWidth(1f * u);
|
||||||
|
c.drawPath(gridPath, line);
|
||||||
|
// Far roads thinner and fainter.
|
||||||
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
|
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
|
||||||
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x30 : 0x50));
|
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x60 : 0x90));
|
||||||
line.setStrokeWidth((cls == 0 ? 1.6f : 1f) * u);
|
line.setStrokeWidth((cls == 0 ? 2.4f : cls == 3 ? 1.2f : 1.8f) * u);
|
||||||
c.drawPath(roads[cls * 2 + 1], line);
|
c.drawPath(roads[cls * 2 + 1], line);
|
||||||
}
|
}
|
||||||
line.setColor(AccentColor.withAlpha(main, 0x80));
|
line.setColor(AccentColor.withAlpha(main, 0xA0));
|
||||||
line.setStrokeWidth(1.4f * u);
|
line.setStrokeWidth(1.8f * u);
|
||||||
c.drawPath(roads[3 * 2], line);
|
c.drawPath(roads[3 * 2], line);
|
||||||
line.setColor(AccentColor.withAlpha(main, 0xC0));
|
line.setColor(AccentColor.withAlpha(main, 0xD8));
|
||||||
line.setStrokeWidth(2f * u);
|
line.setStrokeWidth(2.4f * u);
|
||||||
c.drawPath(roads[2 * 2], line);
|
c.drawPath(roads[2 * 2], line);
|
||||||
glow(c, roads[2], main, 2.4f * u);
|
glow(c, roads[2], main, 2.8f * u);
|
||||||
glow(c, roads[0], light, 3.2f * u);
|
glow(c, roads[0], light, 3.6f * u);
|
||||||
line.setStrokeJoin(Paint.Join.MITER);
|
line.setStrokeJoin(Paint.Join.MITER);
|
||||||
line.setStrokeCap(Paint.Cap.SQUARE);
|
line.setStrokeCap(Paint.Cap.SQUARE);
|
||||||
c.restore();
|
|
||||||
|
|
||||||
// The car: a light-cycle chevron pointing ahead.
|
// The car: a light-cycle chevron pointing ahead.
|
||||||
float cy = horizon + depth / CAR_DEPTH;
|
|
||||||
path.reset();
|
path.reset();
|
||||||
path.moveTo(vx, cy - 16f * u);
|
path.moveTo(carSx, carSy - 18f * u);
|
||||||
path.lineTo(vx + 11f * u, cy + 10f * u);
|
path.lineTo(carSx + 12f * u, carSy + 11f * u);
|
||||||
path.lineTo(vx, cy + 4f * u);
|
path.lineTo(carSx, carSy + 5f * u);
|
||||||
path.lineTo(vx - 11f * u, cy + 10f * u);
|
path.lineTo(carSx - 12f * u, carSy + 11f * u);
|
||||||
path.close();
|
path.close();
|
||||||
fill.setColor(light);
|
fill.setColor(light);
|
||||||
c.drawPath(path, fill);
|
c.drawPath(path, fill);
|
||||||
glow(c, path, light, 1.5f * u);
|
glow(c, path, light, 1.5f * u);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One stretch of road from (a0, f0) to (a1, f1) in metres (side, ahead), cut at the near edge. */
|
private float screenX(float side) {
|
||||||
private void segment(int cls, float a0, float f0, float a1, float f1, float vx, float horizon, float lane,
|
return carSx + side * mapPx;
|
||||||
float depth) {
|
}
|
||||||
float d0 = CAR_DEPTH + f0 / MAP_UNIT_M, d1 = CAR_DEPTH + f1 / MAP_UNIT_M;
|
|
||||||
Path p = roads[cls * 2 + (Math.min(d0, d1) > FADE_DEPTH ? 1 : 0)];
|
private float screenY(float ahead) {
|
||||||
if ((d0 < NEAR && d1 < NEAR) || (f0 > FAR_M && f1 > FAR_M)) {
|
return carSy - ahead * mapPx;
|
||||||
penDown = false;
|
}
|
||||||
return;
|
|
||||||
}
|
/** A grid line between two ground points given east and north of the car, in metres. */
|
||||||
boolean cut = false;
|
private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) {
|
||||||
if (d0 < NEAR || d1 < NEAR) {
|
gridPath.moveTo(screenX(e0 * cos - n0 * sin), screenY(e0 * sin + n0 * cos));
|
||||||
float t = (NEAR - d0) / (d1 - d0);
|
gridPath.lineTo(screenX(e1 * cos - n1 * sin), screenY(e1 * sin + n1 * cos));
|
||||||
float a = a0 + (a1 - a0) * t, d = NEAR;
|
}
|
||||||
if (d0 < NEAR) {
|
|
||||||
a0 = a;
|
/** Dark panels under the battery wall and the clock, so the map does not run through them. */
|
||||||
d0 = d;
|
private void drawPanels(Canvas c, float w, float horizon, float u) {
|
||||||
cut = true;
|
chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
|
||||||
} else {
|
fill.setColor(0xC8000000);
|
||||||
a1 = a;
|
c.drawPath(path, fill);
|
||||||
d1 = d;
|
chamfer(w - 210f * u, 12f * u, w - 12f * u, 82f * u, 10f * u);
|
||||||
}
|
c.drawPath(path, fill);
|
||||||
}
|
// Behind the gears as well.
|
||||||
float x0 = vx + a0 / MAP_UNIT_M * lane / d0, y0 = horizon + depth / d0;
|
chamfer(w / 2f - 150f * u, 12f * u, w / 2f + 150f * u, 72f * u, 10f * u);
|
||||||
float x1 = vx + a1 / MAP_UNIT_M * lane / d1, y1 = horizon + depth / d1;
|
c.drawPath(path, fill);
|
||||||
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
|
}
|
||||||
p.lineTo(x1, y1);
|
|
||||||
penPath = p;
|
/** The speed over the map: smaller, on a panel under the gears. */
|
||||||
penX = x1;
|
private void drawMapSpeed(Canvas c, float w, float u) {
|
||||||
penY = y1;
|
chamfer(w / 2f - 130f * u, 80f * u, w / 2f + 130f * u, 232f * u, 14f * u);
|
||||||
penDown = true;
|
fill.setColor(0xC8000000);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(dim);
|
||||||
|
line.setStrokeWidth(1.5f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
text.setTypeface(thin);
|
||||||
|
text.setTextSize(120f * u);
|
||||||
|
text.setTextAlign(Paint.Align.CENTER);
|
||||||
|
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 194f * u, light, u);
|
||||||
|
caption(c, "KM/H", w / 2f, 220f * u, u, Paint.Align.CENTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Why the map is not there, on the floor. */
|
/** Why the map is not there, on the floor. */
|
||||||
@@ -296,14 +333,14 @@ final class GridView extends ClusterCanvasView {
|
|||||||
caption(c, why, w / 2f, horizon + (h - horizon) * 0.2f, u, Paint.Align.CENTER);
|
caption(c, why, w / 2f, horizon + (h - horizon) * 0.2f, u, Paint.Align.CENTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** OpenStreetMap's licence asks for its name wherever its data is shown. */
|
/** The tiles' and the data's licences ask for their names wherever the map is shown. */
|
||||||
private void drawAttribution(Canvas c, float w, float h, float u) {
|
private void drawAttribution(Canvas c, float w, float h, float u) {
|
||||||
text.setTypeface(label);
|
text.setTypeface(label);
|
||||||
text.setTextSize(12f * u);
|
text.setTextSize(12f * u);
|
||||||
text.setTextAlign(Paint.Align.RIGHT);
|
text.setTextAlign(Paint.Align.RIGHT);
|
||||||
text.setStyle(Paint.Style.FILL);
|
text.setStyle(Paint.Style.FILL);
|
||||||
text.setColor(dim);
|
text.setColor(dim);
|
||||||
c.drawText("© OpenStreetMap contributors", w - 26f * u, h - 80f * u, text);
|
c.drawText("OpenFreeMap © OpenMapTiles · © OpenStreetMap contributors", w - 26f * u, h - 80f * u, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Top ----------
|
// ---------- Top ----------
|
||||||
@@ -393,8 +430,13 @@ final class GridView extends ClusterCanvasView {
|
|||||||
// ---------- Power disc ----------
|
// ---------- Power disc ----------
|
||||||
|
|
||||||
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
|
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
|
||||||
private void drawDisc(Canvas c, float w, float u) {
|
private void drawDisc(Canvas c, float w, boolean overMap, float u) {
|
||||||
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
|
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
|
||||||
|
if (overMap) {
|
||||||
|
// The map runs under the disc: a dark backing keeps it readable.
|
||||||
|
fill.setColor(0xD0000000);
|
||||||
|
c.drawCircle(cx, cy, outer + 16f * u, fill);
|
||||||
|
}
|
||||||
line.setColor(dim);
|
line.setColor(dim);
|
||||||
line.setStrokeWidth(1.5f * u);
|
line.setStrokeWidth(1.5f * u);
|
||||||
c.drawCircle(cx, cy, outer + 10f * u, line);
|
c.drawCircle(cx, cy, outer + 10f * u, line);
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ final class MapRoads {
|
|||||||
private static final String TAG = "MapRoads";
|
private static final String TAG = "MapRoads";
|
||||||
private static final int DISK_TILES = 400;
|
private static final int DISK_TILES = 400;
|
||||||
private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000;
|
private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000;
|
||||||
private static final long RETRY_MS = 60_000L;
|
/** A tile the server did not have yet (it goes on fetching) is asked for again after this. */
|
||||||
|
private static final long RETRY_MS = 15_000L;
|
||||||
/** A fix older than this is not drawn from. */
|
/** A fix older than this is not drawn from. */
|
||||||
static final long FIX_FRESH_MS = 15_000L;
|
static final long FIX_FRESH_MS = 15_000L;
|
||||||
|
|
||||||
|
|||||||
+32
-5
@@ -1,5 +1,21 @@
|
|||||||
# The car server (Go). One replica on purpose: pending pairing requests and the wrong-password
|
# The car server (Go). One replica on purpose: pending pairing requests and the wrong-password
|
||||||
# counters are kept in memory.
|
# counters are kept in memory.
|
||||||
|
# The roads of the app's Grid map, fetched on demand, are kept on this small volume.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mg4-server-maps
|
||||||
|
namespace: mg4
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: mg4-server
|
||||||
|
app.kubernetes.io/part-of: mg4
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
# No storageClassName: the cluster's default class is used.
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@@ -25,12 +41,13 @@ spec:
|
|||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 65532
|
runAsUser: 65532
|
||||||
runAsGroup: 65532
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
seccompProfile:
|
seccompProfile:
|
||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: server
|
- name: server
|
||||||
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs.
|
# Pinned by digest so a re-pushed tag cannot change what runs (version in SPECS.md).
|
||||||
image: git.stinnesbeck.com/nils/mg4-server@sha256:e5da9452a9dbdfabe3340766cc51f18bb1418b2588ab534714550470abe1ce29
|
image: git.stinnesbeck.com/nils/mg4-server@sha256:8b8d94e0562833d196162866b7fdc6910d5dd219f6eb84572d4d5c91b74f7ff1
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
containerPort: 8080
|
containerPort: 8080
|
||||||
@@ -50,9 +67,12 @@ spec:
|
|||||||
value: https://git.stinnesbeck.com
|
value: https://git.stinnesbeck.com
|
||||||
- name: FORGE_REPO
|
- name: FORGE_REPO
|
||||||
value: nils/mg4
|
value: nils/mg4
|
||||||
# The roads for the app's Grid map, from OpenStreetMap; "off" switches maps off.
|
# The roads for the app's Grid map: OpenStreetMap vector tiles, fetched only for the
|
||||||
- name: OVERPASS_URL
|
# tiles a car asks for and kept on the volume below; "off" switches maps off.
|
||||||
value: https://overpass-api.de/api/interpreter
|
- name: MAP_TILES_URL
|
||||||
|
value: https://tiles.openfreemap.org/planet
|
||||||
|
- name: MAP_CACHE_DIR
|
||||||
|
value: /var/cache/mg4-maps
|
||||||
- name: INFLUX_TOKEN
|
- name: INFLUX_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -84,6 +104,13 @@ spec:
|
|||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
capabilities:
|
capabilities:
|
||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: maps
|
||||||
|
mountPath: /var/cache/mg4-maps
|
||||||
|
volumes:
|
||||||
|
- name: maps
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: mg4-server-maps
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 105 KiB |
+18
-12
@@ -23,7 +23,8 @@ Environment only; the server refuses to start when a required value is missing.
|
|||||||
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||||||
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||||||
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
|
| `FORGE_URL`, `FORGE_REPO`, `FORGE_TOKEN` | optional; app updates (below). The token only needs to read the repository |
|
||||||
| `OVERPASS_URL` | the Overpass API the map's roads come from; default `https://overpass-api.de/api/interpreter`, `off` switches maps off, otherwise https only |
|
| `MAP_TILES_URL` | a TileJSON naming the vector tiles the map's roads come from; default OpenFreeMap's `https://tiles.openfreemap.org/planet`, `off` switches maps off, otherwise https only |
|
||||||
|
| `MAP_CACHE_DIR` | where fetched map tiles are kept across restarts (the manifest mounts a volume there); empty keeps them in memory only |
|
||||||
| `LISTEN_ADDR` | default `:8080` |
|
| `LISTEN_ADDR` | default `:8080` |
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -60,18 +61,23 @@ download cut short`. The forge's address and token never appear in the log.
|
|||||||
|
|
||||||
## Map roads
|
## Map roads
|
||||||
|
|
||||||
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to
|
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to minor
|
||||||
residential) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car
|
streets) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car names
|
||||||
names the tile, not its position, and in the body, so it stays out of access logs. The answer is
|
the tile, not its position, and in the body, so it stays out of access logs. The answer is
|
||||||
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
|
`{z, x, y, extent: 4096, ways: [{c, p: [x, y, …]}]}`: per road its class (0 motorway and trunk,
|
||||||
1 primary and secondary, 2 tertiary and unclassified, 3 residential and living streets) and its
|
1 primary and secondary, 2 tertiary, 3 minor streets) and its points in tile units, simplified to
|
||||||
points in tile units, simplified to about 1.5 m; gzipped when the car accepts it (20–30 KB a tile
|
about 1.5 m; gzipped when the car accepts it (10–15 KB a tile in a city before that).
|
||||||
in a city before that). The roads come from OpenStreetMap through `OVERPASS_URL`, one request at
|
|
||||||
a time and at most one a second, with a User-Agent naming this service, and are kept in memory
|
The roads are fetched **only for the tiles a car asks for**: the OpenStreetMap vector tile
|
||||||
for 30 days (up to 3000 tiles); the car keeps its own copy too. 400 for anything but zoom 14 or
|
(OpenMapTiles schema) of that square from the TileJSON at `MAP_TILES_URL` (by default
|
||||||
a tile outside it, 503 when Overpass is busy or unreachable (not cached, the car tries again a
|
OpenFreeMap: free, no key, no request limits; its template moves with each weekly build and is
|
||||||
minute later), 404 when maps are off. The log says `map tile fetched` with the car's installation
|
looked up every 6 hours), decoded by a small reader of our own (`mvt.go`, the `transportation`
|
||||||
and the size, never the tile. Map data © OpenStreetMap contributors, ODbL.
|
layer only). Each tile is fetched once and kept for 30 days in `MAP_CACHE_DIR` (and the last 300
|
||||||
|
in memory); at most two fetches run at once. A request waits up to 6 s; a slower fetch goes on
|
||||||
|
and is kept, and the car hears 503 with `Retry-After: 10` and gets the tile on its next try.
|
||||||
|
400 for anything but zoom 14 or a tile outside it, 503 when the tile host fails (not cached),
|
||||||
|
404 when maps are off. The log says `map tile fetched` with the size, never the tile.
|
||||||
|
Map data © OpenStreetMap contributors (ODbL), tiles © OpenMapTiles, served by OpenFreeMap.
|
||||||
|
|
||||||
## Settings from a phone
|
## Settings from a phone
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ type API struct {
|
|||||||
Handoff *Handoff
|
Handoff *Handoff
|
||||||
// Releases relays the app's releases from the private forge to the car (releases.go).
|
// Releases relays the app's releases from the private forge to the car (releases.go).
|
||||||
Releases *Releases
|
Releases *Releases
|
||||||
// Maps serves the roads for the Overview's map from OpenStreetMap (maps.go); nil is off.
|
// Maps serves the roads for the Overview's map from OpenStreetMap vector tiles (maps.go); nil is off.
|
||||||
Maps *Maps
|
Maps *Maps
|
||||||
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||||
// derived from the request when empty.
|
// derived from the request when empty.
|
||||||
|
|||||||
+19
-11
@@ -34,7 +34,8 @@ type config struct {
|
|||||||
forgeURL string
|
forgeURL string
|
||||||
forgeRepo string
|
forgeRepo string
|
||||||
forgeToken string
|
forgeToken string
|
||||||
overpassURL string
|
mapTilesURL string
|
||||||
|
mapCacheDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
||||||
@@ -52,7 +53,8 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
forgeURL: getenv("FORGE_URL"),
|
forgeURL: getenv("FORGE_URL"),
|
||||||
forgeRepo: getenv("FORGE_REPO"),
|
forgeRepo: getenv("FORGE_REPO"),
|
||||||
forgeToken: getenv("FORGE_TOKEN"),
|
forgeToken: getenv("FORGE_TOKEN"),
|
||||||
overpassURL: getenv("OVERPASS_URL"),
|
mapTilesURL: getenv("MAP_TILES_URL"),
|
||||||
|
mapCacheDir: getenv("MAP_CACHE_DIR"),
|
||||||
}
|
}
|
||||||
if c.listenAddr == "" {
|
if c.listenAddr == "" {
|
||||||
c.listenAddr = ":8080"
|
c.listenAddr = ":8080"
|
||||||
@@ -75,16 +77,16 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
|
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
|
||||||
return config{}, errors.New("FORGE_URL must be an https URL")
|
return config{}, errors.New("FORGE_URL must be an https URL")
|
||||||
}
|
}
|
||||||
// The map's roads come from an Overpass API: the public one unless another is named, none
|
// The map's roads come from vector tiles named by a TileJSON: OpenFreeMap unless another is
|
||||||
// with "off". Only https, like the forge.
|
// named, none with "off". Only https, like the forge.
|
||||||
switch c.overpassURL {
|
switch c.mapTilesURL {
|
||||||
case "":
|
case "":
|
||||||
c.overpassURL = DefaultOverpassURL
|
c.mapTilesURL = DefaultMapTilesURL
|
||||||
case "off":
|
case "off":
|
||||||
c.overpassURL = ""
|
c.mapTilesURL = ""
|
||||||
default:
|
default:
|
||||||
if !strings.HasPrefix(c.overpassURL, "https://") {
|
if !strings.HasPrefix(c.mapTilesURL, "https://") {
|
||||||
return config{}, errors.New("OVERPASS_URL must be an https URL or \"off\"")
|
return config{}, errors.New("MAP_TILES_URL must be an https URL or \"off\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
||||||
@@ -116,8 +118,14 @@ func main() {
|
|||||||
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
|
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
|
||||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
|
||||||
Releases: releases, PublicURL: cfg.publicURL}
|
Releases: releases, PublicURL: cfg.publicURL}
|
||||||
if cfg.overpassURL != "" {
|
if cfg.mapTilesURL != "" {
|
||||||
api.Maps = NewMaps(cfg.overpassURL, mapUserAgent)
|
if cfg.mapCacheDir != "" {
|
||||||
|
if err := os.MkdirAll(cfg.mapCacheDir, 0o700); err != nil {
|
||||||
|
slog.Error("map cache directory not usable", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.Maps = NewMaps(cfg.mapTilesURL, cfg.mapCacheDir, mapUserAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
+263
-195
@@ -12,53 +12,69 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style),
|
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style). It
|
||||||
// taken from OpenStreetMap through an Overpass API and cached here, so the car only ever
|
// fetches only the tiles a car asks for, as OpenStreetMap vector tiles (OpenMapTiles schema,
|
||||||
// talks to this server and asks Overpass nothing itself.
|
// by default from OpenFreeMap), keeps their roads and stores them, so each tile is fetched
|
||||||
|
// once and the car only ever talks to this server.
|
||||||
//
|
//
|
||||||
// POST /v1/map/roads {"z":14,"x":…,"y":…}
|
// POST /v1/map/roads {"z":14,"x":…,"y":…}
|
||||||
// → {"z","x","y","extent":4096,"ways":[{"c":class,"p":[x,y,x,y,…]}]}
|
// → {"z","x","y","extent":4096,"ways":[{"c":class,"p":[x,y,x,y,…]}]}
|
||||||
//
|
//
|
||||||
// The car names a web-mercator tile at zoom 14 (about 1.5 km across in central Europe), not
|
// The car names a web-mercator tile at zoom 14 (about 1.5 km across in central Europe), not
|
||||||
// its position, and the request goes in the body so the tile stays out of access logs. Points
|
// its position, and in the body so the tile stays out of access logs. Points are tile-local
|
||||||
// are tile-local in 0..4096 (they may lie a little outside: a road is kept up to the first
|
// in 0..4096 (a little beyond at the edges), simplified to about 1.5 m. Classes: 0 motorway and
|
||||||
// point beyond the edge), simplified to about 1.5 m. Classes: 0 motorway and trunk, 1 primary
|
// trunk, 1 primary and secondary, 2 tertiary, 3 minor streets (residential, unclassified,
|
||||||
// and secondary, 2 tertiary and unclassified, 3 residential and living streets.
|
// living streets).
|
||||||
//
|
//
|
||||||
// Overpass is asked one tile at a time, at most once a second; a tile is kept for 30 days,
|
// A fetch runs to its end even when the car stops waiting (the car gives up after a few
|
||||||
// up to mapCacheTiles of them. The tile's coordinates never appear in the log.
|
// seconds), so its next try finds the tile. Tiles are kept in memory (the last mapMemoryTiles)
|
||||||
|
// and, with a cache directory, on disk for mapCacheAge. The tile's coordinates never appear
|
||||||
|
// in the log.
|
||||||
type Maps struct {
|
type Maps struct {
|
||||||
OverpassURL string
|
// TileJSONURL names the vector tiles: a TileJSON document whose first "tiles" template is
|
||||||
UserAgent string
|
// used (OpenFreeMap moves its template with every weekly build).
|
||||||
Client *http.Client
|
TileJSONURL string
|
||||||
|
// CacheDir keeps fetched tiles across restarts; empty keeps them in memory only.
|
||||||
|
CacheDir string
|
||||||
|
UserAgent string
|
||||||
|
Client *http.Client
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tiles map[mapTile]mapEntry
|
memory map[mapTile]mapEntry
|
||||||
|
inflight map[mapTile]*mapFlight
|
||||||
fetchMu sync.Mutex // one Overpass request at a time
|
template string
|
||||||
lastFetch time.Time
|
tmplAt time.Time
|
||||||
gap time.Duration
|
// slots limits fetches running at once.
|
||||||
|
slots chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mapZoom = 14
|
mapZoom = 14
|
||||||
mapExtent = 4096
|
mapExtent = 4096
|
||||||
mapCacheTiles = 3000
|
mapMemoryTiles = 300
|
||||||
mapCacheAge = 30 * 24 * time.Hour
|
mapCacheAge = 30 * 24 * time.Hour
|
||||||
maxOverpassBytes = 32 << 20
|
maxTileBytes = 8 << 20
|
||||||
|
mapFetchTime = 60 * time.Second
|
||||||
|
templateAge = 6 * time.Hour
|
||||||
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
|
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
|
||||||
mapTolerance = 4.0
|
mapTolerance = 4.0
|
||||||
// DefaultOverpassURL is the public instance of the Overpass API.
|
// DefaultMapTilesURL is OpenFreeMap's TileJSON: free OpenStreetMap vector tiles, no key.
|
||||||
DefaultOverpassURL = "https://overpass-api.de/api/interpreter"
|
DefaultMapTilesURL = "https://tiles.openfreemap.org/planet"
|
||||||
// mapUserAgent names this service to Overpass, as its usage policy asks.
|
// mapUserAgent names this service to the tile host.
|
||||||
mapUserAgent = "nils-mg4-server/2.7 (private car dashboard; roads for an in-car map)"
|
mapUserAgent = "nils-mg4-server/2.8 (private car dashboard; roads for an in-car map)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// mapWait is how long a request waits for a fetch: less than the car's own 8 s.
|
||||||
|
var mapWait = 6 * time.Second
|
||||||
|
|
||||||
type mapTile struct{ Z, X, Y int }
|
type mapTile struct{ Z, X, Y int }
|
||||||
|
|
||||||
type mapEntry struct {
|
type mapEntry struct {
|
||||||
@@ -66,12 +82,19 @@ type mapEntry struct {
|
|||||||
at time.Time
|
at time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// roadClasses maps OSM's highway values to the four classes the car draws.
|
type mapFlight struct {
|
||||||
|
done chan struct{}
|
||||||
|
body []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// roadClasses maps the OpenMapTiles transportation classes to the four the car draws; the
|
||||||
|
// rest (service, track, path, rail, ferry…) are left out.
|
||||||
var roadClasses = map[string]int{
|
var roadClasses = map[string]int{
|
||||||
"motorway": 0, "motorway_link": 0, "trunk": 0, "trunk_link": 0,
|
"motorway": 0, "trunk": 0,
|
||||||
"primary": 1, "primary_link": 1, "secondary": 1, "secondary_link": 1,
|
"primary": 1, "secondary": 1,
|
||||||
"tertiary": 2, "tertiary_link": 2, "unclassified": 2,
|
"tertiary": 2,
|
||||||
"residential": 3, "living_street": 3,
|
"minor": 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
type roadTile struct {
|
type roadTile struct {
|
||||||
@@ -87,92 +110,227 @@ type road struct {
|
|||||||
P []int `json:"p"`
|
P []int `json:"p"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaps(overpassURL, userAgent string) *Maps {
|
func NewMaps(tileJSONURL, cacheDir, userAgent string) *Maps {
|
||||||
return &Maps{OverpassURL: overpassURL, UserAgent: userAgent, gap: time.Second}
|
return &Maps{TileJSONURL: tileJSONURL, CacheDir: cacheDir, UserAgent: userAgent}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Maps) client() *http.Client {
|
func (m *Maps) client() *http.Client {
|
||||||
if m.Client != nil {
|
if m.Client != nil {
|
||||||
return m.Client
|
return m.Client
|
||||||
}
|
}
|
||||||
return &http.Client{Timeout: 60 * time.Second}
|
return &http.Client{Timeout: mapFetchTime}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tileBounds is the tile's south, west, north and east edge in degrees.
|
// Tile returns the tile's roads as JSON and whether it came from a cache. Waiting ends with
|
||||||
func tileBounds(t mapTile) (s, w, n, e float64) {
|
// ctx; the fetch itself goes on and is kept.
|
||||||
scale := math.Exp2(float64(t.Z))
|
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
|
||||||
lon := func(x float64) float64 { return x/scale*360 - 180 }
|
if body, ok := m.cached(t); ok {
|
||||||
lat := func(y float64) float64 { return math.Atan(math.Sinh(math.Pi*(1-2*y/scale))) * 180 / math.Pi }
|
return body, true, nil
|
||||||
return lat(float64(t.Y + 1)), lon(float64(t.X)), lat(float64(t.Y)), lon(float64(t.X + 1))
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.inflight == nil {
|
||||||
|
m.inflight = map[mapTile]*mapFlight{}
|
||||||
|
m.slots = make(chan struct{}, 2)
|
||||||
|
}
|
||||||
|
f, running := m.inflight[t]
|
||||||
|
if !running {
|
||||||
|
f = &mapFlight{done: make(chan struct{})}
|
||||||
|
m.inflight[t] = f
|
||||||
|
go m.run(t, f)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return f.body, false, f.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, false, errors.New("still fetching")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tilePoint is a position in the tile's own units: 0..extent across, y downwards.
|
func (m *Maps) run(t mapTile, f *mapFlight) {
|
||||||
func tilePoint(t mapTile, lat, lon float64) (int, int) {
|
defer func() {
|
||||||
scale := math.Exp2(float64(t.Z)) * mapExtent
|
m.mu.Lock()
|
||||||
x := (lon + 180) / 360 * scale
|
delete(m.inflight, t)
|
||||||
r := lat * math.Pi / 180
|
m.mu.Unlock()
|
||||||
y := (1 - math.Log(math.Tan(r)+1/math.Cos(r))/math.Pi) / 2 * scale
|
close(f.done)
|
||||||
return int(math.Round(x - float64(t.X)*mapExtent)), int(math.Round(y - float64(t.Y)*mapExtent))
|
}()
|
||||||
|
m.slots <- struct{}{}
|
||||||
|
defer func() { <-m.slots }()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), mapFetchTime)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := m.fetch(ctx, t)
|
||||||
|
if err == nil {
|
||||||
|
f.body, err = buildTile(t, raw)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
f.err = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.store(t, f.body)
|
||||||
|
slog.Info("map tile fetched", "ways", strings.Count(string(f.body), `"c":`), "bytes", len(f.body))
|
||||||
}
|
}
|
||||||
|
|
||||||
// overpassQuery asks for the drivable roads in the tile, with a small margin so that roads
|
func (m *Maps) cachePath(t mapTile) string {
|
||||||
// crossing its edge reach a point beyond it.
|
return filepath.Join(m.CacheDir, fmt.Sprintf("%d_%d_%d.json", t.Z, t.X, t.Y))
|
||||||
func overpassQuery(t mapTile) string {
|
|
||||||
s, w, n, e := tileBounds(t)
|
|
||||||
dy, dx := (n-s)*0.03, (e-w)*0.03
|
|
||||||
box := fmt.Sprintf("%.6f,%.6f,%.6f,%.6f", s-dy, w-dx, n+dy, e+dx)
|
|
||||||
return "[out:json][timeout:50];way[\"highway\"~\"^(motorway|motorway_link|trunk|trunk_link|primary|primary_link|" +
|
|
||||||
"secondary|secondary_link|tertiary|tertiary_link|unclassified|residential|living_street)$\"](" + box +
|
|
||||||
");out geom(" + box + ");"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type overpassResult struct {
|
func (m *Maps) cached(t mapTile) ([]byte, bool) {
|
||||||
Elements []struct {
|
m.mu.Lock()
|
||||||
Type string `json:"type"`
|
e, ok := m.memory[t]
|
||||||
Tags map[string]string `json:"tags"`
|
m.mu.Unlock()
|
||||||
Geometry []*struct {
|
if ok && time.Since(e.at) <= mapCacheAge {
|
||||||
Lat float64 `json:"lat"`
|
return e.body, true
|
||||||
Lon float64 `json:"lon"`
|
}
|
||||||
} `json:"geometry"`
|
if m.CacheDir == "" {
|
||||||
} `json:"elements"`
|
return nil, false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(m.cachePath(t))
|
||||||
|
if err != nil || time.Since(info.ModTime()) > mapCacheAge {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(m.cachePath(t))
|
||||||
|
if err != nil || !json.Valid(body) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
m.remember(t, body, info.ModTime())
|
||||||
|
return body, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTile turns Overpass's answer into the tile the car reads. A missing point (outside
|
func (m *Maps) store(t mapTile, body []byte) {
|
||||||
// the clipped geometry) splits a road in two.
|
m.remember(t, body, time.Now())
|
||||||
|
if m.CacheDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := m.cachePath(t) + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, body, 0o600); err != nil {
|
||||||
|
slog.Warn("map tile not stored", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, m.cachePath(t)); err != nil {
|
||||||
|
slog.Warn("map tile not stored", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remember keeps the tile in memory, dropping the oldest when full.
|
||||||
|
func (m *Maps) remember(t mapTile, body []byte, at time.Time) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.memory == nil {
|
||||||
|
m.memory = map[mapTile]mapEntry{}
|
||||||
|
}
|
||||||
|
if _, ok := m.memory[t]; !ok && len(m.memory) >= mapMemoryTiles {
|
||||||
|
var oldest mapTile
|
||||||
|
var first time.Time
|
||||||
|
for k, e := range m.memory {
|
||||||
|
if first.IsZero() || e.at.Before(first) {
|
||||||
|
oldest, first = k, e.at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m.memory, oldest)
|
||||||
|
}
|
||||||
|
m.memory[t] = mapEntry{body: body, at: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Maps) get(ctx context.Context, u string, limit int64) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", m.UserAgent)
|
||||||
|
resp, err := m.client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// The URL holds the tile: name the failure, not the request.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, errors.New("tile host timed out")
|
||||||
|
}
|
||||||
|
return nil, errors.New("tile host could not be reached")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||||
|
return nil, fmt.Errorf("tile host answered %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("tile host answer could not be read")
|
||||||
|
}
|
||||||
|
if int64(len(body)) > limit {
|
||||||
|
return nil, errors.New("tile host answer is too large")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tileTemplate is the current {z}/{x}/{y} URL from the TileJSON, looked up every few hours.
|
||||||
|
func (m *Maps) tileTemplate(ctx context.Context) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
tmpl, at := m.template, m.tmplAt
|
||||||
|
m.mu.Unlock()
|
||||||
|
if tmpl != "" && time.Since(at) < templateAge {
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
body, err := m.get(ctx, m.TileJSONURL, 1<<20)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Tiles []string `json:"tiles"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil || len(doc.Tiles) == 0 {
|
||||||
|
return "", errors.New("tile index has no tiles")
|
||||||
|
}
|
||||||
|
tmpl = doc.Tiles[0]
|
||||||
|
u, err := url.Parse(strings.NewReplacer("{z}", "0", "{x}", "0", "{y}", "0").Replace(tmpl))
|
||||||
|
if err != nil || u.Scheme != "https" || u.Host == "" ||
|
||||||
|
!strings.Contains(tmpl, "{z}") || !strings.Contains(tmpl, "{x}") || !strings.Contains(tmpl, "{y}") {
|
||||||
|
return "", errors.New("tile index names no usable https template")
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.template, m.tmplAt = tmpl, time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Maps) fetch(ctx context.Context, t mapTile) ([]byte, error) {
|
||||||
|
tmpl, err := m.tileTemplate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u := strings.NewReplacer("{z}", strconv.Itoa(t.Z), "{x}", strconv.Itoa(t.X), "{y}", strconv.Itoa(t.Y)).Replace(tmpl)
|
||||||
|
raw, err := m.get(ctx, u, maxTileBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Some hosts send the tile gzipped whatever the request said.
|
||||||
|
if len(raw) > 2 && raw[0] == 0x1f && raw[1] == 0x8b {
|
||||||
|
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("tile is not readable")
|
||||||
|
}
|
||||||
|
raw, err = io.ReadAll(io.LimitReader(zr, maxTileBytes+1))
|
||||||
|
if err != nil || len(raw) > maxTileBytes {
|
||||||
|
return nil, errors.New("tile is not readable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTile keeps the roads of the vector tile's transportation layer, in the car's format.
|
||||||
func buildTile(t mapTile, raw []byte) ([]byte, error) {
|
func buildTile(t mapTile, raw []byte) ([]byte, error) {
|
||||||
var res overpassResult
|
lines, err := decodeRoads(raw)
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("overpass answer: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
|
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
|
||||||
for _, el := range res.Elements {
|
for _, l := range lines {
|
||||||
class, ok := roadClasses[el.Tags["highway"]]
|
pts := simplify(l.points, mapTolerance)
|
||||||
if el.Type != "way" || !ok {
|
if len(pts) < 2 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var run [][2]int
|
flat := make([]int, 0, len(pts)*2)
|
||||||
flush := func() {
|
for _, p := range pts {
|
||||||
if pts := simplify(run, mapTolerance); len(pts) >= 2 {
|
flat = append(flat, p[0], p[1])
|
||||||
flat := make([]int, 0, len(pts)*2)
|
|
||||||
for _, p := range pts {
|
|
||||||
flat = append(flat, p[0], p[1])
|
|
||||||
}
|
|
||||||
out.Ways = append(out.Ways, road{C: class, P: flat})
|
|
||||||
}
|
|
||||||
run = nil
|
|
||||||
}
|
}
|
||||||
for _, g := range el.Geometry {
|
out.Ways = append(out.Ways, road{C: l.class, P: flat})
|
||||||
if g == nil {
|
|
||||||
flush()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
x, y := tilePoint(t, g.Lat, g.Lon)
|
|
||||||
if n := len(run); n > 0 && run[n-1] == [2]int{x, y} {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
run = append(run, [2]int{x, y})
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
}
|
}
|
||||||
return json.Marshal(out)
|
return json.Marshal(out)
|
||||||
}
|
}
|
||||||
@@ -216,100 +374,11 @@ func segmentDistance(p, a, b [2]int) float64 {
|
|||||||
if dx == 0 && dy == 0 {
|
if dx == 0 && dy == 0 {
|
||||||
return math.Hypot(px-ax, py-ay)
|
return math.Hypot(px-ax, py-ay)
|
||||||
}
|
}
|
||||||
u := math.Max(0, math.Min(1, ((px-ax)*dx+(py-ay)*dy)/(dx*dx+dy*dy)))
|
u := ((px-ax)*dx + (py-ay)*dy) / (dx*dx + dy*dy)
|
||||||
|
u = max(0, min(1, u))
|
||||||
return math.Hypot(px-ax-u*dx, py-ay-u*dy)
|
return math.Hypot(px-ax-u*dx, py-ay-u*dy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tile returns the tile's roads as JSON, from the cache or from Overpass, and whether it
|
|
||||||
// came from the cache.
|
|
||||||
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
|
|
||||||
if body, ok := m.cached(t); ok {
|
|
||||||
return body, true, nil
|
|
||||||
}
|
|
||||||
m.fetchMu.Lock()
|
|
||||||
defer m.fetchMu.Unlock()
|
|
||||||
// Another request may have fetched it while this one waited.
|
|
||||||
if body, ok := m.cached(t); ok {
|
|
||||||
return body, true, nil
|
|
||||||
}
|
|
||||||
if wait := m.gap - time.Since(m.lastFetch); wait > 0 {
|
|
||||||
select {
|
|
||||||
case <-time.After(wait):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, false, ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.lastFetch = time.Now()
|
|
||||||
raw, err := m.fetch(ctx, t)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
body, err := buildTile(t, raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
m.store(t, body)
|
|
||||||
return body, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Maps) cached(t mapTile) ([]byte, bool) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
e, ok := m.tiles[t]
|
|
||||||
if !ok || time.Since(e.at) > mapCacheAge {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return e.body, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// store keeps the tile, dropping the oldest when the cache is full.
|
|
||||||
func (m *Maps) store(t mapTile, body []byte) {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.tiles == nil {
|
|
||||||
m.tiles = map[mapTile]mapEntry{}
|
|
||||||
}
|
|
||||||
if len(m.tiles) >= mapCacheTiles {
|
|
||||||
var oldest mapTile
|
|
||||||
var at time.Time
|
|
||||||
for k, e := range m.tiles {
|
|
||||||
if at.IsZero() || e.at.Before(at) {
|
|
||||||
oldest, at = k, e.at
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delete(m.tiles, oldest)
|
|
||||||
}
|
|
||||||
m.tiles[t] = mapEntry{body: body, at: time.Now()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch asks Overpass. The query goes in the body, so its errors never carry the tile.
|
|
||||||
func (m *Maps) fetch(ctx context.Context, t mapTile) ([]byte, error) {
|
|
||||||
form := url.Values{"data": {overpassQuery(t)}}.Encode()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.OverpassURL, strings.NewReader(form))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("User-Agent", m.UserAgent)
|
|
||||||
resp, err := m.client().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("overpass could not be reached")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
|
||||||
return nil, fmt.Errorf("overpass answered %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxOverpassBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New("overpass answer could not be read")
|
|
||||||
}
|
|
||||||
if len(raw) > maxOverpassBytes {
|
|
||||||
return nil, errors.New("overpass answer is too large")
|
|
||||||
}
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *API) mapRoutes(mux *http.ServeMux) {
|
func (a *API) mapRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("POST /v1/map/roads", a.ingest(a.mapRoads))
|
mux.HandleFunc("POST /v1/map/roads", a.ingest(a.mapRoads))
|
||||||
}
|
}
|
||||||
@@ -332,17 +401,16 @@ func (a *API) mapRoads(w http.ResponseWriter, r *http.Request, c caller, body []
|
|||||||
if *req.X < 0 || *req.X >= limit || *req.Y < 0 || *req.Y >= limit {
|
if *req.X < 0 || *req.X >= limit || *req.Y < 0 || *req.Y >= limit {
|
||||||
return invalid("x and y must be within the zoom level")
|
return invalid("x and y must be within the zoom level")
|
||||||
}
|
}
|
||||||
t := mapTile{Z: *req.Z, X: *req.X, Y: *req.Y}
|
// The car waits a few seconds at most; the fetch goes on without it.
|
||||||
data, cached, err := a.Maps.Tile(r.Context(), t)
|
wait, cancel := context.WithTimeout(r.Context(), mapWait)
|
||||||
|
defer cancel()
|
||||||
|
data, _, err := a.Maps.Tile(wait, mapTile{Z: *req.Z, X: *req.X, Y: *req.Y})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("map tile not available", "installation", who(c), "error", err)
|
slog.Warn("map tile not available yet", "installation", who(c), "error", err)
|
||||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available, try again later"})
|
w.Header().Set("Retry-After", "10")
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available yet, try again shortly"})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !cached {
|
|
||||||
slog.Info("map tile fetched", "installation", who(c), "ways", strings.Count(string(data), `"c":`),
|
|
||||||
"bytes", len(data))
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
+246
-97
@@ -12,50 +12,140 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A tile in central Berlin.
|
// A tile in central Berlin.
|
||||||
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
|
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
|
||||||
|
|
||||||
// fakeOverpass answers every query with a primary road (with a gap in its geometry), a
|
// ---------- A minimal vector-tile encoder for the tests ----------
|
||||||
// residential street with a needless middle point, and a footway the car does not draw.
|
|
||||||
func fakeOverpass(t *testing.T, calls *atomic.Int32, status int) *httptest.Server {
|
func pbVarint(v uint64) []byte {
|
||||||
t.Helper()
|
var b []byte
|
||||||
s, w, n, e := tileBounds(testTile)
|
for v >= 0x80 {
|
||||||
lat := func(f float64) float64 { return s + (n-s)*f }
|
b = append(b, byte(v)|0x80)
|
||||||
lon := func(f float64) float64 { return w + (e-w)*f }
|
v >>= 7
|
||||||
answer := fmt.Sprintf(`{"elements":[
|
}
|
||||||
{"type":"way","tags":{"highway":"primary"},"geometry":[
|
return append(b, byte(v))
|
||||||
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},null,{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
|
|
||||||
{"type":"way","tags":{"highway":"residential"},"geometry":[
|
|
||||||
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]},
|
|
||||||
{"type":"way","tags":{"highway":"footway"},"geometry":[
|
|
||||||
{"lat":%f,"lon":%f},{"lat":%f,"lon":%f}]}]}`,
|
|
||||||
lat(0.1), lon(0.1), lat(0.2), lon(0.2), lat(0.6), lon(0.6), lat(0.7), lon(0.7),
|
|
||||||
lat(0.5), lon(0.1), lat(0.5), lon(0.5), lat(0.5), lon(0.9),
|
|
||||||
lat(0.3), lon(0.3), lat(0.4), lon(0.4))
|
|
||||||
return httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
calls.Add(1)
|
|
||||||
if r.Method != http.MethodPost || !strings.Contains(r.UserAgent(), "nils-mg4-server") {
|
|
||||||
t.Errorf("request %s %q", r.Method, r.UserAgent())
|
|
||||||
}
|
|
||||||
_ = r.ParseForm()
|
|
||||||
if q := r.PostForm.Get("data"); !strings.Contains(q, "out geom(") || !strings.Contains(q, "residential") {
|
|
||||||
t.Errorf("query %q", q)
|
|
||||||
}
|
|
||||||
if status != http.StatusOK {
|
|
||||||
http.Error(rw, "busy", status)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = rw.Write([]byte(answer))
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapsAPI(overpass *httptest.Server) http.Handler {
|
func pbKey(num, wire int) []byte { return pbVarint(uint64(num<<3 | wire)) }
|
||||||
|
|
||||||
|
func pbBytes(num int, data []byte) []byte {
|
||||||
|
return append(append(pbKey(num, 2), pbVarint(uint64(len(data)))...), data...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pbUint(num int, v uint64) []byte { return append(pbKey(num, 0), pbVarint(v)...) }
|
||||||
|
|
||||||
|
func pbPacked(num int, vs []uint32) []byte {
|
||||||
|
var d []byte
|
||||||
|
for _, v := range vs {
|
||||||
|
d = append(d, pbVarint(uint64(v))...)
|
||||||
|
}
|
||||||
|
return pbBytes(num, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func zz(v int) uint32 { return uint32((v << 1) ^ (v >> 31)) }
|
||||||
|
|
||||||
|
// lineCmds encodes line strings as MoveTo/LineTo commands with relative moves.
|
||||||
|
func lineCmds(lines ...[][2]int) []uint32 {
|
||||||
|
var out []uint32
|
||||||
|
x, y := 0, 0
|
||||||
|
for _, l := range lines {
|
||||||
|
out = append(out, 1|1<<3, zz(l[0][0]-x), zz(l[0][1]-y))
|
||||||
|
x, y = l[0][0], l[0][1]
|
||||||
|
out = append(out, uint32(2|(len(l)-1)<<3))
|
||||||
|
for _, p := range l[1:] {
|
||||||
|
out = append(out, zz(p[0]-x), zz(p[1]-y))
|
||||||
|
x, y = p[0], p[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type testFeature struct {
|
||||||
|
class int // index into the values below
|
||||||
|
geomType uint64
|
||||||
|
geometry []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// testVectorTile has a water layer (ignored) and a transportation layer with the given extent.
|
||||||
|
func testVectorTile(extent uint64) []byte {
|
||||||
|
values := []string{"primary", "minor", "service", "motorway"}
|
||||||
|
features := []testFeature{
|
||||||
|
// A primary road with a needless middle point.
|
||||||
|
{0, 2, lineCmds([][2]int{{100, 100}, {200, 200}, {300, 300}})},
|
||||||
|
// A minor street in two pieces.
|
||||||
|
{1, 2, lineCmds([][2]int{{0, 500}, {400, 500}}, [][2]int{{600, 500}, {900, 520}})},
|
||||||
|
// A service road and a motorway as a polygon: neither is drawn.
|
||||||
|
{2, 2, lineCmds([][2]int{{10, 10}, {20, 20}})},
|
||||||
|
{3, 3, lineCmds([][2]int{{10, 10}, {20, 20}})},
|
||||||
|
}
|
||||||
|
var layer []byte
|
||||||
|
layer = append(layer, pbUint(15, 2)...)
|
||||||
|
layer = append(layer, pbBytes(1, []byte("transportation"))...)
|
||||||
|
for _, f := range features {
|
||||||
|
var feat []byte
|
||||||
|
feat = append(feat, pbPacked(2, []uint32{0, uint32(f.class)})...)
|
||||||
|
feat = append(feat, pbUint(3, f.geomType)...)
|
||||||
|
feat = append(feat, pbPacked(4, f.geometry)...)
|
||||||
|
layer = append(layer, pbBytes(2, feat)...)
|
||||||
|
}
|
||||||
|
layer = append(layer, pbBytes(3, []byte("class"))...)
|
||||||
|
for _, v := range values {
|
||||||
|
layer = append(layer, pbBytes(4, pbBytes(1, []byte(v)))...)
|
||||||
|
}
|
||||||
|
layer = append(layer, pbUint(5, extent)...)
|
||||||
|
|
||||||
|
water := append(pbBytes(1, []byte("water")), pbUint(5, 4096)...)
|
||||||
|
return append(pbBytes(3, water), pbBytes(3, layer)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- A fake tile host ----------
|
||||||
|
|
||||||
|
type fakeHost struct {
|
||||||
|
*httptest.Server
|
||||||
|
tiles atomic.Int32
|
||||||
|
status int
|
||||||
|
delay time.Duration
|
||||||
|
gzip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeHost(t *testing.T) *fakeHost {
|
||||||
|
t.Helper()
|
||||||
|
h := &fakeHost{status: http.StatusOK}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /planet", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprintf(w, `{"tilejson":"3.0.0","tiles":["%s/planet/build1/{z}/{x}/{y}.pbf"]}`, h.URL)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /planet/build1/{z}/{x}/{y}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.tiles.Add(1)
|
||||||
|
if !strings.Contains(r.UserAgent(), "nils-mg4-server") {
|
||||||
|
t.Errorf("user agent %q", r.UserAgent())
|
||||||
|
}
|
||||||
|
time.Sleep(h.delay)
|
||||||
|
if h.status != http.StatusOK {
|
||||||
|
http.Error(w, "busy", h.status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body := testVectorTile(4096)
|
||||||
|
if h.gzip {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
zw := gzip.NewWriter(&buf)
|
||||||
|
_, _ = zw.Write(body)
|
||||||
|
_ = zw.Close()
|
||||||
|
body = buf.Bytes()
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
})
|
||||||
|
h.Server = httptest.NewTLSServer(mux)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsAPIWith(host *fakeHost, cacheDir string) http.Handler {
|
||||||
api, _ := newTestAPI()
|
api, _ := newTestAPI()
|
||||||
api.Maps = NewMaps(overpass.URL, mapUserAgent)
|
api.Maps = NewMaps(host.URL+"/planet", cacheDir, mapUserAgent)
|
||||||
api.Maps.Client = overpass.Client()
|
api.Maps.Client = host.Client()
|
||||||
api.Maps.gap = 0
|
|
||||||
return api.Routes()
|
return api.Routes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,15 +153,8 @@ func tileBody(t mapTile) string {
|
|||||||
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
|
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMapTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
|
func decodeAnswer(t *testing.T, rec *httptest.ResponseRecorder) roadTile {
|
||||||
var calls atomic.Int32
|
t.Helper()
|
||||||
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
|
||||||
defer overpass.Close()
|
|
||||||
h := mapsAPI(overpass)
|
|
||||||
if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 {
|
|
||||||
t.Fatalf("without the car's token: %d", rec.Code)
|
|
||||||
}
|
|
||||||
rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))
|
|
||||||
if rec.Code != 200 {
|
if rec.Code != 200 {
|
||||||
t.Fatalf("%d %s", rec.Code, rec.Body)
|
t.Fatalf("%d %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
@@ -79,39 +162,100 @@ func TestMapTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y {
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tests ----------
|
||||||
|
|
||||||
|
func TestVectorTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
|
||||||
|
lines, err := decodeRoads(testVectorTile(4096))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(lines) != 3 || lines[0].class != 1 || lines[1].class != 3 || lines[2].class != 3 {
|
||||||
|
t.Fatalf("lines %+v", lines)
|
||||||
|
}
|
||||||
|
if fmt.Sprint(lines[1].points) != "[[0 500] [400 500]]" || fmt.Sprint(lines[2].points) != "[[600 500] [900 520]]" {
|
||||||
|
t.Fatalf("pieces %v %v", lines[1].points, lines[2].points)
|
||||||
|
}
|
||||||
|
// Another extent is scaled to 4096.
|
||||||
|
half, err := decodeRoads(testVectorTile(8192))
|
||||||
|
if err != nil || fmt.Sprint(half[0].points[2]) != "[150 150]" {
|
||||||
|
t.Fatalf("scaled %v %v", half, err)
|
||||||
|
}
|
||||||
|
body, err := buildTile(testTile, testVectorTile(4096))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), `{"c":1,"p":[100,100,300,300]}`) {
|
||||||
|
t.Fatalf("the straight road keeps only its ends: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedVectorTilesAreRefused(t *testing.T) {
|
||||||
|
good := testVectorTile(4096)
|
||||||
|
for name, raw := range map[string][]byte{
|
||||||
|
"cut short": good[:len(good)-7],
|
||||||
|
"bad wire type": {0x1f, 0x00},
|
||||||
|
"length too long": {0x1a, 0x7f, 0x01},
|
||||||
|
} {
|
||||||
|
if _, err := decodeRoads(raw); err == nil {
|
||||||
|
t.Errorf("%s: accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lines, err := decodeRoads(nil); err != nil || len(lines) != 0 {
|
||||||
|
t.Fatalf("an empty tile is an empty map: %v %v", lines, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapTilesAreFetchedOnceAndKeptOnDisk(t *testing.T) {
|
||||||
|
host := newFakeHost(t)
|
||||||
|
host.gzip = true // some hosts gzip whatever was asked
|
||||||
|
defer host.Close()
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := mapsAPIWith(host, dir)
|
||||||
|
if rec := do(t, h, "POST", "/v1/map/roads", "", tileBody(testTile)); rec.Code != 401 {
|
||||||
|
t.Fatalf("without the car's token: %d", rec.Code)
|
||||||
|
}
|
||||||
|
got := decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
|
||||||
|
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y || len(got.Ways) != 3 {
|
||||||
t.Fatalf("tile %+v", got)
|
t.Fatalf("tile %+v", got)
|
||||||
}
|
}
|
||||||
// The primary road in two pieces, the street straightened to its ends, no footway.
|
decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
|
||||||
if len(got.Ways) != 3 || got.Ways[0].C != 1 || got.Ways[1].C != 1 || got.Ways[2].C != 3 {
|
// A restart: a new server with the same directory asks the host nothing.
|
||||||
t.Fatalf("ways %+v", got.Ways)
|
decodeAnswer(t, do(t, mapsAPIWith(host, dir), "POST", "/v1/map/roads", testToken, tileBody(testTile)))
|
||||||
}
|
if n := host.tiles.Load(); n != 1 {
|
||||||
if len(got.Ways[2].P) != 4 {
|
t.Fatalf("tile fetched %d times", n)
|
||||||
t.Fatalf("the straight street keeps only its ends: %v", got.Ways[2].P)
|
|
||||||
}
|
|
||||||
// Tile-local: 0.1 of the way in is about 410 units from the west edge.
|
|
||||||
if x := got.Ways[0].P[0]; x < 400 || x > 420 {
|
|
||||||
t.Fatalf("x %d", x)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cached: the second car asking costs Overpass nothing.
|
func TestASlowTileIsFinishedForTheCarsNextTry(t *testing.T) {
|
||||||
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 200 {
|
previous := mapWait
|
||||||
t.Fatalf("%d", rec.Code)
|
mapWait = 50 * time.Millisecond
|
||||||
|
defer func() { mapWait = previous }()
|
||||||
|
host := newFakeHost(t)
|
||||||
|
host.delay = 300 * time.Millisecond
|
||||||
|
defer host.Close()
|
||||||
|
h := mapsAPIWith(host, "")
|
||||||
|
rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile))
|
||||||
|
if rec.Code != 503 || rec.Header().Get("Retry-After") == "" {
|
||||||
|
t.Fatalf("the car should hear 'try again': %d", rec.Code)
|
||||||
}
|
}
|
||||||
if calls.Load() != 1 {
|
time.Sleep(600 * time.Millisecond)
|
||||||
t.Fatalf("overpass asked %d times", calls.Load())
|
decodeAnswer(t, do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)))
|
||||||
|
if n := host.tiles.Load(); n != 1 {
|
||||||
|
t.Fatalf("tile fetched %d times", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
|
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
|
||||||
var calls atomic.Int32
|
host := newFakeHost(t)
|
||||||
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
defer host.Close()
|
||||||
defer overpass.Close()
|
|
||||||
req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile)))
|
req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile)))
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
req.Header.Set("Accept-Encoding", "gzip")
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
mapsAPI(overpass).ServeHTTP(rec, req)
|
mapsAPIWith(host, "").ServeHTTP(rec, req)
|
||||||
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
|
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
|
||||||
t.Fatalf("%d %v", rec.Code, rec.Header())
|
t.Fatalf("%d %v", rec.Code, rec.Header())
|
||||||
}
|
}
|
||||||
@@ -126,84 +270,89 @@ func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMapRequestsAreChecked(t *testing.T) {
|
func TestMapRequestsAreChecked(t *testing.T) {
|
||||||
var calls atomic.Int32
|
host := newFakeHost(t)
|
||||||
overpass := fakeOverpass(t, &calls, http.StatusOK)
|
defer host.Close()
|
||||||
defer overpass.Close()
|
h := mapsAPIWith(host, "")
|
||||||
h := mapsAPI(overpass)
|
|
||||||
for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`,
|
for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`,
|
||||||
`{"z":14,"x":1,"y":16384}`, `not json`} {
|
`{"z":14,"x":1,"y":16384}`, `not json`} {
|
||||||
if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 {
|
if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 {
|
||||||
t.Errorf("%s: %d", body, rec.Code)
|
t.Errorf("%s: %d", body, rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if calls.Load() != 0 {
|
if host.tiles.Load() != 0 {
|
||||||
t.Fatal("a bad request reached Overpass")
|
t.Fatal("a bad request reached the tile host")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestABusyOverpassIsATemporaryFailureAndNotCached(t *testing.T) {
|
func TestAFailingHostIsATemporaryFailureAndNotCached(t *testing.T) {
|
||||||
var logged bytes.Buffer
|
var logged bytes.Buffer
|
||||||
previous := slog.Default()
|
previous := slog.Default()
|
||||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
|
||||||
defer slog.SetDefault(previous)
|
defer slog.SetDefault(previous)
|
||||||
|
|
||||||
var calls atomic.Int32
|
host := newFakeHost(t)
|
||||||
overpass := fakeOverpass(t, &calls, http.StatusTooManyRequests)
|
host.status = http.StatusBadGateway
|
||||||
defer overpass.Close()
|
defer host.Close()
|
||||||
h := mapsAPI(overpass)
|
h := mapsAPIWith(host, t.TempDir())
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
|
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
|
||||||
t.Fatalf("%d", rec.Code)
|
t.Fatalf("%d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if calls.Load() != 2 {
|
if n := host.tiles.Load(); n != 2 {
|
||||||
t.Fatalf("a failure was cached: %d calls", calls.Load())
|
t.Fatalf("a failure was cached: %d fetches", n)
|
||||||
}
|
}
|
||||||
// Where the car is stays out of the log.
|
// Where the car is stays out of the log.
|
||||||
out := logged.String()
|
out := logged.String()
|
||||||
if !strings.Contains(out, "overpass answered 429") || strings.Contains(out, "8802") ||
|
if !strings.Contains(out, "tile host answered 502") || strings.Contains(out, "8802") ||
|
||||||
strings.Contains(out, "5373") {
|
strings.Contains(out, "5373") {
|
||||||
t.Fatalf("log:\n%s", out)
|
t.Fatalf("log:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMapsAreOffWithoutOverpass(t *testing.T) {
|
func TestATileIndexWithoutHttpsIsRefused(t *testing.T) {
|
||||||
|
index := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"tiles":["http://tiles.example.com/{z}/{x}/{y}.pbf"]}`))
|
||||||
|
}))
|
||||||
|
defer index.Close()
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
api.Maps = NewMaps(index.URL, "", mapUserAgent)
|
||||||
|
api.Maps.Client = index.Client()
|
||||||
|
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
|
||||||
|
t.Fatalf("%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapsAreOffWithoutATileSource(t *testing.T) {
|
||||||
api, _ := newTestAPI()
|
api, _ := newTestAPI()
|
||||||
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 {
|
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 {
|
||||||
t.Fatalf("%d", rec.Code)
|
t.Fatalf("%d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOverpassURLConfiguration(t *testing.T) {
|
func TestMapTilesURLConfiguration(t *testing.T) {
|
||||||
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
|
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
|
||||||
"INGEST_TOKEN": "0123456789abcdef01234567"}
|
"INGEST_TOKEN": "0123456789abcdef01234567"}
|
||||||
load := func(overpass string) (config, error) {
|
load := func(tiles string) (config, error) {
|
||||||
return loadConfig(func(k string) string {
|
return loadConfig(func(k string) string {
|
||||||
if k == "OVERPASS_URL" {
|
if k == "MAP_TILES_URL" {
|
||||||
return overpass
|
return tiles
|
||||||
}
|
}
|
||||||
return base[k]
|
return base[k]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if c, err := load(""); err != nil || c.overpassURL != DefaultOverpassURL {
|
if c, err := load(""); err != nil || c.mapTilesURL != DefaultMapTilesURL {
|
||||||
t.Fatalf("default: %q %v", c.overpassURL, err)
|
t.Fatalf("default: %q %v", c.mapTilesURL, err)
|
||||||
}
|
}
|
||||||
if c, err := load("off"); err != nil || c.overpassURL != "" {
|
if c, err := load("off"); err != nil || c.mapTilesURL != "" {
|
||||||
t.Fatalf("off: %q %v", c.overpassURL, err)
|
t.Fatalf("off: %q %v", c.mapTilesURL, err)
|
||||||
}
|
}
|
||||||
if _, err := load("http://overpass.example.com/api/interpreter"); err == nil {
|
if _, err := load("http://tiles.example.com/planet"); err == nil {
|
||||||
t.Fatal("plain http accepted")
|
t.Fatal("plain http accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTilePointsAndSimplification(t *testing.T) {
|
func TestSimplification(t *testing.T) {
|
||||||
s, w, n, e := tileBounds(testTile)
|
|
||||||
if x, y := tilePoint(testTile, n, w); x != 0 || y != 0 {
|
|
||||||
t.Fatalf("north-west corner %d,%d", x, y)
|
|
||||||
}
|
|
||||||
if x, y := tilePoint(testTile, s, e); x != 4096 || y != 4096 {
|
|
||||||
t.Fatalf("south-east corner %d,%d", x, y)
|
|
||||||
}
|
|
||||||
line := [][2]int{{0, 0}, {10, 1}, {20, 0}, {30, 40}}
|
line := [][2]int{{0, 0}, {10, 1}, {20, 0}, {30, 40}}
|
||||||
got := simplify(line, 4)
|
got := simplify(line, 4)
|
||||||
if len(got) != 3 || got[1] != [2]int{20, 0} {
|
if len(got) != 3 || got[1] != [2]int{20, 0} {
|
||||||
|
|||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The roads of a Mapbox Vector Tile (spec 2.1) in the OpenMapTiles schema: the line features
|
||||||
|
// of the "transportation" layer whose "class" the car draws (roadClasses), scaled to
|
||||||
|
// mapExtent. A minimal protobuf reader, enough for that and nothing more; anything malformed
|
||||||
|
// is an error, never a guess.
|
||||||
|
|
||||||
|
type roadLine struct {
|
||||||
|
class int
|
||||||
|
points [][2]int
|
||||||
|
}
|
||||||
|
|
||||||
|
var errBadTile = errors.New("vector tile is malformed")
|
||||||
|
|
||||||
|
// pbReader walks protobuf fields.
|
||||||
|
type pbReader struct {
|
||||||
|
b []byte
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pbReader) done() bool { return r.i >= len(r.b) }
|
||||||
|
|
||||||
|
func (r *pbReader) varint() (uint64, error) {
|
||||||
|
var v uint64
|
||||||
|
for shift := uint(0); shift < 64; shift += 7 {
|
||||||
|
if r.i >= len(r.b) {
|
||||||
|
return 0, errBadTile
|
||||||
|
}
|
||||||
|
c := r.b[r.i]
|
||||||
|
r.i++
|
||||||
|
v |= uint64(c&0x7f) << shift
|
||||||
|
if c < 0x80 {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errBadTile
|
||||||
|
}
|
||||||
|
|
||||||
|
// field reads one field's key and, for length-delimited ones, its bytes.
|
||||||
|
func (r *pbReader) field() (num int, wire int, val uint64, data []byte, err error) {
|
||||||
|
key, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, nil, err
|
||||||
|
}
|
||||||
|
num, wire = int(key>>3), int(key&7)
|
||||||
|
switch wire {
|
||||||
|
case 0:
|
||||||
|
val, err = r.varint()
|
||||||
|
case 1:
|
||||||
|
if r.i+8 > len(r.b) {
|
||||||
|
return 0, 0, 0, nil, errBadTile
|
||||||
|
}
|
||||||
|
r.i += 8
|
||||||
|
case 2:
|
||||||
|
var n uint64
|
||||||
|
n, err = r.varint()
|
||||||
|
if err == nil {
|
||||||
|
if n > uint64(len(r.b)-r.i) {
|
||||||
|
return 0, 0, 0, nil, errBadTile
|
||||||
|
}
|
||||||
|
data = r.b[r.i : r.i+int(n)]
|
||||||
|
r.i += int(n)
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if r.i+4 > len(r.b) {
|
||||||
|
return 0, 0, 0, nil, errBadTile
|
||||||
|
}
|
||||||
|
r.i += 4
|
||||||
|
default:
|
||||||
|
return 0, 0, 0, nil, errBadTile
|
||||||
|
}
|
||||||
|
return num, wire, val, data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// packed reads a repeated uint32 field, packed or not.
|
||||||
|
func packed(wire int, val uint64, data []byte, into []uint32) ([]uint32, error) {
|
||||||
|
if wire == 0 {
|
||||||
|
return append(into, uint32(val)), nil
|
||||||
|
}
|
||||||
|
if wire != 2 {
|
||||||
|
return nil, errBadTile
|
||||||
|
}
|
||||||
|
r := pbReader{b: data}
|
||||||
|
for !r.done() {
|
||||||
|
v, err := r.varint()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
into = append(into, uint32(v))
|
||||||
|
}
|
||||||
|
return into, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeRoads returns the drawable roads of the tile.
|
||||||
|
func decodeRoads(tile []byte) ([]roadLine, error) {
|
||||||
|
r := pbReader{b: tile}
|
||||||
|
for !r.done() {
|
||||||
|
num, _, _, data, err := r.field()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if num != 3 { // layers
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines, isRoads, err := decodeLayer(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if isRoads {
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil // a tile without roads (sea, desert) is an empty one
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawFeature struct {
|
||||||
|
tags []uint32
|
||||||
|
geomType uint64
|
||||||
|
geometry []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLayer(layer []byte) ([]roadLine, bool, error) {
|
||||||
|
r := pbReader{b: layer}
|
||||||
|
var name string
|
||||||
|
var keys []string
|
||||||
|
var values []string // only string values matter for "class"
|
||||||
|
var features []rawFeature
|
||||||
|
extent := uint64(4096)
|
||||||
|
for !r.done() {
|
||||||
|
num, wire, val, data, err := r.field()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
switch num {
|
||||||
|
case 1:
|
||||||
|
name = string(data)
|
||||||
|
case 2:
|
||||||
|
f, err := decodeFeature(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
features = append(features, f)
|
||||||
|
case 3:
|
||||||
|
keys = append(keys, string(data))
|
||||||
|
case 4:
|
||||||
|
values = append(values, valueString(data))
|
||||||
|
case 5:
|
||||||
|
if wire != 0 || val == 0 {
|
||||||
|
return nil, false, errBadTile
|
||||||
|
}
|
||||||
|
extent = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name != "transportation" {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
scale := float64(mapExtent) / float64(extent)
|
||||||
|
var lines []roadLine
|
||||||
|
for _, f := range features {
|
||||||
|
if f.geomType != 2 { // LINESTRING
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
class, ok := -1, false
|
||||||
|
for i := 0; i+1 < len(f.tags); i += 2 {
|
||||||
|
k, v := int(f.tags[i]), int(f.tags[i+1])
|
||||||
|
if k >= len(keys) || v >= len(values) {
|
||||||
|
return nil, false, errBadTile
|
||||||
|
}
|
||||||
|
if keys[k] == "class" {
|
||||||
|
class, ok = roadClasses[values[v]]
|
||||||
|
if !ok {
|
||||||
|
class = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if class < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts, err := lineGeometry(f.geometry, scale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
lines = append(lines, roadLine{class: class, points: p})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeFeature(data []byte) (rawFeature, error) {
|
||||||
|
var f rawFeature
|
||||||
|
r := pbReader{b: data}
|
||||||
|
for !r.done() {
|
||||||
|
num, wire, val, d, err := r.field()
|
||||||
|
if err != nil {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
switch num {
|
||||||
|
case 2:
|
||||||
|
if f.tags, err = packed(wire, val, d, f.tags); err != nil {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
f.geomType = val
|
||||||
|
case 4:
|
||||||
|
if f.geometry, err = packed(wire, val, d, f.geometry); err != nil {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.tags)%2 != 0 {
|
||||||
|
return f, errBadTile
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueString is a Value's string_value, "" for any other kind.
|
||||||
|
func valueString(data []byte) string {
|
||||||
|
r := pbReader{b: data}
|
||||||
|
for !r.done() {
|
||||||
|
num, _, _, d, err := r.field()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if num == 1 {
|
||||||
|
return string(d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineGeometry follows the MoveTo/LineTo commands of a (multi)line string.
|
||||||
|
func lineGeometry(cmds []uint32, scale float64) ([][][2]int, error) {
|
||||||
|
var parts [][][2]int
|
||||||
|
var cur [][2]int
|
||||||
|
x, y := 0, 0
|
||||||
|
zig := func(v uint32) int { return int(int32(v>>1) ^ -int32(v&1)) }
|
||||||
|
at := func() [2]int {
|
||||||
|
return [2]int{int(math.Round(float64(x) * scale)), int(math.Round(float64(y) * scale))}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(cmds); {
|
||||||
|
id, count := cmds[i]&7, int(cmds[i]>>3)
|
||||||
|
i++
|
||||||
|
switch id {
|
||||||
|
case 1, 2: // MoveTo, LineTo
|
||||||
|
if i+2*count > len(cmds) {
|
||||||
|
return nil, errBadTile
|
||||||
|
}
|
||||||
|
for k := 0; k < count; k++ {
|
||||||
|
x += zig(cmds[i])
|
||||||
|
y += zig(cmds[i+1])
|
||||||
|
i += 2
|
||||||
|
if id == 1 {
|
||||||
|
if len(cur) >= 2 {
|
||||||
|
parts = append(parts, cur)
|
||||||
|
}
|
||||||
|
cur = [][2]int{at()}
|
||||||
|
} else {
|
||||||
|
if p := at(); len(cur) == 0 || cur[len(cur)-1] != p {
|
||||||
|
cur = append(cur, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 7: // ClosePath, not in a line; ignored
|
||||||
|
default:
|
||||||
|
return nil, errBadTile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cur) >= 2 {
|
||||||
|
parts = append(parts, cur)
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user