feat: add Go marker-api service for MediaWiki Kartographer pin support

- 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
This commit is contained in:
Conrad Schulz 2026-07-01 11:59:00 +00:00
parent b0fdcd4332
commit 9df190aca8
9 changed files with 452 additions and 7 deletions

View file

@ -0,0 +1,35 @@
name: Build & Push marker-api
on:
push:
branches:
- main
paths:
- 'services/marker-api/**'
- '.forgejo/workflows/build-marker-api.yml'
env:
REGISTRY: 192.168.178.6:8083
IMAGE: 192.168.178.6:8083/${{ github.repository_owner }}/rd13_tile_server-marker-api
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Login to Forgejo Container Registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
docker login ${{ env.REGISTRY }} \
--username "${{ github.actor }}" \
--password-stdin
- name: Build & push marker-api
run: |
docker build \
--tag "${{ env.IMAGE }}:latest" \
--tag "${{ env.IMAGE }}:${{ github.sha }}" \
./services/marker-api
docker push "${{ env.IMAGE }}:latest"
docker push "${{ env.IMAGE }}:${{ github.sha }}"

View file

@ -38,3 +38,21 @@ services:
start_period: 30s
depends_on:
- martin
marker-api:
# Image wird von Forgejo CI gebaut und in die Registry gepusht.
# Erstes lokales Build: docker compose build marker-api
image: 192.168.178.6:8083/cschulz/rd13_tile_server-marker-api:latest
container_name: rd13_marker_api
build:
context: ./services/marker-api
dockerfile: Dockerfile
ports:
- "9984:3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s

View file

@ -232,6 +232,15 @@ docker compose logs -f tileserver # Raster-Rendering
- Fonts: `data/fonts/`
- Sprites: `data/sprites/`
**marker-api (Marker-PNG-Generierung)**
- Code: `services/marker-api/` (Go, 0 externe Dependencies)
- Image: `192.168.178.6:8083/cschulz/rd13_tile_server-marker-api:latest` (Forgejo Registry)
- API Root: `http://localhost:9984`
- Endpunkt: `/styles/v4/marker/{type}+{hexcolor}(@{scale}x).png`
- Typen: `pin-s` (24px), `pin-m` (32px), `pin-l` (40px)
- Image neu bauen: `docker compose build marker-api && docker compose up -d marker-api`
- CI: Forgejo baut + pusht automatisch bei Änderungen in `services/marker-api/`
### Reverse Proxy (NPM) Öffentliche URLs
**Konfiguration (manuell im NPM WebUI):**

View file

@ -10,9 +10,10 @@
```
Internet (HTTPS)
NPM Reverse Proxy (tiles.rd13server.de, Rate Limiting)
├─ /osm/{z}/{x}/{y}.pbf → Martin (Vektor-Tiles)
└─ /osm-intl/{z}/{x}/{y}.png → tileserver-gl (Raster-PNG)
Caddy Reverse Proxy (tiles.rd13server.de)
├─ /styles/v4/marker/* → marker-api :9984 (Go, Pin-PNG-Generator)
├─ /styles/* → tileserver-gl :9983 (Raster-PNG)
└─ /* → Martin :9982 (Vektor-Tiles)
tileserver-gl renders Martin Vektor → PNG
@ -27,7 +28,8 @@ NPM Reverse Proxy (tiles.rd13server.de, Rate Limiting)
|---|---|---|---|
| **Martin** | `osm.mbtiles` | `/{layer}/{z}/{x}/{y}.pbf` | Vektor-API, Tile-Distribution |
| **tileserver-gl** | Martin PBF + `osm-intl.json` | `/{style}/{z}/{x}/{y}.png` | Rendering, PNG-Tile-Cache |
| **NPM** | HTTP Client Requests | HTTPS Responses | Proxy, SSL, Rate-Limit |
| **marker-api** | URL-Parameter (type, color, scale) | `/styles/v4/marker/*.png` | Go-Service, Google-Maps-Pin-Generierung |
| **Caddy** | HTTP Client Requests | HTTPS Responses | Proxy, SSL, Routing |
**Verwandte ADRs:**
- [ADR-001: Tile Rendering Stack Martin + tileserver-gl](docs/adr/001-martin-tileserver-gl-stack.md)

View file

@ -0,0 +1,104 @@
# Session 2026-07-01 Marker-API für MediaWiki Kartographer Visual Editor
## Kontext
MediaWiki Kartographer Visual Editor rief `https://tiles.rd13server.de/styles/v4/marker/pin-m+7e7e7e@2x.png` auf der Endpunkt existierte nicht (HTTP 404). Ziel war es, einen Maptiler-kompatiblen Marker-Endpunkt bereitzustellen, ohne MediaWiki-Extensions anzupassen.
---
### Prompt 1
«Wenn ich im media wiki auf karten einen pin im visual editor setzen will https://tiles.rd13server.de/styles/v4/marker/pin-m+7e7e7e@2x.png und das hier aufgerufen wird, kommt nur ein ? im browser. Wie können wir das beheben?»
### Antwort 1
Diagnose ergab: URL lieferte HTTP 404. Die Maptiler Static Images Marker-API (`/styles/v4/marker/`) ist kein Standard-Endpunkt von tileserver-gl. Analysierte Lösungsoptionen.
---
### Prompt 2
«Ich würde gerne die media wiki extensions so unangetastet wie möglich lassen.»
### Antwort 2
Entscheidung für einen serverseitigen Marker-Generierungs-Service, der die Maptiler-kompatible URL-Schema bereitstellt. MediaWiki-Extensions bleiben vollständig unverändert.
---
### Prompt 3
«Heißt das das am ende der caddy den marker bereit stellt? und wie wäre es wenn der tile server den maker mit liefern würde?»
### Antwort 3
Analyse zweier Ansätze: Custom tileserver-gl Image (Variante A) vs. separater Service. Variante A wurde versucht aber verworfen, da tileserver-gl kein Node.js Modul exportiert und der Original-Entrypoint komplex ist.
---
### Prompt 46
«variante a implementieren» / «Prüfe noch mal welche ansätze es generell gibt» / «ok dann lass uns die option mit dem zusätzlichen service container implementieren!»
### Antwort 46
Vollständige Lösungsanalyse durchgeführt. Entscheidung für **Option D: minimaler separater Docker Container** mit Go (keine externen Dependencies, ~510 MB RAM, `FROM scratch`-artiges Alpine-Image).
---
### Prompt 7
«ist node.js schon das schlangste was wir machen können ich würde gerne auf minimal ram verbauch optimieren?»
### Antwort 7
RAM-Vergleich: Go ~510 MB vs. Node.js ~2540 MB vs. Python ~2030 MB. Go als klarer Gewinner (stdlib reicht für PNG-Generierung).
---
### Prompt 8
«dann bitte in go implementieren.»
### Antwort 8
Implementiert:
- `services/marker-api/main.go` Go HTTP-Server mit Scanline-Algorithmus für Kreis+Dreieck Pin
- `services/marker-api/go.mod` Go-Modulfile, 0 externe Dependencies
- `services/marker-api/Dockerfile` Multi-Stage Build: `golang:1.21-alpine``alpine:3.19`
- `.forgejo/workflows/build-marker-api.yml` CI Workflow
- `docker-compose.yml` marker-api Service auf Port 9984
- `rd13_system_proxy/caddy/Caddyfile` Route `/styles/v4/marker/*` → Port 9984
```bash
docker compose build marker-api
docker compose up -d marker-api
docker exec rd13_caddy_proxy caddy reload --config /etc/caddy/Caddyfile
curl "https://tiles.rd13server.de/styles/v4/marker/pin-m+7e7e7e@2x.png"
# HTTP 200 | 545 bytes | image/png ✅
```
---
### Prompt 9
«ok the maker is not very good looking can we make it to look more like the marker from google maps? I want to have all the container build happening in forgejo and have the images stored in the container registry.»
### Antwort 9
Marker-Optik komplett neu implementiert: Mathematisch korrekte Teardrop-Form mit echten Tangenten vom Tip-Punkt an den Kreis (Google-Maps-Look). Weißer Rand + weißer Innenpunkt.
```bash
docker compose build marker-api && docker compose up -d marker-api
curl "https://tiles.rd13server.de/styles/v4/marker/pin-m+7e7e7e@2x.png"
# HTTP 200 | 568 bytes | image/png ✅
```
Forgejo CI Workflow: `.forgejo/workflows/build-marker-api.yml`
- Trigger: Push auf `main`, wenn `services/marker-api/**` geändert
- Registry: `192.168.178.6:8083/cschulz/rd13_tile_server-marker-api:latest`
---
### Prompt 10
«alle container builds und code sollte gemeinsam im tile server repo auf forgejo liegen und es soll kein extra repo für marker-api geben.»
### Antwort 10
Aufgeräumt:
- `services/tileserver-gl-marker/` (gescheiterter Ansatz) gelöscht
- Workflow-Image-Name: `rd13_tile_server-marker-api` (klar zum Repo gehörig)
- `docker-compose.yml` Image-Referenz aktualisiert
- Session-History und PROJECT_CONTEXT erstellt/aktualisiert
Gesamtstruktur:
```
rd13_tile_server (Forgejo Repo)
├── services/marker-api/ ← Go Code + Dockerfile
├── .forgejo/workflows/ ← CI baut + pusht nach Registry
└── docker-compose.yml ← nutzt Registry-Image
```

View file

@ -1,13 +1,13 @@
# PROJECT_CONTEXT rd13_tile_server
**Letzte Aktualisierung:** 2026-06-22 **Integrationen geschärft**: Nextcloud- und Home-Assistant-Doku auf verifizierte Endpunkte umgestellt, produktive Domain `tiles.rd13server.de` explizit in Integrations-Snippets gesetzt, Bamberg z14/z16 Vergleichsexporte + Bash-Exportskript ergänzt.
**Letzte Aktualisierung:** 2026-07-01 **Marker-API**: Go-Service für Maptiler-kompatible Marker-Endpunkte (`/styles/v4/marker/*`) hinzugefügt. Forgejo CI baut + pusht Image in lokale Container-Registry.
---
## Projektziel
Selbst gehosteter Tile Server für MediaWiki (Kartographer), Nextcloud, Home Assistant.
**Stack:** Martin (Vektor-API) + tileserver-gl (Raster PNG) + Caddy (Reverse Proxy).
**Stack:** Martin (Vektor-API) + tileserver-gl (Raster PNG) + marker-api (Go) + Caddy (Reverse Proxy).
Tile-Updates manuell 24×/Jahr; Styles anpassbar.
---
@ -16,8 +16,9 @@ Tile-Updates manuell 24×/Jahr; Styles anpassbar.
- **Vektor-API:** `ghcr.io/maplibre/martin:latest` (Port 9982 → `/osm/{z}/{x}/{y}`)
- **Raster-Renderer:** `maptiler/tileserver-gl` (Port 9983 → `/styles/osm-intl/{z}/{x}/{y}.png`)
- **Marker-API:** `192.168.178.6:8083/cschulz/rd13_tile_server-marker-api:latest` (Port 9984 → `/styles/v4/marker/*.png`)
- **Style:** OSM Bright GL (128 Layer, Wanderwege, POIs, vollständige Weltkarte)
- **Proxy:** Caddy (`rd13_system_proxy`) SSL, Routing `/styles/` → 9983, rest → 9982
- **Proxy:** Caddy (`rd13_system_proxy`) SSL, Routing `/styles/v4/marker/*` → 9984, `/styles/` → 9983, rest → 9982
- **Konfiguration:** `config/tileserver.json`, `config/styles/osm-intl.json`
- **Daten:** `data/osm.mbtiles` (95 GB, Planet z014), `data/sprites/`, `data/fonts/`
- **Satellit:** `docker-compose.download.yml``data/satellite.mbtiles` (~38 GB, z011, noch nicht gestartet)

View file

@ -0,0 +1,20 @@
# ── Stage 1: Build ──────────────────────────────────────────────────────────
FROM golang:1.21-alpine AS builder
WORKDIR /build
COPY go.mod main.go ./
# Statisch gelinkt, minimales Binary (~5 MB)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o marker-api .
# ── Stage 2: Run ─────────────────────────────────────────────────────────────
# alpine:3.19 ≈ 8 MB Basis, wget für Healthcheck
FROM alpine:3.19
RUN apk add --no-cache wget
COPY --from=builder /build/marker-api /usr/local/bin/marker-api
EXPOSE 3000
ENV PORT=3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
ENTRYPOINT ["marker-api"]

View file

@ -0,0 +1,3 @@
module marker-api
go 1.21

253
services/marker-api/main.go Normal file
View file

@ -0,0 +1,253 @@
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)
}
}