Tile-Server: Vektor-Basemap in PWA integrieren (protomaps-leaflet, Feature-Flag)

- ui.js Map.create: Basemap-Swap OSM-Raster→PMTiles-Vektorlayer hinter Flag
  'by_vector_map' (?vectormap=1/0). Leaflet+markercluster+Marker unverändert,
  sauberer Raster-Fallback bei Fehler. Attribution Pflicht eingeblendet.
- map-vector.js: protomaps-leaflet paintRules/labelRules für OpenMapTiles-Schema
  (Light+Dark), Labels per Canvas-Text → keine Glyphs nötig. Quelle /tiles/dach.pmtiles.
- protomaps-leaflet 4.0.1 vendored.
- Makefile: 'make tiles' (download→merge -H+time-filter dedup→planetiler) + 'make tiles-deploy'
  (atomarer Swap, ENV=prod für Produktion).
This commit is contained in:
rene 2026-06-04 21:53:07 +02:00
parent a561759034
commit 2b5afcf0ae
4 changed files with 205 additions and 6 deletions

View file

@ -28,7 +28,7 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
logs logs-f shell db dev clean-cache check-ssh reports bump test
logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy
# ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
@ -140,6 +140,48 @@ staging-db: check-ssh
sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \
echo '✓ DB kopiert'"
# ----------------------------------------------------------
# TILES — DACH-Vektortiles (planetiler → PMTiles), lokal bauen + ausliefern
# Voraussetzung: Docker Desktop läuft, osmium installiert (brew install osmium-tool).
# make tiles DACH neu generieren (download → merge → planetiler)
# make tiles-deploy dach.pmtiles auf Staging ausliefern (atomar)
# make tiles-deploy ENV=prod dach.pmtiles auf Produktion ausliefern (atomar)
# Monatlich neu generieren hält die Karte aktuell. Datei liegt im data-Volume,
# NICHT im Image — wird per Range-Route (/tiles) ausgeliefert.
# ----------------------------------------------------------
TILES_DIR := tiles/build
TILES_REGIONS := germany austria switzerland
PLANETILER_IMAGE := ghcr.io/onthegomap/planetiler:latest
TILES_TARGET := $(if $(filter prod,$(ENV)),$(DS_PATH),$(DS_PATH_STAGING))
tiles:
@mkdir -p $(TILES_DIR)
@echo "→ Geofabrik-Extrakte laden ($(TILES_REGIONS))..."
@for r in $(TILES_REGIONS); do \
echo " $$r"; \
curl -fsSL -o $(TILES_DIR)/$$r.osm.pbf https://download.geofabrik.de/europe/$$r-latest.osm.pbf; done
@echo "→ merge (History) + time-filter dedup → dach.osm.pbf..."
@# Geofabrik-Extrakte können versetzte Stände haben (z.B. germany älter als at/ch)
@# Grenz-Nodes mit abweichender Version. Als History mergen + auf 'jetzt' snapshotten
@# liefert genau eine Version pro ID (planetiler braucht eindeutige, sortierte IDs).
@osmium merge -H $(foreach r,$(TILES_REGIONS),$(TILES_DIR)/$(r).osm.pbf) -o $(TILES_DIR)/dach-hist.osm.pbf --overwrite
@osmium time-filter $(TILES_DIR)/dach-hist.osm.pbf -o $(TILES_DIR)/dach.osm.pbf --overwrite
@rm -f $(TILES_DIR)/dach-hist.osm.pbf
@echo "→ planetiler → dach.pmtiles (disk-backed mmap)..."
@docker run --rm -v "$(CURDIR)/$(TILES_DIR):/data" $(PLANETILER_IMAGE) \
--osm-path=/data/dach.osm.pbf --download --output=/data/dach.pmtiles --force \
--storage=mmap --nodemap-storage=mmap
@echo ""
@echo " ✓ Tiles gebaut:"; ls -lh $(TILES_DIR)/dach.pmtiles
tiles-deploy: check-ssh
@if [ ! -f $(TILES_DIR)/dach.pmtiles ]; then echo "$(TILES_DIR)/dach.pmtiles fehlt — erst 'make tiles'"; exit 1; fi
@echo "→ Ausliefern nach $(TILES_TARGET)/data/tiles/ (atomarer Swap)..."
@ssh $(DS_HOST) "mkdir -p $(TILES_TARGET)/data/tiles"
@scp -O $(TILES_DIR)/dach.pmtiles $(DS_HOST):$(TILES_TARGET)/data/tiles/dach.pmtiles.tmp
@ssh $(DS_HOST) "mv -f $(TILES_TARGET)/data/tiles/dach.pmtiles.tmp $(TILES_TARGET)/data/tiles/dach.pmtiles"
@echo " ✓ dach.pmtiles ausgeliefert ($(if $(filter prod,$(ENV)),PRODUKTION,Staging))"
# ----------------------------------------------------------
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
# Beispiel: make release VERSION=1.1.0

View file

@ -0,0 +1,92 @@
// Vektor-Basemap für Leaflet via protomaps-leaflet, gerendert aus unseren eigenen
// PMTiles (OpenMapTiles-Schema von planetiler, ausgeliefert unter /tiles/).
// Ersetzt den OSM-Raster-Layer — Leaflet + markercluster + alle Marker bleiben unberührt.
// Labels werden von protomaps-leaflet per Canvas-Text gezeichnet → KEINE Glyphs nötig.
(function () {
'use strict';
// Single-File-Tile-Archiv (DACH). Liegt im data-Volume, per Range ausgeliefert.
var TILES_FILE = 'dach.pmtiles';
function tilesUrl() {
return window.location.origin + '/tiles/' + TILES_FILE;
}
// Straßenbreite zoomabhängig (dünn weit draußen, breit im Detail).
function roadWidth(z) {
if (z >= 16) return 4;
if (z >= 14) return 2.5;
if (z >= 12) return 1.5;
if (z >= 9) return 0.8;
return 0.4;
}
// Farbpaletten Light/Dark.
var THEMES = {
light: {
bg: '#f4f1ec', water: '#a0c8f0', land: '#dce8c8', park: '#c8e6b0',
road: '#ffffff', roadCasing: '#d9cfc2', building: '#e6ddcf',
buildingLine: '#d4cabb', boundary: '#b08ac0',
label: '#33312e', labelHalo: 'rgba(255,255,255,.85)',
},
dark: {
bg: '#1a1d21', water: '#16242e', land: '#222820', park: '#27331f',
road: '#3a4046', roadCasing: '#23282d', building: '#262b30',
buildingLine: '#31373d', boundary: '#7d5a8c',
label: '#cfd2d6', labelHalo: 'rgba(0,0,0,.8)',
},
};
function buildRules(t) {
var P = protomapsL;
var paint = [
{ dataLayer: 'landcover',
symbolizer: new P.PolygonSymbolizer({ fill: t.land, opacity: 0.55 }) },
{ dataLayer: 'park',
symbolizer: new P.PolygonSymbolizer({ fill: t.park, opacity: 0.5 }) },
{ dataLayer: 'water',
symbolizer: new P.PolygonSymbolizer({ fill: t.water }) },
{ dataLayer: 'waterway',
symbolizer: new P.LineSymbolizer({ color: t.water, width: 0.8 }) },
// Straßen-Casing zuerst (liegt unter der Füllung), dann die Straße.
{ dataLayer: 'transportation', minzoom: 11,
symbolizer: new P.LineSymbolizer({ color: t.roadCasing,
width: function (z) { return roadWidth(z) + 1.5; } }) },
{ dataLayer: 'transportation',
symbolizer: new P.LineSymbolizer({ color: t.road, width: roadWidth }) },
{ dataLayer: 'building', minzoom: 13,
symbolizer: new P.PolygonSymbolizer({ fill: t.building, stroke: t.buildingLine, width: 0.5 }) },
{ dataLayer: 'boundary',
symbolizer: new P.LineSymbolizer({ color: t.boundary, width: 1, dash: [2, 2] }) },
];
var label = [
{ dataLayer: 'place', minzoom: 4,
symbolizer: new P.CenteredTextSymbolizer({
labelProps: ['name:de', 'name'],
fill: t.label, stroke: t.labelHalo, width: 2.5,
font: function (z) { return (z >= 10 ? '600 13px' : '600 11px') + ' system-ui, sans-serif'; },
}) },
];
return { paint: paint, label: label };
}
// Erzeugt den Leaflet-Layer. dark=true → dunkles Theme.
function basemapLayer(opts) {
opts = opts || {};
var t = THEMES[opts.dark ? 'dark' : 'light'];
var rules = buildRules(t);
return protomapsL.leafletLayer({
url: tilesUrl(),
paintRules: rules.paint,
labelRules: rules.label,
backgroundColor: t.bg,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
});
}
window.MapVector = {
basemapLayer: basemapLayer,
tilesUrl: tilesUrl,
tilesFile: TILES_FILE,
};
})();

View file

@ -439,7 +439,6 @@ const UI = (() => {
OSM_MAX_ZOOM: 19,
async create(containerId, options = {}) {
await loadLeaflet();
const {
center = [51.1657, 10.4515],
zoom = 6,
@ -447,11 +446,33 @@ const UI = (() => {
attributionControl = false,
darkFilter = false,
} = options;
await loadLeaflet();
const m = L.map(containerId, { zoomControl, attributionControl }).setView(center, zoom);
const tiles = L.tileLayer(this.OSM_URL, { maxZoom: this.OSM_MAX_ZOOM }).addTo(m);
if (darkFilter) {
const isDark = document.documentElement.dataset.theme === 'dark';
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
// Vektor-Basemap aus eigenen PMTiles (hinter Feature-Flag). Bei Fehler
// (Tiles/Lib nicht da) sauberer Fallback auf den OSM-Raster — Marker etc.
// bleiben in beiden Fällen identisch (reiner Basemap-Tausch).
let usedVector = false;
if (_vectorMapEnabled()) {
try {
await loadProtomaps();
const isDark = document.documentElement.dataset.theme === 'dark';
MapVector.basemapLayer({ dark: isDark }).addTo(m);
if (!attributionControl) {
L.control.attribution({ prefix: false }).addTo(m)
.addAttribution('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors');
}
usedVector = true;
} catch (e) {
console.warn('Vektor-Basemap nicht verfügbar — Fallback auf Raster:', e);
}
}
if (!usedVector) {
const tiles = L.tileLayer(this.OSM_URL, { maxZoom: this.OSM_MAX_ZOOM }).addTo(m);
if (darkFilter) {
const isDark = document.documentElement.dataset.theme === 'dark';
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
}
}
// Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert
// grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt
@ -813,6 +834,41 @@ const UI = (() => {
});
}
// ----------------------------------------------------------
// VEKTOR-BASEMAP (protomaps-leaflet + eigene PMTiles) — lazy laden
// ----------------------------------------------------------
let _protomapsPromise = null;
function loadProtomaps() {
if (_protomapsPromise) return _protomapsPromise;
const v = '?v=' + (window.APP_VER || '');
const loadSeq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => {
if ((src.includes('protomaps-leaflet') && window.protomapsL) ||
(src.includes('map-vector') && window.MapVector)) return res();
const s = document.createElement('script');
s.src = src + v;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
})), Promise.resolve());
// map-vector.js hängt von protomapsL ab → strikt sequenziell laden.
_protomapsPromise = loadSeq(['/js/vendor/protomaps-leaflet.js', '/js/map-vector.js'])
.then(() => {
if (window.protomapsL && window.MapVector) return;
throw new Error('protomaps-leaflet/MapVector nicht geladen');
});
return _protomapsPromise;
}
// Feature-Flag: localStorage 'by_vector_map'==='1'. ?vectormap=1/0 setzt ihn (Testing).
function _vectorMapEnabled() {
try {
const u = new URLSearchParams(location.search);
if (u.has('vectormap')) {
localStorage.setItem('by_vector_map', u.get('vectormap') === '0' ? '0' : '1');
}
return localStorage.getItem('by_vector_map') === '1';
} catch (e) { return false; }
}
// ----------------------------------------------------------
// LEAFLET MARKER FACTORY — erzeugt einen L.divIcon-Marker
// Verwendung:

File diff suppressed because one or more lines are too long