feat(geoportal): OpenFreeMap vector basemaps + eTerra ORTO 2024 ortophoto

Basemap options:
- Liberty (OpenFreeMap vector) — default, sharp vector tiles
- Dark (OpenFreeMap) — dark theme, auto-styled
- Satellite (ESRI World Imagery) — raster
- ANCPI Ortofoto 2024 — proxied via /api/eterra/tiles/orto, converts
  Web Mercator z/x/y to EPSG:3844 bbox, authenticates with eTerra
  session, caches 24h. Requires ETERRA_USERNAME/PASSWORD env vars.

Replaces old raster OSM/OpenTopoMap with vector styles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 18:43:21 +02:00
parent 06932b5ddc
commit 6c55264fa3
5 changed files with 197 additions and 48 deletions
@@ -1,14 +1,15 @@
"use client";
import { Map, Mountain, Satellite } from "lucide-react";
import { Map, Moon, Satellite, TreePine } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import type { BasemapId } from "../types";
const BASEMAPS: { id: BasemapId; label: string; icon: typeof Map }[] = [
{ id: "osm", label: "Harta", icon: Map },
{ id: "liberty", label: "Harta", icon: Map },
{ id: "dark", label: "Dark", icon: Moon },
{ id: "satellite", label: "Satelit", icon: Satellite },
{ id: "topo", label: "Teren", icon: Mountain },
{ id: "orto", label: "ANCPI", icon: TreePine },
];
type BasemapSwitcherProps = {
@@ -40,7 +40,7 @@ export function GeoportalModule() {
const mapHandleRef = useRef<MapViewerHandle>(null);
// Map state
const [basemap, setBasemap] = useState<BasemapId>("osm");
const [basemap, setBasemap] = useState<BasemapId>("liberty");
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
getDefaultVisibility
);
+48 -43
View File
@@ -52,37 +52,61 @@ const LAYER_IDS = {
selectionLine: "layer-selection-line",
} as const;
/** Basemap tile definitions */
const BASEMAP_TILES: Record<BasemapId, { tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }> = {
osm: {
tiles: [
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
],
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
tileSize: 256,
/** Basemap definitions — vector style URL or inline raster config */
type BasemapDef =
| { type: "style"; url: string; maxzoom?: number }
| { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number };
const BASEMAPS: Record<BasemapId, BasemapDef> = {
liberty: {
type: "style",
url: "https://tiles.openfreemap.org/styles/liberty",
},
dark: {
type: "style",
url: "https://tiles.openfreemap.org/styles/dark",
},
satellite: {
type: "raster",
tiles: [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
],
attribution: '&copy; <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics',
tileSize: 256,
},
topo: {
tiles: [
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://b.tile.opentopomap.org/{z}/{x}/{y}.png",
"https://c.tile.opentopomap.org/{z}/{x}/{y}.png",
],
attribution:
'&copy; <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
tileSize: 256,
maxzoom: 17,
orto: {
type: "raster",
tiles: ["/api/eterra/tiles/orto?z={z}&x={x}&y={y}"],
attribution: '&copy; <a href="https://ancpi.ro">ANCPI</a> Ortofoto 2024',
tileSize: 512,
maxzoom: 19,
},
};
function buildStyle(def: BasemapDef): string | maplibregl.StyleSpecification {
if (def.type === "style") return def.url;
return {
version: 8 as const,
sources: {
basemap: {
type: "raster" as const,
tiles: def.tiles,
tileSize: def.tileSize,
attribution: def.attribution,
},
},
layers: [
{
id: "basemap-tiles",
type: "raster" as const,
source: "basemap",
minzoom: 0,
maxzoom: def.maxzoom ?? 19,
},
],
};
}
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
@@ -135,7 +159,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
zoom,
martinUrl,
className,
basemap = "osm",
basemap = "liberty",
selectionMode = false,
onFeatureClick,
onSelectionChange,
@@ -236,33 +260,14 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
useEffect(() => {
if (!containerRef.current) return;
const initialBasemap = BASEMAP_TILES[basemap];
const basemapDef = BASEMAPS[basemap];
const map = new maplibregl.Map({
container: containerRef.current,
style: {
version: 8,
sources: {
basemap: {
type: "raster",
tiles: initialBasemap.tiles,
tileSize: initialBasemap.tileSize,
attribution: initialBasemap.attribution,
},
},
layers: [
{
id: "basemap-tiles",
type: "raster",
source: "basemap",
minzoom: 0,
maxzoom: initialBasemap.maxzoom ?? 19,
},
],
},
style: buildStyle(basemapDef),
center: center ?? DEFAULT_CENTER,
zoom: zoom ?? DEFAULT_ZOOM,
maxZoom: initialBasemap.maxzoom ?? 20,
maxZoom: basemapDef.maxzoom ?? 20,
});
mapRef.current = map;
+1 -1
View File
@@ -46,7 +46,7 @@ export type MapViewState = {
/* Basemap */
/* ------------------------------------------------------------------ */
export type BasemapId = "osm" | "satellite" | "topo";
export type BasemapId = "liberty" | "dark" | "satellite" | "orto";
export type BasemapDef = {
id: BasemapId;