rd13_tile_server/services/marker-api/main.go
Conrad Schulz bedbe0f3f3 fix: pin dimensions match Kartographer mapbox-lib.js exactly
Kartographer/mapbox-lib.js defines:
  sizes = { small:[20,50], medium:[30,70], large:[35,90] }
  iconSize: sizes[size]  <- CSS stretches image to this

Previous: pin-m=27x41 -> CSS stretched to 30x70 (+11% h, +71% v)
Result: slim tip distorted into fat oval shape

Fix: generate images at exact expected dimensions:
  pin-s: 20x50, pin-m: 30x70, pin-l: 35x90 (@1x)
  pin-m@2x: 60x140

Geometry: r=w*0.47 (circle fills width), cy=r+1, tipY=h-1
2026-07-01 13:48:39 +00:00

269 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
}
// Geometrie für 30×70 (@1x) / 60×140 (@2x):
// - Kreis füllt fast die ganze Breite (r≈47% width)
// - iconAnchor=[w/2, h/2] in mapbox-lib.js → Anker liegt bei y=35 im Tail
// - Spitze bei y≈h-1: 34px unterhalb des Ankers (deutlich sichtbar)
cx := w / 2
r := w * 0.47 // r≈14px (@1x): Kreis fast so breit wie Canvas
cy := r + 1.0 // cy≈15: Kreis berührt fast den oberen Rand
tipY := h - 1.0 // Spitze 1px vom unteren Rand
borderW := math.Max(1.5, w*0.06) // weißer Rand
innerR := r * 0.36 // weißer Innenpunkt
// 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)
}
}