App 2.39.0, server 2.7.0: Grid with map

A new Overview style: the Grid over the roads round the car from
OpenStreetMap, drawn in the floor's perspective, heading up, the bigger
roads glowing brighter, the car as a chevron.

Server: POST /v1/map/roads {z:14,x,y} fetches a tile's drivable roads
from Overpass (OVERPASS_URL; one request at a time, at least a second
apart), simplifies them to tile-local points in four classes, caches
them for 30 days and never logs the tile.

App: MapRoads keeps the 3x3 tiles round the car from its own cache or
the server; GPS only while the style is shown; bearing from the GPS or
from successive positions. Without GPS, server or roads the plain floor
says why. OSM attribution on screen and in LICENSE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-26 23:34:21 +01:00
co-authored by Claude Opus 5.5
parent 776a11b182
commit 65dc1ba847
24 changed files with 1312 additions and 18 deletions
+4 -3
View File
@@ -20,7 +20,8 @@ MG4 head unit (Android 9, AAOS, MT2712)
server and to ABRP, and updates itself.
- **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
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
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`.
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
dashboards, Traefik routes). See `k8s/README.md`.
- **EVHardware/** — vendored shared vehicle layer (PolyForm Noncommercial, see `LICENSE.md`).
@@ -75,11 +76,11 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents |
|---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
+6
View File
@@ -18,6 +18,12 @@ Nils' MG4 is a fork of a chain of forks, all MIT:
MIT requires the copyright notice to travel with any copy or substantial portion of the
software. `LICENSE` names all of them; keep every line.
## Map data
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
whenever it draws them. The server fetches them from an Overpass API (`server/maps.go`).
## The EVHardware library
`EVHardware/` (the shared vehicle layer from EVSuite) is source-available under the
+5 -4
View File
@@ -39,8 +39,8 @@ APIs.
## Screenshots
**Overview styles:** the Overview comes in seventeen looks, from an 80s fluorescent display to a
starship console, a watch face, a neon grid, a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a
starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
navigation, and what inspired it.
<p align="center">
@@ -81,14 +81,15 @@ 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
ABRP and the server do not depend on each other. The server only stores. The car authenticates
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
with the server using a token of its own, obtained by pairing with a phone (below).
## The app
| 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, 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. 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"). 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 |
| **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 |
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
+24
View File
@@ -138,6 +138,30 @@ 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
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).
- 2026-10-26 — App 2.39.0 and server 2.7.0: **Grid with map**, a new Overview style — the Grid
over the roads round the car from OpenStreetMap (owner's choice of design: roads through the
car's own server, drawn as the floor). Server: `POST /v1/map/roads` `{z:14, x, y}` (the tile,
not the position, and in the body so it stays out of access logs) fetches the tile's drivable
roads from Overpass (`OVERPASS_URL`, default the public instance, `off` disables; https only;
one request at a time, ≥ 1 s apart, identifying User-Agent), turns them into tile-local points
in 4 classes simplified with Douglas–Peucker (~1.5 m), caches them in memory (30 days, 3000
tiles; failures not cached), gzips when accepted, answers 503 when Overpass fails and never logs
the tile. Tests with a fake Overpass (classes, split at missing points, simplification, cache,
gzip, validation, 503 without caching, no tile in the log, config). App: `MapRoads` keeps the
3×3 tiles round the car (files/map, 30 days, 400 tiles; one at a time on a background thread;
a failed tile waits a minute), `RoadTile` parses them failing closed, `MapGeometry` does the
web-mercator arithmetic (both tested). GPS only while that style is shown and the app is in
front; the bearing from the GPS, or worked out from positions 8 m apart when it gives none.
`GridView` with a map draws the roads in the floor's perspective, heading up, 40 m floor steps,
up to 900 m ahead, far roads fainter, the car as a chevron, its position run on with the speed
between fixes, 20 fps while moving; the trail gives way to the map; "© OpenStreetMap
contributors" shown; otherwise the plain floor saying why (waiting for GPS, set up the server,
loading, not available). `OVERPASS_URL` in `k8s/server.yaml`; LICENSE.md notes the ODbL. Tested:
server unit tests; one real fetch of 3×3 tiles in central Berlin through the server code
(20–30 KB each; Overpass answered 502 a few times and the retry got them); on the emulator those
tiles from the app's cache with emulator GPS, north up and turning with a simulated drive, both
layouts, no GPS, no server; no crash. Not tried: the app fetching tiles from the deployed
server (not deployed yet) and anything on the car.
- 2026-10-26 — App 2.38.0 published (`car-2.38.0`) and installed on the car through the in-app
updater, from 2.35.3 (so Grid, Time circuits, Quest, Visor and Empire are on the car).
- 2026-10-26 — App 2.38.0: Overview styles **Visor** (after Halo's helmet display, `VisorView`)
+17 -2
View File
@@ -1,6 +1,6 @@
# Overview styles
The Overview can look seventeen ways: pick one under **Settings → Overview style**. Every style shows
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
shows "—" for anything the car did not answer, never a made-up zero.
@@ -10,7 +10,7 @@ emulator at the car's resolution with **sample values** (87 km/h in D, 76 %, 18.
emulator has no car; on the car the values are live.
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid and Visor can also show their original
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
(navigation, buttons, switches) takes on its colours too.
@@ -29,6 +29,7 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
| [Chronograph](#chronograph) | a watch face | accent |
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
| [Grid with map](#grid-with-map) | the Grid, with the real roads round the car on its floor | original or accent |
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
| [Quest](#quest) | a fantasy game's interface | own |
| [Visor](#visor) | a soldier's helmet display | original or accent |
@@ -162,6 +163,20 @@ films' other side.
![Grid with the navigation](screenshots/themes/grid-panel.png)
![Grid, full screen](screenshots/themes/grid.png)
## Grid with map
The Grid, with the roads round the car from OpenStreetMap laid on its floor as light lanes, in
the floor's perspective and turned with the direction you drive: motorways glow brightest, then
main roads, then the smaller streets, fading towards the horizon. 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
server, which fetches them from OpenStreetMap 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
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.
![Grid with map, with the navigation](screenshots/themes/grid-map-panel.png)
![Grid with map, full screen](screenshots/themes/grid-map.png)
## Time circuits
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 141
versionName "2.38.0"
versionCode 142
versionName "2.39.0"
// 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.
@@ -22,6 +22,13 @@ import android.os.SystemClock;
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
* 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
* on the floor in its perspective, heading up, the bigger ones glowing brighter, with the car
* as a light marker a little way up the floor; 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
* makes way for the map. Without a fix, a server or the roads, the plain floor with a line
* saying why.
*
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
* low battery is orange in the original (the films' other side) and white in the accent one,
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
@@ -32,6 +39,11 @@ final class GridView extends ClusterCanvasView {
/** Battery segments in the light wall, disc segments, metres per grid tile. */
private static final int WALL = 20, DISC = 60;
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 nearest depth drawn, how far ahead, and the depth from which roads are drawn fainter.
*/
private static final float MAP_UNIT_M = 40f, CAR_DEPTH = 1.6f, NEAR = 0.6f, FAR_M = 900f, FADE_DEPTH = 5f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -44,6 +56,15 @@ final class GridView extends ClusterCanvasView {
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final int accent, accentLight;
/** Null for the plain Grid. */
private final MapRoads map;
/** Per road class, near and far (from {@link #FADE_DEPTH}). */
private final Path[] roads = new Path[RoadTile.CLASSES * 2];
private float heading = Float.NaN;
/** The pen while a road is added: the last point, and whether it continues from there. */
private float penX, penY;
private boolean penDown;
private Path penPath;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -52,7 +73,13 @@ final class GridView extends ClusterCanvasView {
private int skyFor = -1;
GridView(Context context) {
this(context, null);
}
GridView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
@@ -83,16 +110,20 @@ final class GridView extends ClusterCanvasView {
float horizon = h * 0.55f;
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
boolean mapShown = map != null && drawMap(c, w, h, horizon, u);
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
drawGears(c, w, u);
drawClock(c, w, u);
drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u);
drawDisc(c, w, u);
drawTrail(c, w, h, u);
if (!mapShown) drawTrail(c, w, h, u);
drawReadouts(c, w, h, u);
if (mapShown) drawAttribution(c, w, h, u);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
keepAnimating(moving, 33);
// The map costs more per frame: 20 a second is smooth enough for it.
keepAnimating(moving, mapShown ? 50 : 33);
}
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
@@ -101,7 +132,8 @@ final class GridView extends ClusterCanvasView {
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.speedKmh != null && v.speedKmh > 0.5f) {
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
float tile = map != null ? MAP_UNIT_M : TILE_M;
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / tile) % 1f;
}
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
}
@@ -138,6 +170,142 @@ final class GridView extends ClusterCanvasView {
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
}
// ---------- Map ----------
/** The roads round the car on the floor; false when there is nothing to draw. */
private boolean drawMap(Canvas c, float w, float h, float horizon, float u) {
MapRoads.Fix fix = map.fix();
java.util.Collection<RoadTile> tiles = map.tiles();
if (fix == null || tiles.isEmpty()) return false;
// 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;
if (Float.isNaN(heading)) heading = target;
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.2f + 360f) % 360f;
// Between fixes the car runs on with the speed, for at most 2.5 s.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = 0f;
if (v.speedKmh != null && !Float.isNaN(fix.bearing)) {
ran = v.speedKmh / 3.6f * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
}
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
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;
double range = FAR_M / mpu;
for (Path p : roads) p.reset();
for (RoadTile tile : tiles) {
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) {
continue;
}
float fx = (float) ox, fy = (float) oy;
for (int i = 0; i < tile.points.length; i++) {
int[] b = tile.bounds[i];
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
int cls = tile.classes[i];
int[] pts = tile.points[i];
penDown = false;
float pa = 0f, pf = 0f;
for (int k = 0; k < pts.length; k += 2) {
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
float fwd = east * sin + north * cos, side = east * cos - north * sin;
if (k > 0) segment(cls, pa, pf, side, fwd, vx, horizon, lane, depth);
pa = side;
pf = fwd;
}
}
}
c.save();
c.clipRect(0, horizon, w, h);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
// Far roads thin and faint, so the horizon stays a line rather than a tangle.
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x30 : 0x50));
line.setStrokeWidth((cls == 0 ? 1.6f : 1f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0x80));
line.setStrokeWidth(1.4f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xC0));
line.setStrokeWidth(2f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.4f * u);
glow(c, roads[0], light, 3.2f * u);
line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE);
c.restore();
// The car: a light-cycle chevron pointing ahead.
float cy = horizon + depth / CAR_DEPTH;
path.reset();
path.moveTo(vx, cy - 16f * u);
path.lineTo(vx + 11f * u, cy + 10f * u);
path.lineTo(vx, cy + 4f * u);
path.lineTo(vx - 11f * u, cy + 10f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
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 void segment(int cls, float a0, float f0, float a1, float f1, float vx, float horizon, float lane,
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)];
if ((d0 < NEAR && d1 < NEAR) || (f0 > FAR_M && f1 > FAR_M)) {
penDown = false;
return;
}
boolean cut = false;
if (d0 < NEAR || d1 < NEAR) {
float t = (NEAR - d0) / (d1 - d0);
float a = a0 + (a1 - a0) * t, d = NEAR;
if (d0 < NEAR) {
a0 = a;
d0 = d;
cut = true;
} else {
a1 = a;
d1 = d;
}
}
float x0 = vx + a0 / MAP_UNIT_M * lane / d0, y0 = horizon + depth / d0;
float x1 = vx + a1 / MAP_UNIT_M * lane / d1, y1 = horizon + depth / d1;
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
p.lineTo(x1, y1);
penPath = p;
penX = x1;
penY = y1;
penDown = true;
}
/** Why the map is not there, on the floor. */
private void drawMapState(Canvas c, float w, float h, float horizon, float u) {
MapRoads.State state = map.state();
String why = state == MapRoads.State.NO_SERVER ? "MAP · SET UP THE SERVER FIRST"
: state == MapRoads.State.WAITING_FOR_GPS ? "MAP · WAITING FOR GPS"
: state == MapRoads.State.UNAVAILABLE ? "MAP · NOT AVAILABLE, TRYING AGAIN"
: "MAP · LOADING";
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. */
private void drawAttribution(Canvas c, float w, float h, float u) {
text.setTypeface(label);
text.setTextSize(12f * u);
text.setTextAlign(Paint.Align.RIGHT);
text.setStyle(Paint.Style.FILL);
text.setColor(dim);
c.drawText("© OpenStreetMap contributors", w - 26f * u, h - 80f * u, text);
}
// ---------- Top ----------
private void drawGears(Canvas c, float w, float u) {
@@ -70,6 +70,11 @@ public class MainActivity extends AppCompatActivity {
private TerminalView terminalView;
private GridView gridView;
private VisorView visorView;
/** "Grid with map": the same style over the roads round the car (MapRoads, fed by GPS here). */
private GridView gridMapView;
private MapRoads mapRoads;
private android.location.LocationListener mapListener;
private boolean inFront;
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
private View[] stylePanes;
private View consumptionPane;
@@ -345,9 +350,11 @@ public class MainActivity extends AppCompatActivity {
mgPane = drawnPane(octagonView);
terminalView = new TerminalView(this);
gridView = new GridView(this);
mapRoads = new MapRoads(this);
gridMapView = new GridView(this, mapRoads);
visorView = new VisorView(this);
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
new NixieView(this), new ChronographView(this), gridView, new TimeCircuitsView(this),
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
new QuestView(this), visorView, new EmpireView(this));
View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
@@ -487,6 +494,8 @@ public class MainActivity extends AppCompatActivity {
// Poll state and log only while the screen is up; onPause cancels it.
uiHandler.post(uiRefresh);
uiHandler.post(overviewRefresh);
inFront = true;
updateMapLocation();
}
@Override
@@ -534,6 +543,8 @@ public class MainActivity extends AppCompatActivity {
// Stop polling off-screen: this activity has no reason to spend cycles then.
uiHandler.removeCallbacks(uiRefresh);
uiHandler.removeCallbacks(overviewRefresh);
inFront = false;
updateMapLocation();
}
@Override
@@ -1725,9 +1736,56 @@ public class MainActivity extends AppCompatActivity {
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
gridMapView.setAccentPalette(!originalColours(OverviewStyle.GRID_MAP));
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
if (settingsPane != null) bindAccentColor();
updateMapLocation();
}
/**
* GPS for the Grid's map, only while "Grid with map" is the style and the app is in front;
* every fix goes to {@link MapRoads}. Without the location permission there is no map (the
* floor says it waits for GPS), never a crash.
*/
private void updateMapLocation() {
android.location.LocationManager lm =
(android.location.LocationManager) getSystemService(LOCATION_SERVICE);
if (lm == null || mapRoads == null) return;
boolean wanted = inFront && overviewStyle() == OverviewStyle.GRID_MAP;
if (!wanted) {
if (mapListener != null) {
try {
lm.removeUpdates(mapListener);
} catch (RuntimeException ignored) {
// already gone
}
mapListener = null;
}
return;
}
if (mapListener != null) return;
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION)
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
return;
}
android.location.LocationListener listener = new android.location.LocationListener() {
@Override public void onLocationChanged(android.location.Location location) { mapRoads.onFix(location); }
@Override public void onStatusChanged(String provider, int status, android.os.Bundle extras) { }
@Override public void onProviderEnabled(String provider) { }
@Override public void onProviderDisabled(String provider) { }
};
try {
lm.requestLocationUpdates(android.location.LocationManager.GPS_PROVIDER, 1000L, 0f, listener);
mapListener = listener;
android.location.Location last = lm.getLastKnownLocation(android.location.LocationManager.GPS_PROVIDER);
if (last != null && android.os.SystemClock.elapsedRealtimeNanos() - last.getElapsedRealtimeNanos()
< MapRoads.FIX_FRESH_MS * 1_000_000L) {
mapRoads.onFix(last);
}
} catch (RuntimeException e) {
android.util.Log.w("MainActivity", "No GPS for the map: " + e.getClass().getSimpleName());
}
}
/**
@@ -1757,6 +1815,7 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
getString(R.string.overview_style_nixie),
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
getString(R.string.overview_style_grid_map),
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
}
@@ -0,0 +1,63 @@
package com.evsuite.abrp;
/**
* Web-mercator arithmetic for the Grid's map: which tile the car is in, positions in the
* server's tile units (4096 across a zoom-14 tile) and metres per unit. Pure Java, unit-tested.
*/
final class MapGeometry {
static final int ZOOM = 14;
static final int EXTENT = 4096;
/** Tile units across the world at {@link #ZOOM}. */
static final double WORLD = (double) (1 << ZOOM) * EXTENT;
private static final double EQUATOR_M = 40_075_016.686;
private MapGeometry() {}
/** The position east, in tile units from the date line. */
static double worldX(double lon) {
return (lon + 180d) / 360d * WORLD;
}
/** The position south, in tile units from the top of the map. */
static double worldY(double lat) {
double r = Math.toRadians(lat);
return (1d - Math.log(Math.tan(r) + 1d / Math.cos(r)) / Math.PI) / 2d * WORLD;
}
static int tileX(double lon) {
return clampTile((int) Math.floor(worldX(lon) / EXTENT));
}
static int tileY(double lat) {
return clampTile((int) Math.floor(worldY(lat) / EXTENT));
}
private static int clampTile(int t) {
return Math.max(0, Math.min((1 << ZOOM) - 1, t));
}
/** Metres per tile unit on the ground at this latitude. */
static double metresPerUnit(double lat) {
return EQUATOR_M * Math.cos(Math.toRadians(lat)) / WORLD;
}
/** One number for a tile, for maps and file names. */
static long key(int x, int y) {
return ((long) x << 20) | y;
}
static int keyX(long key) {
return (int) (key >> 20);
}
static int keyY(long key) {
return (int) (key & 0xFFFFF);
}
/** Whether the position is one a map can show (web mercator stops short of the poles). */
static boolean usable(double lat, double lon) {
return Double.isFinite(lat) && Double.isFinite(lon) && Math.abs(lat) < 85d && Math.abs(lon) <= 180d
&& !(lat == 0d && lon == 0d);
}
}
@@ -0,0 +1,198 @@
package com.evsuite.abrp;
import android.content.Context;
import android.location.Location;
import android.os.SystemClock;
import android.util.Log;
import org.json.JSONObject;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* The roads around the car for the Grid's map, and where the car is: the activity hands in
* GPS fixes ({@link #onFix}) while "Grid with map" is shown; this keeps the 3×3 tiles round
* the car loaded from the app's own cache (files/map, 30 days, at most {@value #DISK_TILES}
* tiles) or from the car's server ({@code POST /v1/map/roads}, server/maps.go), one at a time
* on a background thread, and drops tiles from memory once the car is two tiles away. The car
* asks only its own server, naming a tile rather than its position. Without a configured
* server, a fix or the roads, the Grid shows its plain floor ({@link #state}).
*/
final class MapRoads {
/** Why the map shows nothing (or {@link #SHOWING}). */
enum State { NO_SERVER, WAITING_FOR_GPS, LOADING, UNAVAILABLE, SHOWING }
/** One GPS fix: where, which way (degrees from north, NaN before the car has moved), when. */
static final class Fix {
final double lat, lon;
final float bearing;
final long elapsedMs;
Fix(double lat, double lon, float bearing, long elapsedMs) {
this.lat = lat;
this.lon = lon;
this.bearing = bearing;
this.elapsedMs = elapsedMs;
}
}
private static final String TAG = "MapRoads";
private static final int DISK_TILES = 400;
private static final long DISK_AGE_MS = 30L * 24 * 60 * 60 * 1000;
private static final long RETRY_MS = 60_000L;
/** A fix older than this is not drawn from. */
static final long FIX_FRESH_MS = 15_000L;
private final Context context;
private final File dir;
private final Map<Long, RoadTile> tiles = new ConcurrentHashMap<>();
private final Map<Long, Long> failedAt = new ConcurrentHashMap<>();
private final Map<Long, Boolean> queued = new ConcurrentHashMap<>();
private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "map-roads");
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
private volatile Fix fix;
/** The last bearing while moving; GPS has none at a standstill. */
private volatile float bearing = Float.NaN;
private volatile boolean serverMissing;
/** Where the bearing was last worked out from, for a GPS that gives none. */
private Location anchor;
MapRoads(Context context) {
this.context = context.getApplicationContext();
dir = new File(this.context.getFilesDir(), "map");
}
/** A GPS fix. Main thread; the loading happens on the worker. */
void onFix(Location location) {
if (location == null || !MapGeometry.usable(location.getLatitude(), location.getLongitude())) return;
if (location.hasBearing() && location.hasSpeed() && location.getSpeed() > 1.5f) {
bearing = location.getBearing();
anchor = location;
} else if (anchor == null || location.getElapsedRealtimeNanos() - anchor.getElapsedRealtimeNanos() > 10_000_000_000L) {
anchor = location;
} else if (anchor.distanceTo(location) >= 8f) {
// No bearing from the GPS: the way from the last place 8 m or more behind.
bearing = (anchor.bearingTo(location) + 360f) % 360f;
anchor = location;
}
Fix f = new Fix(location.getLatitude(), location.getLongitude(), bearing, SystemClock.elapsedRealtime());
fix = f;
want(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
}
/** The latest fix, or null when there is none or it is stale. */
Fix fix() {
Fix f = fix;
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
}
Collection<RoadTile> tiles() {
return tiles.values();
}
State state() {
if (serverMissing) return State.NO_SERVER;
Fix f = fix();
if (f == null) return State.WAITING_FOR_GPS;
long here = MapGeometry.key(MapGeometry.tileX(f.lon), MapGeometry.tileY(f.lat));
if (tiles.containsKey(here)) return State.SHOWING;
return failedAt.containsKey(here) ? State.UNAVAILABLE : State.LOADING;
}
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
private void want(int cx, int cy) {
for (Long key : tiles.keySet()) {
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key);
}
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
long now = SystemClock.elapsedRealtime();
for (int[] d : order) {
int x = cx + d[0], y = cy + d[1];
if (x < 0 || y < 0 || x >= (1 << MapGeometry.ZOOM) || y >= (1 << MapGeometry.ZOOM)) continue;
long key = MapGeometry.key(x, y);
if (tiles.containsKey(key) || queued.containsKey(key)) continue;
Long failed = failedAt.get(key);
if (failed != null && now - failed < RETRY_MS) continue;
queued.put(key, Boolean.TRUE);
worker.execute(() -> {
try {
load(key, x, y);
} finally {
queued.remove(key);
}
});
}
}
/** From the cache when fresh enough, else from the server; a stale copy beats nothing. */
private void load(long key, int x, int y) {
File file = new File(dir, MapGeometry.ZOOM + "_" + x + "_" + y + ".json");
RoadTile cached = null;
try {
if (file.isFile()) {
cached = RoadTile.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8), x, y);
if (cached == null) file.delete();
}
} catch (Exception e) {
Log.w(TAG, "Cached map tile unreadable: " + e.getClass().getSimpleName());
}
if (cached != null) {
tiles.put(key, cached);
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
}
ServerConfig config = ServerConfig.get(context);
if (!config.isConfigured()) {
serverMissing = true;
return;
}
serverMissing = false;
try {
JSONObject body = new JSONObject().put("z", MapGeometry.ZOOM).put("x", x).put("y", y);
ServerHttp.Response r = ServerHttp.request("POST", config.url + "/v1/map/roads", config.token, body);
RoadTile tile = r.status == 200 ? RoadTile.parse(r.body, x, y) : null;
if (tile == null) {
// The tile stays out of the log: it says where the car is.
Log.w(TAG, "Map tile not loaded: HTTP " + r.status);
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
return;
}
failedAt.remove(key);
tiles.put(key, tile);
save(file, r.body);
} catch (Exception e) {
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
if (cached == null) failedAt.put(key, SystemClock.elapsedRealtime());
}
}
private void save(File file, String body) {
try {
if (!dir.isDirectory() && !dir.mkdirs()) return;
File tmp = new File(dir, file.getName() + ".tmp");
try (FileOutputStream out = new FileOutputStream(tmp)) {
out.write(body.getBytes(StandardCharsets.UTF_8));
}
if (!tmp.renameTo(file)) tmp.delete();
File[] all = dir.listFiles((d, name) -> name.endsWith(".json"));
if (all != null && all.length > DISK_TILES) {
Arrays.sort(all, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
for (int i = 0; i < all.length - DISK_TILES; i++) all[i].delete();
}
} catch (Exception e) {
Log.w(TAG, "Map tile not cached: " + e.getClass().getSimpleName());
}
}
}
@@ -27,6 +27,7 @@ enum OverviewStyle {
NIXIE(false, false),
CHRONOGRAPH(true, false),
GRID(true, true),
GRID_MAP(true, true),
TIME_CIRCUITS(false, false),
QUEST(false, false),
VISOR(true, true),
@@ -53,7 +54,8 @@ enum OverviewStyle {
/** Where the choice between the original palette and the accent colour is kept. */
String originalKey() {
return "original_colours_" + name();
// Grid with map is the Grid over a map: one choice for both.
return "original_colours_" + (this == GRID_MAP ? GRID : this).name();
}
/**
@@ -0,0 +1,76 @@
package com.evsuite.abrp;
import org.json.JSONArray;
import org.json.JSONObject;
/**
* The roads of one map tile as the server sends them (server/maps.go): per road its class
* (0 motorway and trunk, 1 primary and secondary, 2 tertiary and unclassified, 3 residential)
* and its points in tile units, plus each road's bounding box for skipping far ones quickly.
* {@link #parse} fails closed: anything unexpected is no tile at all. Pure Java, unit-tested.
*/
final class RoadTile {
static final int CLASSES = 4;
/** Points may lie a little beyond the tile (a road is kept to its first point outside). */
private static final int SLACK = MapGeometry.EXTENT;
private static final int MAX_ROADS = 20_000;
final int x, y;
final int[] classes;
/** Per road: x0, y0, x1, y1, … in tile units. */
final int[][] points;
/** Per road: minX, minY, maxX, maxY. */
final int[][] bounds;
private RoadTile(int x, int y, int[] classes, int[][] points, int[][] bounds) {
this.x = x;
this.y = y;
this.classes = classes;
this.points = points;
this.bounds = bounds;
}
/** The tile in {@code json}, which must be the one asked for; null when it is not usable. */
static RoadTile parse(String json, int expectX, int expectY) {
try {
JSONObject o = new JSONObject(json);
if (o.getInt("z") != MapGeometry.ZOOM || o.getInt("x") != expectX || o.getInt("y") != expectY
|| o.getInt("extent") != MapGeometry.EXTENT) {
return null;
}
JSONArray ways = o.getJSONArray("ways");
if (ways.length() > MAX_ROADS) return null;
int n = ways.length();
int[] classes = new int[n];
int[][] points = new int[n][];
int[][] bounds = new int[n][];
for (int i = 0; i < n; i++) {
JSONObject way = ways.getJSONObject(i);
int c = way.getInt("c");
JSONArray p = way.getJSONArray("p");
if (c < 0 || c >= CLASSES || p.length() < 4 || p.length() % 2 != 0) return null;
int[] pts = new int[p.length()];
int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
for (int k = 0; k < pts.length; k++) {
int value = p.getInt(k);
if (value < -SLACK || value > MapGeometry.EXTENT + SLACK) return null;
pts[k] = value;
if (k % 2 == 0) {
minX = Math.min(minX, value);
maxX = Math.max(maxX, value);
} else {
minY = Math.min(minY, value);
maxY = Math.max(maxY, value);
}
}
classes[i] = c;
points[i] = pts;
bounds[i] = new int[] { minX, minY, maxX, maxY };
}
return new RoadTile(expectX, expectY, classes, points, bounds);
} catch (Exception e) {
return null;
}
}
}
@@ -18,7 +18,8 @@ final class StylePalette {
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
case GRID: return R.style.ThemeOverlay_EV_Style_Grid;
case GRID:
case GRID_MAP: return R.style.ThemeOverlay_EV_Style_Grid;
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
+1
View File
@@ -136,6 +136,7 @@
<string name="overview_style_terminal">Terminal</string>
<string name="accent_original">Original</string>
<string name="overview_style_grid">Grid</string>
<string name="overview_style_grid_map">Grid with map</string>
<string name="overview_style_time_circuits">Time circuits</string>
<string name="overview_style_quest">Quest</string>
<string name="overview_style_visor">Visor</string>
@@ -0,0 +1,65 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
/** The Grid map's tile arithmetic and the server's road tiles — pure JVM. */
public class MapGeometryTest {
@Test
public void tilesMatchTheServer() {
// Alexanderplatz, Berlin: the tile the server tests use too.
assertEquals(8802, MapGeometry.tileX(13.4125));
assertEquals(5373, MapGeometry.tileY(52.5219));
long key = MapGeometry.key(8802, 5373);
assertEquals(8802, MapGeometry.keyX(key));
assertEquals(5373, MapGeometry.keyY(key));
}
@Test
public void metresPerUnitShrinkTowardsThePoles() {
// A zoom-14 tile is about 2.4 km across at the equator, in 4096 units.
assertEquals(0.597, MapGeometry.metresPerUnit(0), 0.001);
assertEquals(0.363, MapGeometry.metresPerUnit(52.52), 0.001);
}
@Test
public void unusablePositions() {
assertFalse(MapGeometry.usable(Double.NaN, 13));
assertFalse(MapGeometry.usable(0, 0)); // a GPS that has nothing yet
assertFalse(MapGeometry.usable(89, 13));
assertTrue(MapGeometry.usable(52.52, 13.41));
}
@Test
public void roadTilesParse() {
RoadTile t = RoadTile.parse("{\"z\":14,\"x\":8802,\"y\":5373,\"extent\":4096,\"ways\":["
+ "{\"c\":1,\"p\":[-115,1236,82,1080,100,1000]},{\"c\":3,\"p\":[0,0,10,10]}]}", 8802, 5373);
assertNotNull(t);
assertEquals(2, t.classes.length);
assertEquals(1, t.classes[0]);
assertEquals(6, t.points[0].length);
assertEquals(-115, t.bounds[0][0]);
assertEquals(1000, t.bounds[0][1]);
assertEquals(100, t.bounds[0][2]);
assertEquals(1236, t.bounds[0][3]);
}
@Test
public void badRoadTilesAreNoTile() {
String head = "{\"z\":14,\"x\":1,\"y\":2,\"extent\":4096,\"ways\":";
assertNotNull(RoadTile.parse(head + "[]}", 1, 2));
assertNull("another tile", RoadTile.parse(head + "[]}", 1, 3));
assertNull("another zoom", RoadTile.parse("{\"z\":13,\"x\":1,\"y\":2,\"extent\":4096,\"ways\":[]}", 1, 2));
assertNull("unknown class", RoadTile.parse(head + "[{\"c\":7,\"p\":[0,0,1,1]}]}", 1, 2));
assertNull("one point", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0]}]}", 1, 2));
assertNull("odd count", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0,1]}]}", 1, 2));
assertNull("far outside", RoadTile.parse(head + "[{\"c\":1,\"p\":[0,0,99999,1]}]}", 1, 2));
assertNull("not JSON", RoadTile.parse("<html>", 1, 2));
}
}
@@ -63,5 +63,8 @@ public class OverviewStyleTest {
assertEquals(R.style.ThemeOverlay_EV_Style_Retro,
StylePalette.appOverlay(OverviewStyle.RETRO, true, AccentColor.HOLBORN_BLUE));
assertEquals("original_colours_RETRO", OverviewStyle.RETRO.originalKey());
// Grid with map shares the Grid's choice.
assertEquals(OverviewStyle.GRID.originalKey(), OverviewStyle.GRID_MAP.originalKey());
assertEquals(StylePalette.overlay(OverviewStyle.GRID), StylePalette.overlay(OverviewStyle.GRID_MAP));
}
}
+3
View File
@@ -50,6 +50,9 @@ spec:
value: https://git.stinnesbeck.com
- name: FORGE_REPO
value: nils/mg4
# The roads for the app's Grid map, from OpenStreetMap; "off" switches maps off.
- name: OVERPASS_URL
value: https://overpass-api.de/api/interpreter
- name: INFLUX_TOKEN
valueFrom:
secretKeyRef:
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+16
View File
@@ -23,6 +23,7 @@ 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) |
| `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 |
| `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 |
| `LISTEN_ADDR` | default `:8080` |
## Endpoints
@@ -57,6 +58,21 @@ shared ingest token): `update check` (how many releases, the newest pre-release)
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
download cut short`. The forge's address and token never appear in the log.
## Map roads
`POST /v1/map/roads` `{z, x, y}`, with the car's token: the drivable roads (motorway to
residential) of one web-mercator tile at zoom 14, for the app's "Grid with map" style. The car
names 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,
1 primary and secondary, 2 tertiary and unclassified, 3 residential and living streets) and its
points in tile units, simplified to about 1.5 m; gzipped when the car accepts it (20–30 KB a tile
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
for 30 days (up to 3000 tiles); the car keeps its own copy too. 400 for anything but zoom 14 or
a tile outside it, 503 when Overpass is busy or unreachable (not cached, the car tries again a
minute later), 404 when maps are off. The log says `map tile fetched` with the car's installation
and the size, never the tile. Map data © OpenStreetMap contributors, ODbL.
## Settings from a phone
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
+4
View File
@@ -20,6 +20,7 @@ import (
// GET /v1/health unauthenticated liveness probe
// /v1/settings/…, /settings settings typed on a phone (handoff_http.go)
// GET /v1/app/… app updates from the forge (releases.go)
// POST /v1/map/roads the roads of one map tile (maps.go)
//
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
// (pairing_http.go).
@@ -31,6 +32,8 @@ type API struct {
Handoff *Handoff
// Releases relays the app's releases from the private forge to the car (releases.go).
Releases *Releases
// Maps serves the roads for the Overview's map from OpenStreetMap (maps.go); nil is off.
Maps *Maps
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
// derived from the request when empty.
PublicURL string
@@ -50,6 +53,7 @@ func (a *API) Routes() http.Handler {
a.pairingRoutes(mux)
a.handoffRoutes(mux)
a.releaseRoutes(mux)
a.mapRoutes(mux)
return mux
}
+19 -1
View File
@@ -34,6 +34,7 @@ type config struct {
forgeURL string
forgeRepo string
forgeToken string
overpassURL string
}
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
@@ -51,6 +52,7 @@ func loadConfig(getenv func(string) string) (config, error) {
forgeURL: getenv("FORGE_URL"),
forgeRepo: getenv("FORGE_REPO"),
forgeToken: getenv("FORGE_TOKEN"),
overpassURL: getenv("OVERPASS_URL"),
}
if c.listenAddr == "" {
c.listenAddr = ":8080"
@@ -73,6 +75,18 @@ func loadConfig(getenv func(string) string) (config, error) {
if c.forgeURL != "" && !strings.HasPrefix(c.forgeURL, "https://") {
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
// with "off". Only https, like the forge.
switch c.overpassURL {
case "":
c.overpassURL = DefaultOverpassURL
case "off":
c.overpassURL = ""
default:
if !strings.HasPrefix(c.overpassURL, "https://") {
return config{}, errors.New("OVERPASS_URL must be an https URL or \"off\"")
}
}
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
}
@@ -102,6 +116,9 @@ func main() {
releases := &Releases{ForgeURL: cfg.forgeURL, Repo: cfg.forgeRepo, Token: cfg.forgeToken}
api := &API{Store: store, IngestToken: cfg.ingestToken, Pairing: pairing, Handoff: NewHandoff(),
Releases: releases, PublicURL: cfg.publicURL}
if cfg.overpassURL != "" {
api.Maps = NewMaps(cfg.overpassURL, mapUserAgent)
}
srv := &http.Server{
Addr: cfg.listenAddr,
@@ -116,7 +133,8 @@ func main() {
defer stop()
go func() {
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled())
"pairing", pairing.Enabled(), "pairedDevices", len(devices), "appUpdates", releases.enabled(),
"maps", api.Maps != nil)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("server failed", "error", err)
os.Exit(1)
+358
View File
@@ -0,0 +1,358 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style),
// taken from OpenStreetMap through an Overpass API and cached here, so the car only ever
// talks to this server and asks Overpass nothing itself.
//
// POST /v1/map/roads {"z":14,"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
// its position, and the request goes in the body so the tile stays out of access logs. Points
// are tile-local in 0..4096 (they may lie a little outside: a road is kept up to the first
// point beyond the edge), simplified to about 1.5 m. Classes: 0 motorway and trunk, 1 primary
// and secondary, 2 tertiary and unclassified, 3 residential and living streets.
//
// Overpass is asked one tile at a time, at most once a second; a tile is kept for 30 days,
// up to mapCacheTiles of them. The tile's coordinates never appear in the log.
type Maps struct {
OverpassURL string
UserAgent string
Client *http.Client
mu sync.Mutex
tiles map[mapTile]mapEntry
fetchMu sync.Mutex // one Overpass request at a time
lastFetch time.Time
gap time.Duration
}
const (
mapZoom = 14
mapExtent = 4096
mapCacheTiles = 3000
mapCacheAge = 30 * 24 * time.Hour
maxOverpassBytes = 32 << 20
// The simplification tolerance in tile units (about 0.37 m each at 52° N).
mapTolerance = 4.0
// DefaultOverpassURL is the public instance of the Overpass API.
DefaultOverpassURL = "https://overpass-api.de/api/interpreter"
// mapUserAgent names this service to Overpass, as its usage policy asks.
mapUserAgent = "nils-mg4-server/2.7 (private car dashboard; roads for an in-car map)"
)
type mapTile struct{ Z, X, Y int }
type mapEntry struct {
body []byte
at time.Time
}
// roadClasses maps OSM's highway values to the four classes the car draws.
var roadClasses = map[string]int{
"motorway": 0, "motorway_link": 0, "trunk": 0, "trunk_link": 0,
"primary": 1, "primary_link": 1, "secondary": 1, "secondary_link": 1,
"tertiary": 2, "tertiary_link": 2, "unclassified": 2,
"residential": 3, "living_street": 3,
}
type roadTile struct {
Z int `json:"z"`
X int `json:"x"`
Y int `json:"y"`
Extent int `json:"extent"`
Ways []road `json:"ways"`
}
type road struct {
C int `json:"c"`
P []int `json:"p"`
}
func NewMaps(overpassURL, userAgent string) *Maps {
return &Maps{OverpassURL: overpassURL, UserAgent: userAgent, gap: time.Second}
}
func (m *Maps) client() *http.Client {
if m.Client != nil {
return m.Client
}
return &http.Client{Timeout: 60 * time.Second}
}
// tileBounds is the tile's south, west, north and east edge in degrees.
func tileBounds(t mapTile) (s, w, n, e float64) {
scale := math.Exp2(float64(t.Z))
lon := func(x float64) float64 { return x/scale*360 - 180 }
lat := func(y float64) float64 { return math.Atan(math.Sinh(math.Pi*(1-2*y/scale))) * 180 / math.Pi }
return lat(float64(t.Y + 1)), lon(float64(t.X)), lat(float64(t.Y)), lon(float64(t.X + 1))
}
// tilePoint is a position in the tile's own units: 0..extent across, y downwards.
func tilePoint(t mapTile, lat, lon float64) (int, int) {
scale := math.Exp2(float64(t.Z)) * mapExtent
x := (lon + 180) / 360 * scale
r := lat * math.Pi / 180
y := (1 - math.Log(math.Tan(r)+1/math.Cos(r))/math.Pi) / 2 * scale
return int(math.Round(x - float64(t.X)*mapExtent)), int(math.Round(y - float64(t.Y)*mapExtent))
}
// overpassQuery asks for the drivable roads in the tile, with a small margin so that roads
// crossing its edge reach a point beyond it.
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 {
Elements []struct {
Type string `json:"type"`
Tags map[string]string `json:"tags"`
Geometry []*struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"geometry"`
} `json:"elements"`
}
// buildTile turns Overpass's answer into the tile the car reads. A missing point (outside
// the clipped geometry) splits a road in two.
func buildTile(t mapTile, raw []byte) ([]byte, error) {
var res overpassResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("overpass answer: %w", err)
}
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
for _, el := range res.Elements {
class, ok := roadClasses[el.Tags["highway"]]
if el.Type != "way" || !ok {
continue
}
var run [][2]int
flush := func() {
if pts := simplify(run, mapTolerance); len(pts) >= 2 {
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 {
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)
}
// simplify is Douglas–Peucker: the points that keep the line within tol of the original.
func simplify(pts [][2]int, tol float64) [][2]int {
if len(pts) < 3 {
return pts
}
keep := make([]bool, len(pts))
keep[0], keep[len(pts)-1] = true, true
type span struct{ a, b int }
stack := []span{{0, len(pts) - 1}}
for len(stack) > 0 {
s := stack[len(stack)-1]
stack = stack[:len(stack)-1]
best, at := 0.0, -1
for i := s.a + 1; i < s.b; i++ {
if d := segmentDistance(pts[i], pts[s.a], pts[s.b]); d > best {
best, at = d, i
}
}
if at >= 0 && best > tol {
keep[at] = true
stack = append(stack, span{s.a, at}, span{at, s.b})
}
}
var out [][2]int
for i, k := range keep {
if k {
out = append(out, pts[i])
}
}
return out
}
func segmentDistance(p, a, b [2]int) float64 {
px, py := float64(p[0]), float64(p[1])
ax, ay, bx, by := float64(a[0]), float64(a[1]), float64(b[0]), float64(b[1])
dx, dy := bx-ax, by-ay
if dx == 0 && dy == 0 {
return math.Hypot(px-ax, py-ay)
}
u := math.Max(0, math.Min(1, ((px-ax)*dx+(py-ay)*dy)/(dx*dx+dy*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) {
mux.HandleFunc("POST /v1/map/roads", a.ingest(a.mapRoads))
}
func (a *API) mapRoads(w http.ResponseWriter, r *http.Request, c caller, body []byte) error {
if a.Maps == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "maps are not enabled"})
return nil
}
var req struct {
Z, X, Y *int
}
if err := json.Unmarshal(body, &req); err != nil || req.Z == nil || req.X == nil || req.Y == nil {
return invalid("z, x and y are required")
}
if *req.Z != mapZoom {
return invalid("z must be %d", mapZoom)
}
limit := 1 << mapZoom
if *req.X < 0 || *req.X >= limit || *req.Y < 0 || *req.Y >= limit {
return invalid("x and y must be within the zoom level")
}
t := mapTile{Z: *req.Z, X: *req.X, Y: *req.Y}
data, cached, err := a.Maps.Tile(r.Context(), t)
if err != nil {
slog.Warn("map tile not available", "installation", who(c), "error", err)
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available, try again later"})
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")
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, _ = zw.Write(data)
_ = zw.Close()
w.Header().Set("Content-Encoding", "gzip")
data = buf.Bytes()
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// A tile in central Berlin.
var testTile = mapTile{Z: 14, X: 8802, Y: 5373}
// fakeOverpass answers every query with a primary road (with a gap in its geometry), a
// 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 {
t.Helper()
s, w, n, e := tileBounds(testTile)
lat := func(f float64) float64 { return s + (n-s)*f }
lon := func(f float64) float64 { return w + (e-w)*f }
answer := fmt.Sprintf(`{"elements":[
{"type":"way","tags":{"highway":"primary"},"geometry":[
{"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 {
api, _ := newTestAPI()
api.Maps = NewMaps(overpass.URL, mapUserAgent)
api.Maps.Client = overpass.Client()
api.Maps.gap = 0
return api.Routes()
}
func tileBody(t mapTile) string {
return fmt.Sprintf(`{"z":%d,"x":%d,"y":%d}`, t.Z, t.X, t.Y)
}
func TestMapTileRoadsAreClassifiedSplitAndSimplified(t *testing.T) {
var calls atomic.Int32
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 {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
var got roadTile
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Extent != 4096 || got.X != testTile.X || got.Y != testTile.Y {
t.Fatalf("tile %+v", got)
}
// The primary road in two pieces, the street straightened to its ends, no footway.
if len(got.Ways) != 3 || got.Ways[0].C != 1 || got.Ways[1].C != 1 || got.Ways[2].C != 3 {
t.Fatalf("ways %+v", got.Ways)
}
if len(got.Ways[2].P) != 4 {
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.
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
if calls.Load() != 1 {
t.Fatalf("overpass asked %d times", calls.Load())
}
}
func TestMapTilesAreGzippedWhenTheCarAcceptsIt(t *testing.T) {
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusOK)
defer overpass.Close()
req := httptest.NewRequest("POST", "/v1/map/roads", strings.NewReader(tileBody(testTile)))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
mapsAPI(overpass).ServeHTTP(rec, req)
if rec.Code != 200 || rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("%d %v", rec.Code, rec.Header())
}
zr, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatal(err)
}
plain, _ := io.ReadAll(zr)
if !bytes.Contains(plain, []byte(`"extent":4096`)) {
t.Fatalf("%s", plain)
}
}
func TestMapRequestsAreChecked(t *testing.T) {
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusOK)
defer overpass.Close()
h := mapsAPI(overpass)
for _, body := range []string{`{}`, `{"z":15,"x":1,"y":1}`, `{"z":14,"x":-1,"y":1}`,
`{"z":14,"x":1,"y":16384}`, `not json`} {
if rec := do(t, h, "POST", "/v1/map/roads", testToken, body); rec.Code != 400 {
t.Errorf("%s: %d", body, rec.Code)
}
}
if calls.Load() != 0 {
t.Fatal("a bad request reached Overpass")
}
}
func TestABusyOverpassIsATemporaryFailureAndNotCached(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
var calls atomic.Int32
overpass := fakeOverpass(t, &calls, http.StatusTooManyRequests)
defer overpass.Close()
h := mapsAPI(overpass)
for i := 0; i < 2; i++ {
if rec := do(t, h, "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 503 {
t.Fatalf("%d", rec.Code)
}
}
if calls.Load() != 2 {
t.Fatalf("a failure was cached: %d calls", calls.Load())
}
// Where the car is stays out of the log.
out := logged.String()
if !strings.Contains(out, "overpass answered 429") || strings.Contains(out, "8802") ||
strings.Contains(out, "5373") {
t.Fatalf("log:\n%s", out)
}
}
func TestMapsAreOffWithoutOverpass(t *testing.T) {
api, _ := newTestAPI()
if rec := do(t, api.Routes(), "POST", "/v1/map/roads", testToken, tileBody(testTile)); rec.Code != 404 {
t.Fatalf("%d", rec.Code)
}
}
func TestOverpassURLConfiguration(t *testing.T) {
base := map[string]string{"INFLUX_URL": "http://influx", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
"INGEST_TOKEN": "0123456789abcdef01234567"}
load := func(overpass string) (config, error) {
return loadConfig(func(k string) string {
if k == "OVERPASS_URL" {
return overpass
}
return base[k]
})
}
if c, err := load(""); err != nil || c.overpassURL != DefaultOverpassURL {
t.Fatalf("default: %q %v", c.overpassURL, err)
}
if c, err := load("off"); err != nil || c.overpassURL != "" {
t.Fatalf("off: %q %v", c.overpassURL, err)
}
if _, err := load("http://overpass.example.com/api/interpreter"); err == nil {
t.Fatal("plain http accepted")
}
}
func TestTilePointsAndSimplification(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}}
got := simplify(line, 4)
if len(got) != 3 || got[1] != [2]int{20, 0} {
t.Fatalf("%v", got)
}
}