rd13_tile_server/services/marker-api/main.go

271 lines
7.5 KiB
Go
Raw Normal View History

package main
import (
"bytes"
"image"
"image/color"
"image/png"
"log"
"math"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
)
// URL-Muster: /styles/v4/marker/{type}+{hexcolor}(@{scale}x).png
var markerPattern = regexp.MustCompile(
`^/styles/v4/marker/([a-z-]+)\+([0-9a-fA-F]{6})(?:@(\d+(?:\.\d+)?x))?\.png$`,
)
// Basis-Abmessungen (Breite × Höhe) entspricht Maptiler-Standard.
// Der Pin ankert an der Spitze (bottom-center), nicht am Bildmittelpunkt.
type pinDims struct{ w, h float64 }
// Maße MÜSSEN exakt den Werten in Kartographer's mapbox-lib.js entsprechen:
// sizes = { small: [20,50], medium: [30,70], large: [35,90] }
// Leaflet rendert das Bild per CSS auf genau diese Pixelgröße.
// Abweichungen → verzerrte Form (27×41 → 30×70 streckt Spitze um 71% vertikal).
var baseSizes = map[string]pinDims{
"pin-s": {20, 50},
"pin-m": {30, 70}, // mapbox-lib.js: sizes.medium = [30, 70]
"pin-l": {35, 90},
}
// Marker-Cache (max 500 Einträge, reicht für alle Farb/Typ/Scale-Kombinationen)
var (
markerCache = make(map[string][]byte)
markerCacheMu sync.RWMutex
)
func hexToColor(h string) color.RGBA {
r, _ := strconv.ParseUint(h[0:2], 16, 8)
g, _ := strconv.ParseUint(h[2:4], 16, 8)
b, _ := strconv.ParseUint(h[4:6], 16, 8)
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
}
// fillPolygon füllt ein Polygon mit dem Scanline-Algorithmus
func fillPolygon(img *image.RGBA, pts [][2]float64, c color.RGBA) {
if len(pts) < 3 {
return
}
bounds := img.Bounds()
minY, maxY := pts[0][1], pts[0][1]
for _, p := range pts {
if p[1] < minY {
minY = p[1]
}
if p[1] > maxY {
maxY = p[1]
}
}
n := len(pts)
for scanY := int(math.Floor(minY)); scanY <= int(math.Ceil(maxY)); scanY++ {
if scanY < bounds.Min.Y || scanY >= bounds.Max.Y {
continue
}
y := float64(scanY) + 0.5
var xs []float64
for i := 0; i < n; i++ {
p1, p2 := pts[i], pts[(i+1)%n]
if (p1[1] <= y && p2[1] > y) || (p2[1] <= y && p1[1] > y) {
x := p1[0] + (y-p1[1])/(p2[1]-p1[1])*(p2[0]-p1[0])
xs = append(xs, x)
}
}
sort.Float64s(xs)
for i := 0; i+1 < len(xs); i += 2 {
x0 := int(math.Round(xs[i]))
x1 := int(math.Round(xs[i+1]))
for x := x0; x <= x1; x++ {
if x >= bounds.Min.X && x < bounds.Max.X {
img.SetRGBA(x, scanY, c)
}
}
}
}
}
// drawCircle zeichnet einen gefüllten Kreis
func drawCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA) {
bounds := img.Bounds()
for y := int(math.Floor(cy - r)); y <= int(math.Ceil(cy+r)); y++ {
for x := int(math.Floor(cx - r)); x <= int(math.Ceil(cx+r)); x++ {
if !image.Pt(x, y).In(bounds) {
continue
}
dx := float64(x) + 0.5 - cx
dy := float64(y) + 0.5 - cy
if dx*dx+dy*dy <= r*r {
img.SetRGBA(x, y, c)
}
}
}
}
// pinOutline erzeugt den Umriss eines Google-Maps-artigen Pins:
// Kreis oben, gerade Seiten (tangential) laufen in einer Spitze zusammen.
// Mathematisch korrekt: die Seiten sind exakt die Tangenten vom Tip-Punkt an den Kreis.
func pinOutline(cx, cy, r, tipY float64, numArcPts int) [][2]float64 {
d := tipY - cy // vertikaler Abstand: Kreismittelpunkt → Spitze
if d <= r {
// Fallback: Spitze zu nah am Kreis → einfaches Dreieck
return [][2]float64{{cx - r, cy}, {cx + r, cy}, {cx, tipY}}
}
// Winkel, um den die Tangente von der Senkrechten abweicht
alpha := math.Asin(r / d)
// Berührpunkte auf dem Kreis (im Bild-Koordinatensystem: 0°=rechts, 90°=unten)
rightAngle := math.Pi/2 - alpha // rechter Berührpunkt (~63°)
leftAngle := math.Pi/2 + alpha // linker Berührpunkt (~117°)
var pts [][2]float64
// Bogen vom rechten Berührpunkt, gegen den Uhrzeigersinn durch den Scheitel (270°),
// zum linken Berührpunkt.
// arcSpan = rightAngle + 2π - leftAngle (entgegen dem Uhrzeigersinn)
arcSpan := rightAngle + 2*math.Pi - leftAngle
for i := 0; i <= numArcPts; i++ {
t := float64(i) / float64(numArcPts)
angle := rightAngle - t*arcSpan
pts = append(pts, [2]float64{
cx + r*math.Cos(angle),
cy + r*math.Sin(angle),
})
}
// Abschlusspunkt: die Spitze des Pins
pts = append(pts, [2]float64{cx, tipY})
return pts
}
// generateMarker erzeugt einen Google-Maps-artigen Pin als PNG
func generateMarker(pinType, hexColor string, scale float64) ([]byte, error) {
cacheKey := pinType + "-" + hexColor + "-" + strconv.FormatFloat(scale, 'f', 2, 64)
markerCacheMu.RLock()
if cached, ok := markerCache[cacheKey]; ok {
markerCacheMu.RUnlock()
return cached, nil
}
markerCacheMu.RUnlock()
bs, ok := baseSizes[pinType]
if !ok {
bs = baseSizes["pin-m"]
}
w := math.Round(bs.w * scale)
h := math.Round(bs.h * scale)
if w < 10 {
w = 10
}
if h < 15 {
h = 15
}
// mapbox-lib.js setzt: iconAnchor = [w/2, h/2]
// → geografische Koordinate liegt bei Pixel (w/2, h/2) = Bildmitte
// → Spitze MUSS bei tipY = h/2 liegen, untere Hälfte bleibt transparent
//
// r muss entsprechend kleiner sein: r = w*0.33 → r/d = 10/(35-11) ≈ 0.41 → 24° Öffnung
cx := w / 2
r := w * 0.33 // schlanker Kreis; r/d ≈ 0.41 @ tipY=h/2
cy := r + 1.0 // Kreis nahe Oberkante
tipY := h / 2 // Spitze an Ankerpunkt [w/2, h/2] → zeigt exakt auf Koordinate
borderW := math.Max(1.5, w*0.06)
innerR := r * 0.36
// Füllfarbe: 85% opak; Rand und Punkt: vollständig weiß
fillBase := hexToColor(hexColor)
fill := color.RGBA{R: fillBase.R, G: fillBase.G, B: fillBase.B, A: 217}
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
img := image.NewRGBA(image.Rect(0, 0, int(math.Ceil(w)), int(math.Ceil(h))))
// 1. Weißer Rand (Pin mit borderW größerem Radius)
borderPts := pinOutline(cx, cy, r+borderW, tipY, 64)
fillPolygon(img, borderPts, white)
// 2. Farbige Pin-Form
fillPts := pinOutline(cx, cy, r, tipY, 64)
fillPolygon(img, fillPts, fill)
// 3. Weißer Innenpunkt vollständig opak für gute Sichtbarkeit
drawCircle(img, cx, cy, innerR, white)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
result := buf.Bytes()
markerCacheMu.Lock()
if len(markerCache) < 500 {
markerCache[cacheKey] = result
}
markerCacheMu.Unlock()
return result, nil
}
func handleMarker(w http.ResponseWriter, r *http.Request) {
matches := markerPattern.FindStringSubmatch(r.URL.Path)
if matches == nil {
http.NotFound(w, r)
return
}
pinType := matches[1]
hexColor := matches[2]
scaleStr := matches[3]
scale := 1.0
if scaleStr != "" {
if s, err := strconv.ParseFloat(strings.TrimSuffix(scaleStr, "x"), 64); err == nil && s > 0 && s <= 4 {
scale = s
}
}
log.Printf("marker type=%s color=#%s scale=%.1f", pinType, hexColor, scale)
data, err := generateMarker(pinType, hexColor, scale)
if err != nil {
log.Printf("error generating marker: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok","service":"marker-api"}`))
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
mux.HandleFunc("/styles/v4/marker/", handleMarker)
log.Printf("marker-api listening on :%s", port)
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatal(err)
}
}