The public Overpass answered 429/502 and needs ~40 s a tile while the car gives up after 8 s, so the server cancelled its queries and the car's retries added load. Now the server fetches only the tiles a car asks for, as OpenStreetMap vector tiles from OpenFreeMap (MAP_TILES_URL), decodes the transportation layer with a small reader of its own, and keeps each tile 30 days on a volume (MAP_CACHE_DIR). A fetch finishes even after the car has stopped waiting; the car hears 503 with Retry-After and gets the tile on its next try. App: OpenFreeMap/OpenMapTiles attribution on the map, retry after 15 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Maps serves the roads around the car for the Overview's map (the "Grid with map" style). It
|
||
// fetches only the tiles a car asks for, as OpenStreetMap vector tiles (OpenMapTiles schema,
|
||
// 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":…}
|
||
// → {"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 in the body so the tile stays out of access logs. Points are tile-local
|
||
// in 0..4096 (a little beyond at the edges), simplified to about 1.5 m. Classes: 0 motorway and
|
||
// trunk, 1 primary and secondary, 2 tertiary, 3 minor streets (residential, unclassified,
|
||
// living streets).
|
||
//
|
||
// A fetch runs to its end even when the car stops waiting (the car gives up after a few
|
||
// 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 {
|
||
// TileJSONURL names the vector tiles: a TileJSON document whose first "tiles" template is
|
||
// used (OpenFreeMap moves its template with every weekly build).
|
||
TileJSONURL string
|
||
// CacheDir keeps fetched tiles across restarts; empty keeps them in memory only.
|
||
CacheDir string
|
||
UserAgent string
|
||
Client *http.Client
|
||
|
||
mu sync.Mutex
|
||
memory map[mapTile]mapEntry
|
||
inflight map[mapTile]*mapFlight
|
||
template string
|
||
tmplAt time.Time
|
||
// slots limits fetches running at once.
|
||
slots chan struct{}
|
||
}
|
||
|
||
const (
|
||
mapZoom = 14
|
||
mapExtent = 4096
|
||
mapMemoryTiles = 300
|
||
mapCacheAge = 30 * 24 * time.Hour
|
||
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).
|
||
mapTolerance = 4.0
|
||
// DefaultMapTilesURL is OpenFreeMap's TileJSON: free OpenStreetMap vector tiles, no key.
|
||
DefaultMapTilesURL = "https://tiles.openfreemap.org/planet"
|
||
// mapUserAgent names this service to the tile host.
|
||
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 mapEntry struct {
|
||
body []byte
|
||
at time.Time
|
||
}
|
||
|
||
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{
|
||
"motorway": 0, "trunk": 0,
|
||
"primary": 1, "secondary": 1,
|
||
"tertiary": 2,
|
||
"minor": 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(tileJSONURL, cacheDir, userAgent string) *Maps {
|
||
return &Maps{TileJSONURL: tileJSONURL, CacheDir: cacheDir, UserAgent: userAgent}
|
||
}
|
||
|
||
func (m *Maps) client() *http.Client {
|
||
if m.Client != nil {
|
||
return m.Client
|
||
}
|
||
return &http.Client{Timeout: mapFetchTime}
|
||
}
|
||
|
||
// Tile returns the tile's roads as JSON and whether it came from a cache. Waiting ends with
|
||
// ctx; the fetch itself goes on and is kept.
|
||
func (m *Maps) Tile(ctx context.Context, t mapTile) ([]byte, bool, error) {
|
||
if body, ok := m.cached(t); ok {
|
||
return body, true, nil
|
||
}
|
||
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")
|
||
}
|
||
}
|
||
|
||
func (m *Maps) run(t mapTile, f *mapFlight) {
|
||
defer func() {
|
||
m.mu.Lock()
|
||
delete(m.inflight, t)
|
||
m.mu.Unlock()
|
||
close(f.done)
|
||
}()
|
||
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))
|
||
}
|
||
|
||
func (m *Maps) cachePath(t mapTile) string {
|
||
return filepath.Join(m.CacheDir, fmt.Sprintf("%d_%d_%d.json", t.Z, t.X, t.Y))
|
||
}
|
||
|
||
func (m *Maps) cached(t mapTile) ([]byte, bool) {
|
||
m.mu.Lock()
|
||
e, ok := m.memory[t]
|
||
m.mu.Unlock()
|
||
if ok && time.Since(e.at) <= mapCacheAge {
|
||
return e.body, true
|
||
}
|
||
if m.CacheDir == "" {
|
||
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
|
||
}
|
||
|
||
func (m *Maps) store(t mapTile, body []byte) {
|
||
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) {
|
||
lines, err := decodeRoads(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := roadTile{Z: t.Z, X: t.X, Y: t.Y, Extent: mapExtent, Ways: []road{}}
|
||
for _, l := range lines {
|
||
pts := simplify(l.points, mapTolerance)
|
||
if len(pts) < 2 {
|
||
continue
|
||
}
|
||
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: l.class, P: flat})
|
||
}
|
||
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 := ((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)
|
||
}
|
||
|
||
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")
|
||
}
|
||
// The car waits a few seconds at most; the fetch goes on without it.
|
||
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 {
|
||
slog.Warn("map tile not available yet", "installation", who(c), "error", err)
|
||
w.Header().Set("Retry-After", "10")
|
||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "map data not available yet, try again shortly"})
|
||
return nil
|
||
}
|
||
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
|
||
}
|