- services/marker-api/: Go HTTP service (0 external deps, ~8 MB RAM)
- Mathematically correct teardrop pin shape (Google Maps style)
- Tangent-based outline: circle + straight sides meeting at tip
- White border + white inner dot, in-memory cache (max 500 entries)
- .forgejo/workflows/build-marker-api.yml: CI builds & pushes to
Forgejo registry on push to main (image: rd13_tile_server-marker-api)
- docker-compose.yml: add marker-api service on port 9984
- Caddyfile (rd13_system_proxy): route /styles/v4/marker/* -> :9984
- docs/ADMIN.md + MAINTAINER.md: marker-api dokumentiert
Fixes: /styles/v4/marker/pin-m+{color}(@{scale}x).png was 404, now 200
253 lines
6.6 KiB
Go
253 lines
6.6 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-Größen in logischen Pixeln
|
|
var baseSizes = map[string]float64{
|
|
"pin-s": 24,
|
|
"pin-m": 32,
|
|
"pin-l": 40,
|
|
}
|
|
|
|
// 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()
|
|
|
|
baseSize := baseSizes[pinType]
|
|
if baseSize == 0 {
|
|
baseSize = 32
|
|
}
|
|
size := math.Round(baseSize * scale)
|
|
if size < 10 {
|
|
size = 10
|
|
}
|
|
|
|
// Geometrie-Parameter
|
|
cx := size / 2
|
|
r := size * 0.29 // Kreisradius
|
|
cy := r + size*0.03 // Kreismittelpunkt Y
|
|
tipY := size * 0.96 // Spitze Y
|
|
borderW := math.Max(1.5, size*0.04) // Randbreite
|
|
innerR := r * 0.38 // weißer Innenpunkt (Google-Maps-Look)
|
|
|
|
fill := hexToColor(hexColor)
|
|
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
|
|
|
|
iSize := int(math.Ceil(size))
|
|
img := image.NewRGBA(image.Rect(0, 0, iSize, iSize))
|
|
|
|
// 1. Weißer Rand (Pin mit leicht 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 (klassischer Google-Maps-Stil)
|
|
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)
|
|
}
|
|
}
|