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:
+358
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user