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