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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user