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