From 6c55264fa30c91e51e852cbb725daadc8cef833e Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 18:43:21 +0200 Subject: [PATCH] feat(geoportal): OpenFreeMap vector basemaps + eTerra ORTO 2024 ortophoto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/eterra/tiles/orto/route.ts | 143 ++++++++++++++++++ .../geoportal/components/basemap-switcher.tsx | 7 +- .../geoportal/components/geoportal-module.tsx | 2 +- .../geoportal/components/map-viewer.tsx | 91 +++++------ src/modules/geoportal/types.ts | 2 +- 5 files changed, 197 insertions(+), 48 deletions(-) create mode 100644 src/app/api/eterra/tiles/orto/route.ts diff --git a/src/app/api/eterra/tiles/orto/route.ts b/src/app/api/eterra/tiles/orto/route.ts new file mode 100644 index 0000000..698ea17 --- /dev/null +++ b/src/app/api/eterra/tiles/orto/route.ts @@ -0,0 +1,143 @@ +/** + * GET /api/eterra/tiles/orto?z=...&x=...&y=... + * + * Proxies eTerra ORTO2024 ortophoto tiles. Converts Web Mercator + * tile coordinates to EPSG:3844 bbox and fetches from eTerra exportImage. + * Requires active eTerra session (uses stored credentials). + */ +import { NextResponse } from "next/server"; +import proj4 from "proj4"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Register projections +const EPSG_3844_DEF = + "+proj=sterea +lat_0=46 +lon_0=25 +k=0.99975 +x_0=500000 +y_0=500000 +ellps=GRS80 +units=m +no_defs"; +proj4.defs("EPSG:3844", EPSG_3844_DEF); +proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs"); + +const TILE_SIZE = 512; +const ETERRA_BASE = "https://eterra.ancpi.ro/eterra"; +const ORTO_ENDPOINT = `${ETERRA_BASE}/api/map/rest/basemap/ORTO2024/exportImage`; + +/** Convert tile z/x/y to WGS84 bounding box [west, south, east, north] */ +function tileToBbox(z: number, x: number, y: number): [number, number, number, number] { + const n = Math.pow(2, z); + const lonW = (x / n) * 360 - 180; + const lonE = ((x + 1) / n) * 360 - 180; + const latN = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI; + const latS = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI; + return [lonW, latS, lonE, latN]; +} + +/** Reproject WGS84 bbox to EPSG:3844 */ +function bboxTo3844(bbox4326: [number, number, number, number]): [number, number, number, number] { + const [w, s, e, n] = bbox4326; + const sw = proj4("EPSG:4326", "EPSG:3844", [w, s]); + const ne = proj4("EPSG:4326", "EPSG:3844", [e, n]); + return [sw[0]!, sw[1]!, ne[0]!, ne[1]!]; +} + +// Simple in-memory cookie cache for eTerra session +let cachedCookie: string | null = null; +let cookieExpiry = 0; + +async function getEterraCookie(): Promise { + if (cachedCookie && Date.now() < cookieExpiry) return cachedCookie; + + const username = process.env.ETERRA_USERNAME; + const password = process.env.ETERRA_PASSWORD; + if (!username || !password) return null; + + try { + const loginUrl = `${ETERRA_BASE}/api/authentication`; + const body = new URLSearchParams({ + j_username: username, + j_password: password, + j_uuid: "undefined", + j_isRevoked: "undefined", + _spring_security_remember_me: "true", + submit: "Login", + }); + + const resp = await fetch(loginUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + redirect: "manual", + }); + + const setCookies = resp.headers.getSetCookie?.() ?? []; + const jsessionId = setCookies + .find((c) => c.startsWith("JSESSIONID=")) + ?.split(";")[0]; + + if (jsessionId) { + cachedCookie = jsessionId; + cookieExpiry = Date.now() + 8 * 60 * 1000; // 8 min TTL + return cachedCookie; + } + return null; + } catch { + return null; + } +} + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const z = parseInt(url.searchParams.get("z") ?? "", 10); + const x = parseInt(url.searchParams.get("x") ?? "", 10); + const y = parseInt(url.searchParams.get("y") ?? "", 10); + + if (isNaN(z) || isNaN(x) || isNaN(y)) { + return NextResponse.json({ error: "z, x, y required" }, { status: 400 }); + } + + // Only serve tiles within Romania's approximate bounds (zoom >= 6) + if (z < 6) { + return new Response(null, { status: 204 }); + } + + const bbox4326 = tileToBbox(z, x, y); + const bbox3844 = bboxTo3844(bbox4326); + + const cookie = await getEterraCookie(); + if (!cookie) { + return NextResponse.json( + { error: "eTerra login esuat - verificati credentialele" }, + { status: 401 } + ); + } + + const params = new URLSearchParams({ + f: "image", + bbox: bbox3844.join(","), + imageSR: "3844", + bboxSR: "3844", + size: `${TILE_SIZE},${TILE_SIZE}`, + }); + + const imageResp = await fetch(`${ORTO_ENDPOINT}?${params}`, { + headers: { Cookie: cookie }, + signal: AbortSignal.timeout(15_000), + }); + + if (!imageResp.ok) { + // Return transparent tile for areas without coverage + return new Response(null, { status: 204 }); + } + + const imageBuffer = await imageResp.arrayBuffer(); + + return new Response(imageBuffer, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=86400", // cache 24h + }, + }); + } catch { + return new Response(null, { status: 204 }); + } +} diff --git a/src/modules/geoportal/components/basemap-switcher.tsx b/src/modules/geoportal/components/basemap-switcher.tsx index 2a38c61..b0d98b6 100644 --- a/src/modules/geoportal/components/basemap-switcher.tsx +++ b/src/modules/geoportal/components/basemap-switcher.tsx @@ -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 = { diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx index 93bd1f7..9ea315e 100644 --- a/src/modules/geoportal/components/geoportal-module.tsx +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -40,7 +40,7 @@ export function GeoportalModule() { const mapHandleRef = useRef(null); // Map state - const [basemap, setBasemap] = useState("osm"); + const [basemap, setBasemap] = useState("liberty"); const [layerVisibility, setLayerVisibility] = useState( getDefaultVisibility ); diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx index f4aba08..afe8e66 100644 --- a/src/modules/geoportal/components/map-viewer.tsx +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -52,37 +52,61 @@ const LAYER_IDS = { selectionLine: "layer-selection-line", } as const; -/** Basemap tile definitions */ -const BASEMAP_TILES: Record = { - 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: '© OpenStreetMap', - 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 = { + 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: '© Esri, 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: - '© OpenTopoMap (CC-BY-SA)', - tileSize: 256, - maxzoom: 17, + orto: { + type: "raster", + tiles: ["/api/eterra/tiles/orto?z={z}&x={x}&y={y}"], + attribution: '© ANCPI 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( zoom, martinUrl, className, - basemap = "osm", + basemap = "liberty", selectionMode = false, onFeatureClick, onSelectionChange, @@ -236,33 +260,14 @@ export const MapViewer = forwardRef( 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; diff --git a/src/modules/geoportal/types.ts b/src/modules/geoportal/types.ts index 7754e3d..f8ce8bf 100644 --- a/src/modules/geoportal/types.ts +++ b/src/modules/geoportal/types.ts @@ -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;