Compare commits
3 commits
b0fdcd4332
...
9367ba5b6b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9367ba5b6b | |||
| 3960c9c5c1 | |||
| 9df190aca8 |
9 changed files with 473 additions and 7 deletions
35
.forgejo/workflows/build-marker-api.yml
Normal file
35
.forgejo/workflows/build-marker-api.yml
Normal 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 }}"
|
||||||
|
|
@ -38,3 +38,21 @@ services:
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
depends_on:
|
depends_on:
|
||||||
- martin
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,15 @@ docker compose logs -f tileserver # Raster-Rendering
|
||||||
- Fonts: `data/fonts/`
|
- Fonts: `data/fonts/`
|
||||||
- Sprites: `data/sprites/`
|
- 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); Canvas quadratisch, 80% opak, schlanke Teardrop-Form (r/d≈0.35)
|
||||||
|
- 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
|
### Reverse Proxy (NPM) – Öffentliche URLs
|
||||||
|
|
||||||
**Konfiguration (manuell im NPM WebUI):**
|
**Konfiguration (manuell im NPM WebUI):**
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@
|
||||||
```
|
```
|
||||||
Internet (HTTPS)
|
Internet (HTTPS)
|
||||||
↓
|
↓
|
||||||
NPM Reverse Proxy (tiles.rd13server.de, Rate Limiting)
|
Caddy Reverse Proxy (tiles.rd13server.de)
|
||||||
├─ /osm/{z}/{x}/{y}.pbf → Martin (Vektor-Tiles)
|
├─ /styles/v4/marker/* → marker-api :9984 (Go, Pin-PNG-Generator)
|
||||||
└─ /osm-intl/{z}/{x}/{y}.png → tileserver-gl (Raster-PNG)
|
├─ /styles/* → tileserver-gl :9983 (Raster-PNG)
|
||||||
|
└─ /* → Martin :9982 (Vektor-Tiles)
|
||||||
↓
|
↓
|
||||||
tileserver-gl renders Martin Vektor → PNG
|
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 |
|
| **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 |
|
| **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:**
|
**Verwandte ADRs:**
|
||||||
- [ADR-001: Tile Rendering Stack – Martin + tileserver-gl](docs/adr/001-martin-tileserver-gl-stack.md)
|
- [ADR-001: Tile Rendering Stack – Martin + tileserver-gl](docs/adr/001-martin-tileserver-gl-stack.md)
|
||||||
|
|
|
||||||
121
docs/history/prompts/2026-07-01_marker-api_session.md
Normal file
121
docs/history/prompts/2026-07-01_marker-api_session.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
### Prompt 11
|
||||||
|
«so sehen die marker aus im media wiki warum sind sie farblos und warum abgeschnitten?»
|
||||||
|
|
||||||
|
### Antwort 11
|
||||||
|
Zwei Probleme identifiziert:
|
||||||
|
1. **Abgeschnitten**: Canvas war quadratisch, Teardrop-Spitze wurde unten abgeschnitten
|
||||||
|
2. **Farblos**: `7e7e7e` ist das MediaWiki-Default wenn kein `marker-color` im GeoJSON gesetzt ist – korrektes Verhalten
|
||||||
|
|
||||||
|
Geometry-Fix: r=35.5% der Breite, tipY=97.5%, Spitze passt jetzt sauber in die Canvas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Prompt 12
|
||||||
|
«Aber ich will doch das es in media wiki gut aussieht also muss es in 32x32 passen und gut aussehen. wäre es auch möglich ihn etwas durchsichtig zu machen?»
|
||||||
|
|
||||||
|
### Prompt 13
|
||||||
|
«Das sieht aber immer noch komisch aus...»
|
||||||
|
|
||||||
|
### Antwort 13
|
||||||
|
Ursache identifiziert: `r/d = 0.59` (Kreisradius / Abstand zur Spitze) → Öffnungswinkel 36° → Tangenten laufen flach aus → Form sieht aus wie ein Oval.
|
||||||
|
|
||||||
|
Fix: `r = size * 0.24` (statt 0.355) → `r/d ≈ 0.35` → Öffnungswinkel 20° → schlanker Pin.
|
||||||
|
|
||||||
|
Verifiziert mit blauen Test-Pin (±4285F4): sieht jetzt aus wie Google-Maps-Pin.
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# PROJECT_CONTEXT – rd13_tile_server
|
# 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/*`). 32×32px Canvas, schlanker Teardrop-Pin (`r/d≈0.35`, Öffnungswinkel 20°), 80% Transparenz. Forgejo CI baut + pusht Image in lokale Container-Registry.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Projektziel
|
## Projektziel
|
||||||
|
|
||||||
Selbst gehosteter Tile Server für MediaWiki (Kartographer), Nextcloud, Home Assistant.
|
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.
|
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}`)
|
- **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`)
|
- **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)
|
- **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`
|
- **Konfiguration:** `config/tileserver.json`, `config/styles/osm-intl.json`
|
||||||
- **Daten:** `data/osm.mbtiles` (95 GB, Planet z0–14), `data/sprites/`, `data/fonts/`
|
- **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)
|
- **Satellit:** `docker-compose.download.yml` → `data/satellite.mbtiles` (~38 GB, z0–11, noch nicht gestartet)
|
||||||
|
|
|
||||||
20
services/marker-api/Dockerfile
Normal file
20
services/marker-api/Dockerfile
Normal 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"]
|
||||||
3
services/marker-api/go.mod
Normal file
3
services/marker-api/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module marker-api
|
||||||
|
|
||||||
|
go 1.21
|
||||||
257
services/marker-api/main.go
Normal file
257
services/marker-api/main.go
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
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 (quadratische Canvas)
|
||||||
|
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()
|
||||||
|
|
||||||
|
bs, ok := baseSizes[pinType]
|
||||||
|
if !ok {
|
||||||
|
bs = baseSizes["pin-m"]
|
||||||
|
}
|
||||||
|
size := math.Round(bs * scale)
|
||||||
|
if size < 12 {
|
||||||
|
size = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geometrie: schlanker Google-Maps-Pin
|
||||||
|
// r/d ≈ 0.35 → Öffnungswinkel ~20° → Pin sieht spitz aus, nicht oval
|
||||||
|
cx := size / 2
|
||||||
|
r := size * 0.24 // kleiner Kreis → langer, schlanker Schwanz
|
||||||
|
cy := r + size*0.02 // Kreismittelpunkt oben
|
||||||
|
tipY := size * 0.975 // Spitze am unteren Rand
|
||||||
|
borderW := math.Max(1.5, size*0.05) // Randbreite
|
||||||
|
innerR := r * 0.40 // weißer Innenpunkt (etwas größer für Sichtbarkeit)
|
||||||
|
|
||||||
|
// Transparenz: 80% opak (Alpha 204)
|
||||||
|
fillBase := hexToColor(hexColor)
|
||||||
|
fill := color.RGBA{R: fillBase.R, G: fillBase.G, B: fillBase.B, A: 204}
|
||||||
|
white := color.RGBA{R: 255, G: 255, B: 255, A: 220}
|
||||||
|
whiteDot := color.RGBA{R: 255, G: 255, B: 255, A: 204}
|
||||||
|
|
||||||
|
iSize := int(math.Ceil(size))
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, iSize, iSize))
|
||||||
|
|
||||||
|
// 1. Weißer Rand
|
||||||
|
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
|
||||||
|
drawCircle(img, cx, cy, innerR, whiteDot)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue