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:
@@ -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<string | null> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"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 { Button } from "@/shared/components/ui/button";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { BasemapId } from "../types";
|
import type { BasemapId } from "../types";
|
||||||
|
|
||||||
const BASEMAPS: { id: BasemapId; label: string; icon: typeof Map }[] = [
|
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: "satellite", label: "Satelit", icon: Satellite },
|
||||||
{ id: "topo", label: "Teren", icon: Mountain },
|
{ id: "orto", label: "ANCPI", icon: TreePine },
|
||||||
];
|
];
|
||||||
|
|
||||||
type BasemapSwitcherProps = {
|
type BasemapSwitcherProps = {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function GeoportalModule() {
|
|||||||
const mapHandleRef = useRef<MapViewerHandle>(null);
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
||||||
|
|
||||||
// Map state
|
// Map state
|
||||||
const [basemap, setBasemap] = useState<BasemapId>("osm");
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
|
||||||
getDefaultVisibility
|
getDefaultVisibility
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,37 +52,61 @@ const LAYER_IDS = {
|
|||||||
selectionLine: "layer-selection-line",
|
selectionLine: "layer-selection-line",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Basemap tile definitions */
|
/** Basemap definitions — vector style URL or inline raster config */
|
||||||
const BASEMAP_TILES: Record<BasemapId, { tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }> = {
|
type BasemapDef =
|
||||||
osm: {
|
| { type: "style"; url: string; maxzoom?: number }
|
||||||
tiles: [
|
| { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number };
|
||||||
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
const BASEMAPS: Record<BasemapId, BasemapDef> = {
|
||||||
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
liberty: {
|
||||||
],
|
type: "style",
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
url: "https://tiles.openfreemap.org/styles/liberty",
|
||||||
tileSize: 256,
|
},
|
||||||
|
dark: {
|
||||||
|
type: "style",
|
||||||
|
url: "https://tiles.openfreemap.org/styles/dark",
|
||||||
},
|
},
|
||||||
satellite: {
|
satellite: {
|
||||||
|
type: "raster",
|
||||||
tiles: [
|
tiles: [
|
||||||
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
],
|
],
|
||||||
attribution: '© <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics',
|
attribution: '© <a href="https://www.esri.com">Esri</a>, Maxar, Earthstar Geographics',
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
},
|
},
|
||||||
topo: {
|
orto: {
|
||||||
tiles: [
|
type: "raster",
|
||||||
"https://a.tile.opentopomap.org/{z}/{x}/{y}.png",
|
tiles: ["/api/eterra/tiles/orto?z={z}&x={x}&y={y}"],
|
||||||
"https://b.tile.opentopomap.org/{z}/{x}/{y}.png",
|
attribution: '© <a href="https://ancpi.ro">ANCPI</a> Ortofoto 2024',
|
||||||
"https://c.tile.opentopomap.org/{z}/{x}/{y}.png",
|
tileSize: 512,
|
||||||
],
|
maxzoom: 19,
|
||||||
attribution:
|
|
||||||
'© <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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 */
|
/* Props */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -135,7 +159,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
zoom,
|
zoom,
|
||||||
martinUrl,
|
martinUrl,
|
||||||
className,
|
className,
|
||||||
basemap = "osm",
|
basemap = "liberty",
|
||||||
selectionMode = false,
|
selectionMode = false,
|
||||||
onFeatureClick,
|
onFeatureClick,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -236,33 +260,14 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const initialBasemap = BASEMAP_TILES[basemap];
|
const basemapDef = BASEMAPS[basemap];
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: {
|
style: buildStyle(basemapDef),
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
center: center ?? DEFAULT_CENTER,
|
center: center ?? DEFAULT_CENTER,
|
||||||
zoom: zoom ?? DEFAULT_ZOOM,
|
zoom: zoom ?? DEFAULT_ZOOM,
|
||||||
maxZoom: initialBasemap.maxzoom ?? 20,
|
maxZoom: basemapDef.maxzoom ?? 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export type MapViewState = {
|
|||||||
/* Basemap */
|
/* Basemap */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export type BasemapId = "osm" | "satellite" | "topo";
|
export type BasemapId = "liberty" | "dark" | "satellite" | "orto";
|
||||||
|
|
||||||
export type BasemapDef = {
|
export type BasemapDef = {
|
||||||
id: BasemapId;
|
id: BasemapId;
|
||||||
|
|||||||
Reference in New Issue
Block a user