feat(geoportal): nginx tile cache + PMTiles overview layers + tippecanoe pipeline

- Add nginx reverse proxy cache in front of Martin (2GB, 1h TTL, stale serving, CORS)
- Martin no longer exposes host port — all traffic routed through tile-cache on :3010
- Add PMTiles support in map-viewer.tsx (conditional: NEXT_PUBLIC_PMTILES_URL env var)
  - When set: single PMTiles source for UAT + administrativ layers (z0-z14, ~5ms/tile)
  - When empty: fallback to Martin tile sources (existing behavior, zero breaking change)
- Add tippecanoe Docker service (profiles: tools) for on-demand PMTiles generation
- Add rebuild-overview-tiles.sh: ogr2ogr export → tippecanoe → MinIO atomic upload
- Install pmtiles npm package for MapLibre protocol registration

Performance impact:
- nginx cache: 10-100x faster on repeat tile requests, zero PostGIS load on cache hit
- PMTiles: sub-10ms overview tiles, zero PostGIS load for z0-z14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-27 20:28:49 +02:00
parent 67f3237761
commit 536b3659bb
8 changed files with 311 additions and 31 deletions
+77 -29
View File
@@ -2,6 +2,7 @@
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
import maplibregl from "maplibre-gl";
import { Protocol as PmtilesProtocol } from "pmtiles";
import { cn } from "@/shared/lib/utils";
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
@@ -15,6 +16,12 @@ if (typeof document !== "undefined") {
document.head.appendChild(link);
}
}
/* Register PMTiles protocol globally (once) for pmtiles:// source URLs */
if (typeof window !== "undefined") {
const pmtilesProto = new PmtilesProtocol();
maplibregl.addProtocol("pmtiles", pmtilesProto.tile);
}
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
/* ------------------------------------------------------------------ */
@@ -28,6 +35,7 @@ export type SelectionType = "off" | "click" | "rect" | "freehand";
/* ------------------------------------------------------------------ */
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
const DEFAULT_PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
const DEFAULT_ZOOM = 7;
@@ -384,40 +392,80 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
}
}
// === UAT z0-5: very coarse — lines only ===
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// === UAT sources: PMTiles (if configured) or Martin fallback ===
const pmtilesUrl = DEFAULT_PMTILES_URL;
const usePmtiles = !!pmtilesUrl;
// === UAT z5-8: coarse ===
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
if (usePmtiles) {
// Single PMTiles source contains all UAT + administrativ layers (z0-z14)
const PM_SRC = "overview-pmtiles";
map.addSource(PM_SRC, { type: "vector", url: `pmtiles://${pmtilesUrl}` });
// === UAT z8-12: moderate ===
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z0-5: lines only
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// === UAT z12+: full detail (no simplification) ===
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z5-8
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
// === Intravilan — double line (black outer + orange inner), no fill, z13+ ===
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
// z8-12
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// === Terenuri (parcels) — no simplification ===
// z12+: full detail from PMTiles
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// Intravilan from PMTiles
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
} else {
// Fallback: Martin tile sources (existing behavior)
// z0-5: very coarse — lines only
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// z5-8: coarse
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
// z8-12: moderate
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z12+: full detail (no simplification)
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// Intravilan — double line (black outer + orange inner), no fill, z13+
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
}
// === Terenuri (parcels) — always from Martin (live data) ===
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });