feat(geoportal): add search, basemap switcher, feature info panel, selection + export

Major geoportal enhancements:
- Basemap switcher (OSM/Satellite/Terrain) with ESRI + OpenTopoMap tiles
- Search bar with debounced lookup (UATs by name, parcels by cadastral ref, owners by name)
- Feature info panel showing enrichment data from ParcelSync (cadastru, proprietari, suprafata, folosinta)
- Parcel selection mode with amber highlight + export (GeoJSON/DXF/GPKG via ogr2ogr)
- Next.js /tiles rewrite proxying to Martin (fixes dev + avoids mixed content)
- Fixed MapLibre web worker relative URL resolution (window.location.origin)

API routes: /api/geoportal/search, /api/geoportal/feature, /api/geoportal/export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 16:43:01 +02:00
parent 4ea7c6dbd6
commit 1b5876524a
11 changed files with 1427 additions and 33 deletions
+202
View File
@@ -0,0 +1,202 @@
/**
* POST /api/geoportal/export
*
* Exports selected GIS features as GeoJSON, DXF, or GeoPackage.
* Body: { ids: string[], format: "geojson" | "dxf" | "gpkg" }
*
* - GeoJSON: always works (pure JS)
* - DXF/GPKG: requires ogr2ogr (available in Docker image via gdal-tools)
*/
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import { writeFile, readFile, unlink, mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { execFile } from "child_process";
import { promisify } from "util";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const execFileAsync = promisify(execFile);
type ExportRequest = {
ids: string[];
format: "geojson" | "dxf" | "gpkg";
};
export async function POST(req: Request) {
let tmpDir: string | null = null;
try {
const body = (await req.json()) as ExportRequest;
const { ids, format } = body;
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json(
{ error: "Selecteaza cel putin o parcela" },
{ status: 400 }
);
}
if (!["geojson", "dxf", "gpkg"].includes(format)) {
return NextResponse.json(
{ error: "Format invalid. Optiuni: geojson, dxf, gpkg" },
{ status: 400 }
);
}
// The IDs from the selection are objectId strings
// Query features by objectId (converted to int)
const objectIds = ids.map((id) => parseInt(id, 10)).filter((n) => !isNaN(n));
if (objectIds.length === 0) {
return NextResponse.json(
{ error: "Niciun ID valid in selectie" },
{ status: 400 }
);
}
const features = await prisma.gisFeature.findMany({
where: {
objectId: { in: objectIds },
geometry: { not: Prisma.JsonNull },
},
select: {
objectId: true,
cadastralRef: true,
areaValue: true,
attributes: true,
geometry: true,
enrichment: true,
layerId: true,
siruta: true,
},
});
if (features.length === 0) {
return NextResponse.json(
{ error: "Niciun feature cu geometrie gasit" },
{ status: 404 }
);
}
// Build GeoJSON FeatureCollection
const geojson = {
type: "FeatureCollection" as const,
crs: {
type: "name",
properties: { name: "urn:ogc:def:crs:EPSG::3844" },
},
features: features.map((f) => {
const enrichment = (f.enrichment ?? {}) as Record<string, unknown>;
return {
type: "Feature" as const,
geometry: f.geometry as Record<string, unknown>,
properties: {
objectId: f.objectId,
cadastralRef: f.cadastralRef,
areaValue: f.areaValue,
layerId: f.layerId,
siruta: f.siruta,
NR_CAD: enrichment.NR_CAD ?? "",
NR_CF: enrichment.NR_CF ?? "",
PROPRIETARI: enrichment.PROPRIETARI ?? "",
SUPRAFATA: enrichment.SUPRAFATA_2D ?? "",
INTRAVILAN: enrichment.INTRAVILAN ?? "",
CATEGORIE: enrichment.CATEGORIE_FOLOSINTA ?? "",
},
};
}),
};
const timestamp = new Date().toISOString().slice(0, 10);
// GeoJSON — return directly
if (format === "geojson") {
const filename = `parcele_${timestamp}.geojson`;
return new Response(JSON.stringify(geojson, null, 2), {
headers: {
"Content-Type": "application/geo+json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
// DXF or GPKG — use ogr2ogr
tmpDir = await mkdtemp(join(tmpdir(), "geoportal-export-"));
const inputPath = join(tmpDir, "input.geojson");
await writeFile(inputPath, JSON.stringify(geojson));
const ext = format === "dxf" ? "dxf" : "gpkg";
const outputPath = join(tmpDir, `output.${ext}`);
const ogrFormat = format === "dxf" ? "DXF" : "GPKG";
// For DXF, convert to WGS84 (architects expect it). For GPKG keep native CRS.
const ogrArgs = [
"-f", ogrFormat,
outputPath,
inputPath,
"-a_srs", "EPSG:3844",
];
if (format === "dxf") {
ogrArgs.push("-t_srs", "EPSG:4326");
}
try {
await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 });
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
// ogr2ogr not available (local dev without GDAL)
if (errMsg.includes("ENOENT") || errMsg.includes("not found")) {
return NextResponse.json(
{ error: `Export ${format.toUpperCase()} disponibil doar in productie (Docker cu GDAL)` },
{ status: 501 }
);
}
return NextResponse.json(
{ error: `ogr2ogr a esuat: ${errMsg}` },
{ status: 500 }
);
}
const outputBuffer = await readFile(outputPath);
const filename = `parcele_${timestamp}.${ext}`;
const contentType =
format === "dxf"
? "application/dxf"
: "application/geopackage+sqlite3";
// Clean up temp files (best effort)
cleanup(tmpDir);
tmpDir = null;
return new Response(outputBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
if (tmpDir) cleanup(tmpDir);
const msg = error instanceof Error ? error.message : "Eroare la export";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
async function cleanup(dir: string) {
try {
const { readdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
const { rmdir } = await import("fs/promises");
await rmdir(dir).catch(() => {});
} catch {
// best effort
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* GET /api/geoportal/feature?objectId=...&siruta=...&sourceLayer=...
*
* Returns a single GIS feature with enrichment data for the info panel.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const objectId = url.searchParams.get("objectId");
const siruta = url.searchParams.get("siruta");
const sourceLayer = url.searchParams.get("sourceLayer") ?? "";
if (!objectId || !siruta) {
return NextResponse.json(
{ error: "Parametri lipsa: objectId si siruta sunt obligatorii" },
{ status: 400 }
);
}
// UAT features come from GisUat table
if (sourceLayer === "gis_uats") {
const uat = await prisma.gisUat.findUnique({
where: { siruta },
select: {
siruta: true,
name: true,
county: true,
areaValue: true,
workspacePk: true,
},
});
if (!uat) {
return NextResponse.json({ error: "UAT negasit" }, { status: 404 });
}
return NextResponse.json({
feature: {
id: uat.siruta,
layerId: "LIMITE_UAT",
siruta: uat.siruta,
objectId: 0,
cadastralRef: null,
areaValue: uat.areaValue,
enrichment: null,
enrichedAt: null,
extra: {
name: uat.name,
county: uat.county,
workspacePk: uat.workspacePk,
},
},
});
}
// GisFeature (parcels, buildings)
const objId = parseInt(objectId, 10);
if (isNaN(objId)) {
return NextResponse.json({ error: "objectId invalid" }, { status: 400 });
}
const feature = await prisma.gisFeature.findFirst({
where: {
objectId: objId,
siruta,
},
select: {
id: true,
layerId: true,
siruta: true,
objectId: true,
cadastralRef: true,
areaValue: true,
enrichment: true,
enrichedAt: true,
inspireId: true,
},
});
if (!feature) {
return NextResponse.json(
{ error: "Feature negasit in baza de date" },
{ status: 404 }
);
}
return NextResponse.json({
feature: {
id: feature.id,
layerId: feature.layerId,
siruta: feature.siruta,
objectId: feature.objectId,
cadastralRef: feature.cadastralRef,
areaValue: feature.areaValue,
enrichment: feature.enrichment as Record<string, unknown> | null,
enrichedAt: feature.enrichedAt?.toISOString() ?? null,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* GET /api/geoportal/search?q=...&type=...&limit=...
*
* Searches parcels (by cadastral ref, owner) and UATs (by name).
* Returns centroids in EPSG:4326 (WGS84) for map flyTo.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type SearchResultItem = {
id: string;
type: "parcel" | "uat" | "building";
label: string;
sublabel?: string;
coordinates?: [number, number];
};
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim() ?? "";
const typeFilter = url.searchParams.get("type") ?? "";
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20", 10), 50);
if (q.length < 2) {
return NextResponse.json({ results: [] });
}
const results: SearchResultItem[] = [];
const pattern = `%${q}%`;
// Search UATs by name
if (!typeFilter || typeFilter === "uat") {
const uats = await prisma.$queryRaw`
SELECT
siruta,
name,
county,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisUat"
WHERE geom IS NOT NULL
AND (name ILIKE ${pattern} OR county ILIKE ${pattern})
ORDER BY name
LIMIT ${limit}
` as Array<{ siruta: string; name: string; county: string | null; lng: number; lat: number }>;
for (const u of uats) {
results.push({
id: `uat-${u.siruta}`,
type: "uat",
label: u.name,
sublabel: u.county ? `Jud. ${u.county}` : undefined,
coordinates: u.lng && u.lat ? [u.lng, u.lat] : undefined,
});
}
}
// Search parcels by cadastral ref or enrichment data
if (!typeFilter || typeFilter === "parcel") {
const isNumericish = /^\d/.test(q);
if (isNumericish) {
// Search by cadastral reference
const parcels = await prisma.$queryRaw`
SELECT
id,
"cadastralRef",
"areaValue",
siruta,
enrichment,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisFeature"
WHERE geom IS NOT NULL
AND "layerId" LIKE 'TERENURI%'
AND ("cadastralRef" ILIKE ${pattern}
OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'})
ORDER BY "cadastralRef"
LIMIT ${limit}
` as Array<{
id: string;
cadastralRef: string | null;
areaValue: number | null;
siruta: string;
enrichment: Record<string, unknown> | null;
lng: number;
lat: number;
}>;
for (const p of parcels) {
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
results.push({
id: `parcel-${p.id}`,
type: "parcel",
label: `Parcela ${nrCad}`,
sublabel: [area, `SIRUTA ${p.siruta}`].filter(Boolean).join(" | "),
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
} else {
// Search by owner name in enrichment JSON
const parcels = await prisma.$queryRaw`
SELECT
id,
"cadastralRef",
"areaValue",
siruta,
enrichment,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisFeature"
WHERE geom IS NOT NULL
AND "layerId" LIKE 'TERENURI%'
AND enrichment IS NOT NULL
AND enrichment::text ILIKE ${pattern}
ORDER BY "cadastralRef"
LIMIT ${limit}
` as Array<{
id: string;
cadastralRef: string | null;
areaValue: number | null;
siruta: string;
enrichment: Record<string, unknown> | null;
lng: number;
lat: number;
}>;
for (const p of parcels) {
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
results.push({
id: `parcel-${p.id}`,
type: "parcel",
label: `Parcela ${nrCad}`,
sublabel: owner.length > 60 ? owner.slice(0, 60) + "..." : owner,
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
}
}
return NextResponse.json({ results: results.slice(0, limit) });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare la cautare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}