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:
@@ -6,6 +6,15 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
middlewareClientMaxBodySize: '500mb',
|
middlewareClientMaxBodySize: '500mb',
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/tiles/:path*',
|
||||||
|
destination: `${martinUrl}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Map, Mountain, Satellite } 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: "satellite", label: "Satelit", icon: Satellite },
|
||||||
|
{ id: "topo", label: "Teren", icon: Mountain },
|
||||||
|
];
|
||||||
|
|
||||||
|
type BasemapSwitcherProps = {
|
||||||
|
value: BasemapId;
|
||||||
|
onChange: (id: BasemapId) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasemapSwitcher({ value, onChange }: BasemapSwitcherProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex p-0.5 gap-0.5">
|
||||||
|
{BASEMAPS.map((b) => {
|
||||||
|
const Icon = b.icon;
|
||||||
|
const active = value === b.id;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={b.id}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 h-7 text-xs gap-1 rounded-md",
|
||||||
|
active && "bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(b.id)}
|
||||||
|
title={b.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{b.label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
MapPin,
|
||||||
|
User,
|
||||||
|
Ruler,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
TreePine,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { ClickedFeature, FeatureDetail, FeatureEnrichmentData } from "../types";
|
||||||
|
|
||||||
|
type FeatureInfoPanelProps = {
|
||||||
|
feature: ClickedFeature | null;
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FeatureInfoPanel({
|
||||||
|
feature,
|
||||||
|
onClose,
|
||||||
|
className,
|
||||||
|
}: FeatureInfoPanelProps) {
|
||||||
|
const [detail, setDetail] = useState<FeatureDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!feature) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load enrichment from API using the object_id from vector tile props
|
||||||
|
const objectId = feature.properties.object_id ?? feature.properties.objectId;
|
||||||
|
const siruta = feature.properties.siruta;
|
||||||
|
if (!objectId || !siruta) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then((data: { feature: FeatureDetail }) => {
|
||||||
|
if (!cancelled) setDetail(data.feature);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!cancelled) setError(err instanceof Error ? err.message : "Eroare la incarcarea detaliilor");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [feature]);
|
||||||
|
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
const enrichment = detail?.enrichment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg w-80 max-h-[calc(100vh-16rem)] overflow-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-3 border-b sticky top-0 bg-background/95 backdrop-blur-sm z-10">
|
||||||
|
<h3 className="text-sm font-semibold truncate flex-1">
|
||||||
|
{enrichment?.NR_CAD
|
||||||
|
? `Parcela ${enrichment.NR_CAD}`
|
||||||
|
: feature.sourceLayer === "gis_uats"
|
||||||
|
? `UAT ${feature.properties.name ?? ""}`
|
||||||
|
: `Obiect #${feature.properties.object_id ?? feature.properties.objectId ?? "?"}`}
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 ml-2" onClick={onClose}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Se incarca...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive">Nu s-au putut incarca detaliile ({error})</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic props from vector tile */}
|
||||||
|
{!loading && !enrichment && (
|
||||||
|
<PropsTable properties={feature.properties} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enrichment data */}
|
||||||
|
{enrichment && <EnrichmentView data={enrichment} />}
|
||||||
|
|
||||||
|
{/* Coordinates */}
|
||||||
|
<div className="text-xs text-muted-foreground pt-1 border-t">
|
||||||
|
<MapPin className="h-3 w-3 inline mr-1" />
|
||||||
|
{feature.coordinates[1].toFixed(5)}, {feature.coordinates[0].toFixed(5)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Enrichment view */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function EnrichmentView({ data }: { data: FeatureEnrichmentData }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{/* Cadastral info */}
|
||||||
|
<Section icon={FileText} label="Cadastru">
|
||||||
|
<Row label="Nr. cadastral" value={data.NR_CAD} />
|
||||||
|
<Row label="Nr. CF" value={data.NR_CF} />
|
||||||
|
{data.NR_CF_VECHI && data.NR_CF_VECHI !== "-" && (
|
||||||
|
<Row label="CF vechi" value={data.NR_CF_VECHI} />
|
||||||
|
)}
|
||||||
|
{data.NR_TOPO && data.NR_TOPO !== "-" && (
|
||||||
|
<Row label="Nr. topo" value={data.NR_TOPO} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Owners */}
|
||||||
|
{data.PROPRIETARI && data.PROPRIETARI !== "-" && (
|
||||||
|
<Section icon={User} label="Proprietari">
|
||||||
|
<p className="text-xs leading-relaxed">{data.PROPRIETARI}</p>
|
||||||
|
{data.PROPRIETARI_VECHI && data.PROPRIETARI_VECHI !== "-" && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Anterior: {data.PROPRIETARI_VECHI}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Area */}
|
||||||
|
<Section icon={Ruler} label="Suprafata">
|
||||||
|
<Row label="Masurata" value={formatArea(data.SUPRAFATA_2D)} />
|
||||||
|
<Row label="Rotunjita" value={formatArea(data.SUPRAFATA_R)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Land use */}
|
||||||
|
<Section icon={TreePine} label="Folosinta">
|
||||||
|
<Row label="Categorie" value={data.CATEGORIE_FOLOSINTA} />
|
||||||
|
<Row
|
||||||
|
label="Intravilan"
|
||||||
|
value={
|
||||||
|
<Badge variant={data.INTRAVILAN === "DA" ? "default" : "secondary"} className="text-xs h-5">
|
||||||
|
{data.INTRAVILAN || "-"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Building */}
|
||||||
|
{data.HAS_BUILDING === 1 && (
|
||||||
|
<Section icon={Building2} label="Constructie">
|
||||||
|
<Row label="Autorizata" value={data.BUILD_LEGAL ? "Da" : "Nu"} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
{data.ADRESA && data.ADRESA !== "-" && (
|
||||||
|
<Section icon={MapPin} label="Adresa">
|
||||||
|
<p className="text-xs">{data.ADRESA}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
icon: typeof FileText;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 space-y-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
if (!value || value === "-" || value === "") return null;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between text-xs gap-2">
|
||||||
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||||
|
<span className="text-right font-medium truncate">{typeof value === "string" ? value : value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropsTable({ properties }: { properties: Record<string, unknown> }) {
|
||||||
|
const entries = Object.entries(properties).filter(
|
||||||
|
([, v]) => v != null && v !== ""
|
||||||
|
);
|
||||||
|
if (entries.length === 0) return <p className="text-xs text-muted-foreground">Fara atribute</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{entries.map(([key, value]) => (
|
||||||
|
<Row key={key} label={key.replace(/_/g, " ")} value={String(value)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArea(v: number | string): string {
|
||||||
|
if (v === "" || v == null) return "-";
|
||||||
|
const n = typeof v === "string" ? parseFloat(v) : v;
|
||||||
|
if (isNaN(n)) return String(v);
|
||||||
|
return `${n.toLocaleString("ro-RO")} mp`;
|
||||||
|
}
|
||||||
@@ -4,8 +4,18 @@ import { useState, useRef, useCallback } from "react";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
||||||
|
import { BasemapSwitcher } from "./basemap-switcher";
|
||||||
|
import { SearchBar } from "./search-bar";
|
||||||
|
import { SelectionToolbar } from "./selection-toolbar";
|
||||||
|
import { FeatureInfoPanel } from "./feature-info-panel";
|
||||||
import type { MapViewerHandle } from "./map-viewer";
|
import type { MapViewerHandle } from "./map-viewer";
|
||||||
import type { ClickedFeature, LayerVisibility } from "../types";
|
import type {
|
||||||
|
BasemapId,
|
||||||
|
ClickedFeature,
|
||||||
|
LayerVisibility,
|
||||||
|
SearchResult,
|
||||||
|
SelectedFeature,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
/* MapLibre uses WebGL — must disable SSR */
|
/* MapLibre uses WebGL — must disable SSR */
|
||||||
const MapViewer = dynamic(
|
const MapViewer = dynamic(
|
||||||
@@ -29,20 +39,60 @@ const MapViewer = dynamic(
|
|||||||
|
|
||||||
export function GeoportalModule() {
|
export function GeoportalModule() {
|
||||||
const mapHandleRef = useRef<MapViewerHandle>(null);
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
||||||
|
|
||||||
|
// Map state
|
||||||
|
const [basemap, setBasemap] = useState<BasemapId>("osm");
|
||||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
|
||||||
getDefaultVisibility
|
getDefaultVisibility
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Feature info
|
||||||
|
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
const [selectionMode, setSelectionMode] = useState(false);
|
||||||
|
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
||||||
|
|
||||||
|
// Fly-to target (from search)
|
||||||
|
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
|
||||||
|
|
||||||
const handleFeatureClick = useCallback((feature: ClickedFeature) => {
|
const handleFeatureClick = useCallback((feature: ClickedFeature) => {
|
||||||
// Feature click is handled by the MapViewer popup internally.
|
setClickedFeature(feature);
|
||||||
// This callback is available for future integration (e.g., detail panel).
|
|
||||||
void feature;
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
|
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
|
||||||
setLayerVisibility(vis);
|
setLayerVisibility(vis);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSearchResult = useCallback((result: SearchResult) => {
|
||||||
|
if (result.coordinates) {
|
||||||
|
setFlyTarget({
|
||||||
|
center: result.coordinates,
|
||||||
|
zoom: result.type === "uat" ? 12 : 17,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((features: SelectedFeature[]) => {
|
||||||
|
setSelectedFeatures(features);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleSelectionMode = useCallback(() => {
|
||||||
|
setSelectionMode((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
// Turning off: clear selection
|
||||||
|
mapHandleRef.current?.clearSelection();
|
||||||
|
setSelectedFeatures([]);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
mapHandleRef.current?.clearSelection();
|
||||||
|
setSelectedFeatures([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -61,17 +111,48 @@ export function GeoportalModule() {
|
|||||||
<MapViewer
|
<MapViewer
|
||||||
ref={mapHandleRef}
|
ref={mapHandleRef}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
|
basemap={basemap}
|
||||||
|
selectionMode={selectionMode}
|
||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
|
center={flyTarget?.center}
|
||||||
|
zoom={flyTarget?.zoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Layer panel overlay */}
|
{/* Top-left controls: search + layers */}
|
||||||
<div className="absolute top-3 left-3 z-10">
|
<div className="absolute top-3 left-3 z-10 flex flex-col gap-2 max-w-xs">
|
||||||
|
<SearchBar onResultSelect={handleSearchResult} />
|
||||||
<LayerPanel
|
<LayerPanel
|
||||||
visibility={layerVisibility}
|
visibility={layerVisibility}
|
||||||
onVisibilityChange={handleVisibilityChange}
|
onVisibilityChange={handleVisibilityChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top-right: basemap switcher */}
|
||||||
|
<div className="absolute top-3 right-14 z-10">
|
||||||
|
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-left: selection toolbar */}
|
||||||
|
<div className="absolute bottom-8 left-3 z-10">
|
||||||
|
<SelectionToolbar
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onToggleSelectionMode={handleToggleSelectionMode}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: feature info panel */}
|
||||||
|
{clickedFeature && !selectionMode && (
|
||||||
|
<div className="absolute top-3 right-3 z-10 mt-12">
|
||||||
|
<FeatureInfoPanel
|
||||||
|
feature={clickedFeature}
|
||||||
|
onClose={() => setClickedFeature(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardR
|
|||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import "maplibre-gl/dist/maplibre-gl.css";
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { ClickedFeature, LayerVisibility } from "../types";
|
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* Constants */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Martin tile URL — use relative /tiles path (proxied by Traefik).
|
* Martin tile URL — relative /tiles is proxied by Next.js rewrite (dev)
|
||||||
* This works both in production (HTTPS) and avoids mixed-content issues.
|
* or Traefik (production). Falls back to env var if set.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_MARTIN_URL = "/tiles";
|
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
||||||
|
|
||||||
/** Default center: Romania roughly centered */
|
/** Default center: Romania roughly centered */
|
||||||
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
||||||
@@ -36,8 +36,40 @@ const LAYER_IDS = {
|
|||||||
terenuriLine: "layer-terenuri-line",
|
terenuriLine: "layer-terenuri-line",
|
||||||
cladiriFill: "layer-cladiri-fill",
|
cladiriFill: "layer-cladiri-fill",
|
||||||
cladiriLine: "layer-cladiri-line",
|
cladiriLine: "layer-cladiri-line",
|
||||||
|
selectionFill: "layer-selection-fill",
|
||||||
|
selectionLine: "layer-selection-line",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** Basemap tile definitions */
|
||||||
|
const BASEMAP_TILES: Record<BasemapId, { tiles: string[]; attribution: string; tileSize: number }> = {
|
||||||
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
tileSize: 256,
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
tiles: [
|
||||||
|
"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',
|
||||||
|
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:
|
||||||
|
'© <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)',
|
||||||
|
tileSize: 256,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Props */
|
/* Props */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -46,6 +78,7 @@ export type MapViewerHandle = {
|
|||||||
getMap: () => maplibregl.Map | null;
|
getMap: () => maplibregl.Map | null;
|
||||||
setLayerVisibility: (visibility: LayerVisibility) => void;
|
setLayerVisibility: (visibility: LayerVisibility) => void;
|
||||||
flyTo: (center: [number, number], zoom?: number) => void;
|
flyTo: (center: [number, number], zoom?: number) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapViewerProps = {
|
type MapViewerProps = {
|
||||||
@@ -53,7 +86,10 @@ type MapViewerProps = {
|
|||||||
zoom?: number;
|
zoom?: number;
|
||||||
martinUrl?: string;
|
martinUrl?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
basemap?: BasemapId;
|
||||||
|
selectionMode?: boolean;
|
||||||
onFeatureClick?: (feature: ClickedFeature) => void;
|
onFeatureClick?: (feature: ClickedFeature) => void;
|
||||||
|
onSelectionChange?: (features: SelectedFeature[]) => void;
|
||||||
/** External layer visibility control */
|
/** External layer visibility control */
|
||||||
layerVisibility?: LayerVisibility;
|
layerVisibility?: LayerVisibility;
|
||||||
};
|
};
|
||||||
@@ -86,7 +122,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
zoom,
|
zoom,
|
||||||
martinUrl,
|
martinUrl,
|
||||||
className,
|
className,
|
||||||
|
basemap = "osm",
|
||||||
|
selectionMode = false,
|
||||||
onFeatureClick,
|
onFeatureClick,
|
||||||
|
onSelectionChange,
|
||||||
layerVisibility,
|
layerVisibility,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -94,9 +133,49 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const popupRef = useRef<maplibregl.Popup | null>(null);
|
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
|
||||||
|
const selectionModeRef = useRef(selectionMode);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
const resolvedMartinUrl = martinUrl ?? DEFAULT_MARTIN_URL;
|
// Keep ref in sync
|
||||||
|
selectionModeRef.current = selectionMode;
|
||||||
|
|
||||||
|
// MapLibre web workers can't resolve relative URLs — need absolute
|
||||||
|
const resolvedMartinUrl = (() => {
|
||||||
|
const raw = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||||
|
if (raw.startsWith("http")) return raw;
|
||||||
|
if (typeof window !== "undefined") return `${window.location.origin}${raw}`;
|
||||||
|
return raw;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---- Selection helpers ---- */
|
||||||
|
const updateSelectionFilter = useCallback(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const ids = Array.from(selectedRef.current.keys());
|
||||||
|
// Use objectId matching for the selection highlight layer
|
||||||
|
const filter: maplibregl.FilterSpecification =
|
||||||
|
ids.length > 0
|
||||||
|
? ["in", ["to-string", ["get", "object_id"]], ["literal", ids]]
|
||||||
|
: ["==", "object_id", "__NONE__"];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (map.getLayer(LAYER_IDS.selectionFill)) {
|
||||||
|
map.setFilter(LAYER_IDS.selectionFill, filter);
|
||||||
|
}
|
||||||
|
if (map.getLayer(LAYER_IDS.selectionLine)) {
|
||||||
|
map.setFilter(LAYER_IDS.selectionLine, filter);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// layers might not exist yet
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
selectedRef.current.clear();
|
||||||
|
updateSelectionFilter();
|
||||||
|
onSelectionChange?.([]);
|
||||||
|
}, [updateSelectionFilter, onSelectionChange]);
|
||||||
|
|
||||||
/* ---- Imperative handle ---- */
|
/* ---- Imperative handle ---- */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -107,6 +186,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
flyTo: (c: [number, number], z?: number) => {
|
flyTo: (c: [number, number], z?: number) => {
|
||||||
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
|
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
|
||||||
},
|
},
|
||||||
|
clearSelection,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/* ---- Apply layer visibility ---- */
|
/* ---- Apply layer visibility ---- */
|
||||||
@@ -139,32 +219,69 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
}
|
}
|
||||||
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
||||||
|
|
||||||
|
/* ---- Basemap switching ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !mapReady) return;
|
||||||
|
|
||||||
|
const source = map.getSource("basemap") as maplibregl.RasterTileSource | undefined;
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const def = BASEMAP_TILES[basemap];
|
||||||
|
// Update tiles by re-adding the source
|
||||||
|
// MapLibre doesn't support changing tiles on existing source, so we rebuild
|
||||||
|
try {
|
||||||
|
// Remove all layers that depend on basemap source, then remove source
|
||||||
|
if (map.getLayer("basemap-tiles")) map.removeLayer("basemap-tiles");
|
||||||
|
map.removeSource("basemap");
|
||||||
|
|
||||||
|
map.addSource("basemap", {
|
||||||
|
type: "raster",
|
||||||
|
tiles: def.tiles,
|
||||||
|
tileSize: def.tileSize,
|
||||||
|
attribution: def.attribution,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-add basemap layer at bottom
|
||||||
|
const firstLayerId = map.getStyle().layers[0]?.id;
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "basemap-tiles",
|
||||||
|
type: "raster",
|
||||||
|
source: "basemap",
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
firstLayerId // insert before first existing layer
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Fallback: if anything fails, the map still works
|
||||||
|
}
|
||||||
|
}, [basemap, mapReady]);
|
||||||
|
|
||||||
/* ---- Map initialization ---- */
|
/* ---- Map initialization ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const initialBasemap = BASEMAP_TILES[basemap];
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
osm: {
|
basemap: {
|
||||||
type: "raster",
|
type: "raster",
|
||||||
tiles: [
|
tiles: initialBasemap.tiles,
|
||||||
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
tileSize: initialBasemap.tileSize,
|
||||||
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
attribution: initialBasemap.attribution,
|
||||||
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: "osm-tiles",
|
id: "basemap-tiles",
|
||||||
type: "raster",
|
type: "raster",
|
||||||
source: "osm",
|
source: "basemap",
|
||||||
minzoom: 0,
|
minzoom: 0,
|
||||||
maxzoom: 19,
|
maxzoom: 19,
|
||||||
},
|
},
|
||||||
@@ -180,13 +297,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
/* ---- Controls ---- */
|
/* ---- Controls ---- */
|
||||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||||
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
||||||
map.addControl(
|
|
||||||
new maplibregl.GeolocateControl({
|
|
||||||
positionOptions: { enableHighAccuracy: true },
|
|
||||||
trackUserLocation: false,
|
|
||||||
}),
|
|
||||||
"top-right"
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---- Add Martin sources + layers on load ---- */
|
/* ---- Add Martin sources + layers on load ---- */
|
||||||
map.on("load", () => {
|
map.on("load", () => {
|
||||||
@@ -254,7 +364,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
minzoom: 13,
|
minzoom: 13,
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": "#22c55e",
|
"fill-color": "#22c55e",
|
||||||
"fill-opacity": 0.4,
|
"fill-opacity": 0.15,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,7 +375,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
"source-layer": SOURCES.terenuri,
|
"source-layer": SOURCES.terenuri,
|
||||||
minzoom: 13,
|
minzoom: 13,
|
||||||
paint: {
|
paint: {
|
||||||
"line-color": "#1a1a1a",
|
"line-color": "#15803d",
|
||||||
"line-width": 0.8,
|
"line-width": 0.8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -302,6 +412,34 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Selection highlight layer (uses same sources) ---
|
||||||
|
// We add a highlight layer on top for terenuri (primary selection target)
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.selectionFill,
|
||||||
|
type: "fill",
|
||||||
|
source: SOURCES.terenuri,
|
||||||
|
"source-layer": SOURCES.terenuri,
|
||||||
|
minzoom: 13,
|
||||||
|
filter: ["==", "object_id", "__NONE__"], // empty initially
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#f59e0b",
|
||||||
|
"fill-opacity": 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.selectionLine,
|
||||||
|
type: "line",
|
||||||
|
source: SOURCES.terenuri,
|
||||||
|
"source-layer": SOURCES.terenuri,
|
||||||
|
minzoom: 13,
|
||||||
|
filter: ["==", "object_id", "__NONE__"],
|
||||||
|
paint: {
|
||||||
|
"line-color": "#d97706",
|
||||||
|
"line-width": 2.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Apply initial visibility if provided
|
// Apply initial visibility if provided
|
||||||
if (layerVisibility) {
|
if (layerVisibility) {
|
||||||
applyLayerVisibility(layerVisibility);
|
applyLayerVisibility(layerVisibility);
|
||||||
@@ -319,7 +457,13 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
|
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
const features = map.queryRenderedFeatures(e.point, {
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
layers: clickableLayers,
|
layers: clickableLayers.filter((l) => {
|
||||||
|
try {
|
||||||
|
return !!map.getLayer(l);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close existing popup
|
// Close existing popup
|
||||||
@@ -336,6 +480,25 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
const props = (first.properties ?? {}) as Record<string, unknown>;
|
const props = (first.properties ?? {}) as Record<string, unknown>;
|
||||||
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
||||||
|
|
||||||
|
// Selection mode: toggle feature in selection
|
||||||
|
if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) {
|
||||||
|
const objectId = String(props.object_id ?? props.objectId ?? "");
|
||||||
|
if (!objectId) return;
|
||||||
|
|
||||||
|
if (selectedRef.current.has(objectId)) {
|
||||||
|
selectedRef.current.delete(objectId);
|
||||||
|
} else {
|
||||||
|
selectedRef.current.set(objectId, {
|
||||||
|
id: objectId,
|
||||||
|
sourceLayer,
|
||||||
|
properties: props,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateSelectionFilter();
|
||||||
|
onSelectionChange?.(Array.from(selectedRef.current.values()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Notify parent
|
// Notify parent
|
||||||
if (onFeatureClick) {
|
if (onFeatureClick) {
|
||||||
onFeatureClick({
|
onFeatureClick({
|
||||||
@@ -346,7 +509,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show popup
|
// Show popup (only in non-selection mode)
|
||||||
const popup = new maplibregl.Popup({
|
const popup = new maplibregl.Popup({
|
||||||
maxWidth: "360px",
|
maxWidth: "360px",
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { Search, MapPin, LandPlot, Building2, X, Loader2 } from "lucide-react";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { SearchResult } from "../types";
|
||||||
|
|
||||||
|
type SearchBarProps = {
|
||||||
|
onResultSelect: (result: SearchResult) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchBar({ onResultSelect, className }: SearchBarProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
const doSearch = useCallback((q: string) => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
if (q.length < 2) {
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/geoportal/search?q=${encodeURIComponent(q)}&limit=15`)
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
|
||||||
|
.then((data: { results: SearchResult[] }) => {
|
||||||
|
setResults(data.results);
|
||||||
|
setOpen(data.results.length > 0);
|
||||||
|
setSelectedIdx(-1);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (result: SearchResult) => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery(result.label);
|
||||||
|
onResultSelect(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!open || results.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => Math.min(i + 1, results.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => Math.max(i - 1, 0));
|
||||||
|
} else if (e.key === "Enter" && selectedIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
const sel = results[selectedIdx];
|
||||||
|
if (sel) handleSelect(sel);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcon = (type: SearchResult["type"]) => {
|
||||||
|
switch (type) {
|
||||||
|
case "uat":
|
||||||
|
return <MapPin className="h-3.5 w-3.5 text-violet-500" />;
|
||||||
|
case "parcel":
|
||||||
|
return <LandPlot className="h-3.5 w-3.5 text-green-500" />;
|
||||||
|
case "building":
|
||||||
|
return <Building2 className="h-3.5 w-3.5 text-blue-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={cn("relative", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Cauta parcela, UAT, proprietar..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
doSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (results.length > 0) setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{query && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-0.5 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results dropdown */}
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<div className="absolute top-full mt-1 w-full bg-background border rounded-lg shadow-lg overflow-hidden z-50 max-h-80 overflow-y-auto">
|
||||||
|
{results.map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-start gap-2.5 px-3 py-2 text-left hover:bg-muted/50 transition-colors",
|
||||||
|
i === selectedIdx && "bg-muted"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(r)}
|
||||||
|
onMouseEnter={() => setSelectedIdx(i)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0">{typeIcon(r.type)}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium truncate">{r.label}</p>
|
||||||
|
{r.sublabel && (
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{r.sublabel}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Download, Trash2, MousePointerClick, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { SelectedFeature, ExportFormat } from "../types";
|
||||||
|
|
||||||
|
type SelectionToolbarProps = {
|
||||||
|
selectedFeatures: SelectedFeature[];
|
||||||
|
selectionMode: boolean;
|
||||||
|
onToggleSelectionMode: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXPORT_FORMATS: { id: ExportFormat; label: string; ext: string }[] = [
|
||||||
|
{ id: "geojson", label: "GeoJSON (.geojson)", ext: "geojson" },
|
||||||
|
{ id: "dxf", label: "AutoCAD DXF (.dxf)", ext: "dxf" },
|
||||||
|
{ id: "gpkg", label: "GeoPackage (.gpkg)", ext: "gpkg" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SelectionToolbar({
|
||||||
|
selectedFeatures,
|
||||||
|
selectionMode,
|
||||||
|
onToggleSelectionMode,
|
||||||
|
onClearSelection,
|
||||||
|
className,
|
||||||
|
}: SelectionToolbarProps) {
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async (format: ExportFormat) => {
|
||||||
|
if (selectedFeatures.length === 0) return;
|
||||||
|
setExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ids = selectedFeatures.map((f) => f.id);
|
||||||
|
const resp = await fetch("/api/geoportal/export", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids, format }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const errData = await resp.json().catch(() => null);
|
||||||
|
const msg = (errData && typeof errData === "object" && "error" in errData)
|
||||||
|
? String((errData as { error: string }).error)
|
||||||
|
: `Export esuat (${resp.status})`;
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download the blob
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const disposition = resp.headers.get("Content-Disposition");
|
||||||
|
let filename = `export.${format}`;
|
||||||
|
if (disposition) {
|
||||||
|
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/i);
|
||||||
|
if (match?.[1]) filename = decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
alert("Eroare la export");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg flex items-center gap-1.5 p-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Toggle selection mode */}
|
||||||
|
<Button
|
||||||
|
variant={selectionMode ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs gap-1"
|
||||||
|
onClick={onToggleSelectionMode}
|
||||||
|
title={selectionMode ? "Dezactiveaza selectia" : "Activeaza selectia"}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="h-3.5 w-3.5" />
|
||||||
|
Selectie
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{selectedFeatures.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||||
|
{selectedFeatures.length}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Export dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs gap-1"
|
||||||
|
disabled={exporting}
|
||||||
|
>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{EXPORT_FORMATS.map((fmt) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={fmt.id}
|
||||||
|
onClick={() => handleExport(fmt.id)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{fmt.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Clear selection */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
title="Sterge selectia"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,3 +41,80 @@ export type MapViewState = {
|
|||||||
center: [number, number];
|
center: [number, number];
|
||||||
zoom: number;
|
zoom: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Basemap */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type BasemapId = "osm" | "satellite" | "topo";
|
||||||
|
|
||||||
|
export type BasemapDef = {
|
||||||
|
id: BasemapId;
|
||||||
|
label: string;
|
||||||
|
tiles: string[];
|
||||||
|
attribution: string;
|
||||||
|
tileSize?: number;
|
||||||
|
maxzoom?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Selection */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type SelectedFeature = {
|
||||||
|
id: string; // objectId or composite key
|
||||||
|
sourceLayer: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Search */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type SearchResult = {
|
||||||
|
id: string;
|
||||||
|
type: "parcel" | "uat" | "building";
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
coordinates?: [number, number]; // lng, lat for flyTo
|
||||||
|
bbox?: [number, number, number, number];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Feature info (enrichment from ParcelSync) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type FeatureDetail = {
|
||||||
|
id: string;
|
||||||
|
layerId: string;
|
||||||
|
siruta: string;
|
||||||
|
objectId: number;
|
||||||
|
cadastralRef: string | null;
|
||||||
|
areaValue: number | null;
|
||||||
|
enrichment: FeatureEnrichmentData | null;
|
||||||
|
enrichedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureEnrichmentData = {
|
||||||
|
NR_CAD: string;
|
||||||
|
NR_CF: string;
|
||||||
|
NR_CF_VECHI: string;
|
||||||
|
NR_TOPO: string;
|
||||||
|
ADRESA: string;
|
||||||
|
PROPRIETARI: string;
|
||||||
|
PROPRIETARI_VECHI: string;
|
||||||
|
SUPRAFATA_2D: number | string;
|
||||||
|
SUPRAFATA_R: number | string;
|
||||||
|
SOLICITANT: string;
|
||||||
|
INTRAVILAN: string;
|
||||||
|
CATEGORIE_FOLOSINTA: string;
|
||||||
|
HAS_BUILDING: number;
|
||||||
|
BUILD_LEGAL: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Export */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type ExportFormat = "dxf" | "gpkg" | "geojson";
|
||||||
|
|||||||
Reference in New Issue
Block a user