- Non-square canvas (pin-m: 27x41 @1x = 54x82 @2x, Maptiler standard) - r=37% of width -> circle fills 74% of canvas width - r/d=0.37 -> 22deg opening angle (Google Maps standard) - White border: borderW=max(2px,8%width), A=255 (fully opaque) - White inner dot: r*0.38, A=255 (fully opaque, clearly visible) - Fill: 85% opacity (A=217)
263 lines
7.2 KiB
Go
263 lines
7.2 KiB
Go
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 }
|
||
|
||
var baseSizes = map[string]pinDims{
|
||
"pin-s": {20, 30},
|
||
"pin-m": {27, 41}, // Maptiler-Standard
|
||
"pin-l": {34, 52},
|
||
}
|
||
|
||
// 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: Kreis füllt ~74% der Breite, r/d ≈ 0.37 → Öffnungswinkel 22°
|
||
// Entspricht exakt Maptiler pin-m Proportionen (27×41px @1x)
|
||
cx := w / 2
|
||
r := w * 0.37 // Kreisradius: Kreis fast so breit wie Canvas
|
||
cy := r + h*0.04 // Kreismittelpunkt Y: kleiner oberer Rand
|
||
tipY := h - h*0.03 // Spitze Y: 3% Abstand vom unteren Rand
|
||
|
||
borderW := math.Max(2.0, w*0.08) // weißer Rand: gut sichtbar
|
||
innerR := r * 0.38 // weißer Innenpunkt: 38% des Kreisradius
|
||
|
||
// 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=2592000, immutable")
|
||
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)
|
||
}
|
||
}
|