2026-07-01 11:59:00 +00:00
|
|
|
|
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$`,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-01 12:33:05 +00:00
|
|
|
|
// 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 }
|
|
|
|
|
|
|
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
|
|
|
|
// 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).
|
2026-07-01 12:33:05 +00:00
|
|
|
|
var baseSizes = map[string]pinDims{
|
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
|
|
|
|
"pin-s": {20, 50},
|
|
|
|
|
|
"pin-m": {30, 70}, // mapbox-lib.js: sizes.medium = [30, 70]
|
|
|
|
|
|
"pin-l": {35, 90},
|
2026-07-01 11:59:00 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
|
2026-07-01 12:13:48 +00:00
|
|
|
|
bs, ok := baseSizes[pinType]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
bs = baseSizes["pin-m"]
|
2026-07-01 11:59:00 +00:00
|
|
|
|
}
|
2026-07-01 12:33:05 +00:00
|
|
|
|
w := math.Round(bs.w * scale)
|
|
|
|
|
|
h := math.Round(bs.h * scale)
|
|
|
|
|
|
if w < 10 {
|
|
|
|
|
|
w = 10
|
2026-07-01 11:59:00 +00:00
|
|
|
|
}
|
2026-07-01 12:33:05 +00:00
|
|
|
|
if h < 15 {
|
|
|
|
|
|
h = 15
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 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)
|
2026-07-01 12:33:05 +00:00
|
|
|
|
cx := w / 2
|
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
|
|
|
|
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
|
2026-07-01 11:59:00 +00:00
|
|
|
|
|
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
|
|
|
|
borderW := math.Max(1.5, w*0.06) // weißer Rand
|
|
|
|
|
|
innerR := r * 0.36 // weißer Innenpunkt
|
2026-07-01 12:13:48 +00:00
|
|
|
|
|
2026-07-01 12:33:05 +00:00
|
|
|
|
// Füllfarbe: 85% opak; Rand und Punkt: vollständig weiß
|
2026-07-01 12:13:48 +00:00
|
|
|
|
fillBase := hexToColor(hexColor)
|
2026-07-01 12:33:05 +00:00
|
|
|
|
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}
|
2026-07-01 11:59:00 +00:00
|
|
|
|
|
2026-07-01 12:33:05 +00:00
|
|
|
|
img := image.NewRGBA(image.Rect(0, 0, int(math.Ceil(w)), int(math.Ceil(h))))
|
2026-07-01 11:59:00 +00:00
|
|
|
|
|
2026-07-01 12:33:05 +00:00
|
|
|
|
// 1. Weißer Rand (Pin mit borderW größerem Radius)
|
2026-07-01 11:59:00 +00:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-01 12:33:05 +00:00
|
|
|
|
// 3. Weißer Innenpunkt – vollständig opak für gute Sichtbarkeit
|
|
|
|
|
|
drawCircle(img, cx, cy, innerR, white)
|
2026-07-01 11:59:00 +00:00
|
|
|
|
|
|
|
|
|
|
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")
|
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
|
|
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
2026-07-01 11:59:00 +00:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|