diff --git a/.forgejo/workflows/build-marker-api.yml b/.forgejo/workflows/build-marker-api.yml new file mode 100644 index 0000000..afa7ba9 --- /dev/null +++ b/.forgejo/workflows/build-marker-api.yml @@ -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 }}" diff --git a/docker-compose.yml b/docker-compose.yml index 9e0e8aa..fea25ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/ADMIN.md b/docs/ADMIN.md index a0112a8..11c5ff5 100644 --- a/docs/ADMIN.md +++ b/docs/ADMIN.md @@ -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):** diff --git a/docs/MAINTAINER.md b/docs/MAINTAINER.md index c1f9c1e..12594ec 100644 --- a/docs/MAINTAINER.md +++ b/docs/MAINTAINER.md @@ -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) diff --git a/docs/history/prompts/2026-07-01_marker-api_session.md b/docs/history/prompts/2026-07-01_marker-api_session.md new file mode 100644 index 0000000..daa1450 --- /dev/null +++ b/docs/history/prompts/2026-07-01_marker-api_session.md @@ -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 4–6 +«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 4–6 +Vollständige Lösungsanalyse durchgeführt. Entscheidung für **Option D: minimaler separater Docker Container** mit Go (keine externen Dependencies, ~5–10 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 ~5–10 MB vs. Node.js ~25–40 MB vs. Python ~20–30 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 +``` diff --git a/docs/history/summary/PROJECT_CONTEXT.md b/docs/history/summary/PROJECT_CONTEXT.md index 21fc459..89ec1da 100644 --- a/docs/history/summary/PROJECT_CONTEXT.md +++ b/docs/history/summary/PROJECT_CONTEXT.md @@ -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 2–4×/Jahr; Styles anpassbar. --- @@ -16,8 +16,9 @@ Tile-Updates manuell 2–4×/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 z0–14), `data/sprites/`, `data/fonts/` - **Satellit:** `docker-compose.download.yml` → `data/satellite.mbtiles` (~38 GB, z0–11, noch nicht gestartet) diff --git a/services/marker-api/Dockerfile b/services/marker-api/Dockerfile new file mode 100644 index 0000000..3963861 --- /dev/null +++ b/services/marker-api/Dockerfile @@ -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"] diff --git a/services/marker-api/go.mod b/services/marker-api/go.mod new file mode 100644 index 0000000..b911bca --- /dev/null +++ b/services/marker-api/go.mod @@ -0,0 +1,3 @@ +module marker-api + +go 1.21 diff --git a/services/marker-api/main.go b/services/marker-api/main.go new file mode 100644 index 0000000..f8cba1b --- /dev/null +++ b/services/marker-api/main.go @@ -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) + } +}