rd13_tile_server/services/marker-api/main.go
Conrad Schulz 8feaa6bd15
Some checks failed
Build & Push marker-api / build (push) Failing after 5s
fix: pin tip at iconAnchor position (h/2, not h-1)
mapbox-lib.js: iconAnchor = [w/2, h/2] (image center)
Previous: tipY=h-1 -> anchor at y=35, tip at y=69 -> pin floated above click
Fix: tipY=h/2 -> anchor and tip coincide exactly
Reduce r=w*0.33 to keep slim shape (r/d=0.41 -> 24deg opening)
Bottom canvas half is transparent
2026-07-01 13:55:52 +00:00

270 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}