From 1b5876524abcad395ded6ff402f080fe439ba1ea Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 16:43:01 +0200 Subject: [PATCH] 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) --- next.config.ts | 9 + src/app/api/geoportal/export/route.ts | 202 ++++++++++++++ src/app/api/geoportal/feature/route.ts | 109 ++++++++ src/app/api/geoportal/search/route.ts | 152 +++++++++++ .../geoportal/components/basemap-switcher.tsx | 44 +++ .../components/feature-info-panel.tsx | 250 ++++++++++++++++++ .../geoportal/components/geoportal-module.tsx | 93 ++++++- .../geoportal/components/map-viewer.tsx | 217 +++++++++++++-- .../geoportal/components/search-bar.tsx | 156 +++++++++++ .../components/selection-toolbar.tsx | 151 +++++++++++ src/modules/geoportal/types.ts | 77 ++++++ 11 files changed, 1427 insertions(+), 33 deletions(-) create mode 100644 src/app/api/geoportal/export/route.ts create mode 100644 src/app/api/geoportal/feature/route.ts create mode 100644 src/app/api/geoportal/search/route.ts create mode 100644 src/modules/geoportal/components/basemap-switcher.tsx create mode 100644 src/modules/geoportal/components/feature-info-panel.tsx create mode 100644 src/modules/geoportal/components/search-bar.tsx create mode 100644 src/modules/geoportal/components/selection-toolbar.tsx diff --git a/next.config.ts b/next.config.ts index 2b84c5f..6f0f03d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,15 @@ const nextConfig: NextConfig = { experimental: { middlewareClientMaxBodySize: '500mb', }, + async rewrites() { + const martinUrl = process.env.MARTIN_URL || 'http://martin:3000'; + return [ + { + source: '/tiles/:path*', + destination: `${martinUrl}/:path*`, + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/api/geoportal/export/route.ts b/src/app/api/geoportal/export/route.ts new file mode 100644 index 0000000..f207e85 --- /dev/null +++ b/src/app/api/geoportal/export/route.ts @@ -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; + return { + type: "Feature" as const, + geometry: f.geometry as Record, + 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 + } +} diff --git a/src/app/api/geoportal/feature/route.ts b/src/app/api/geoportal/feature/route.ts new file mode 100644 index 0000000..da61d2f --- /dev/null +++ b/src/app/api/geoportal/feature/route.ts @@ -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 | null, + enrichedAt: feature.enrichedAt?.toISOString() ?? null, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/geoportal/search/route.ts b/src/app/api/geoportal/search/route.ts new file mode 100644 index 0000000..1b5513f --- /dev/null +++ b/src/app/api/geoportal/search/route.ts @@ -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 | 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 | 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 }); + } +} diff --git a/src/modules/geoportal/components/basemap-switcher.tsx b/src/modules/geoportal/components/basemap-switcher.tsx new file mode 100644 index 0000000..2a38c61 --- /dev/null +++ b/src/modules/geoportal/components/basemap-switcher.tsx @@ -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 ( +
+ {BASEMAPS.map((b) => { + const Icon = b.icon; + const active = value === b.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/modules/geoportal/components/feature-info-panel.tsx b/src/modules/geoportal/components/feature-info-panel.tsx new file mode 100644 index 0000000..20416ec --- /dev/null +++ b/src/modules/geoportal/components/feature-info-panel.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+

+ {enrichment?.NR_CAD + ? `Parcela ${enrichment.NR_CAD}` + : feature.sourceLayer === "gis_uats" + ? `UAT ${feature.properties.name ?? ""}` + : `Obiect #${feature.properties.object_id ?? feature.properties.objectId ?? "?"}`} +

+ +
+ + {/* Content */} +
+ {loading && ( +
+ + Se incarca... +
+ )} + + {error && ( +

Nu s-au putut incarca detaliile ({error})

+ )} + + {/* Basic props from vector tile */} + {!loading && !enrichment && ( + + )} + + {/* Enrichment data */} + {enrichment && } + + {/* Coordinates */} +
+ + {feature.coordinates[1].toFixed(5)}, {feature.coordinates[0].toFixed(5)} +
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Enrichment view */ +/* ------------------------------------------------------------------ */ + +function EnrichmentView({ data }: { data: FeatureEnrichmentData }) { + return ( +
+ {/* Cadastral info */} +
+ + + {data.NR_CF_VECHI && data.NR_CF_VECHI !== "-" && ( + + )} + {data.NR_TOPO && data.NR_TOPO !== "-" && ( + + )} +
+ + {/* Owners */} + {data.PROPRIETARI && data.PROPRIETARI !== "-" && ( +
+

{data.PROPRIETARI}

+ {data.PROPRIETARI_VECHI && data.PROPRIETARI_VECHI !== "-" && ( +

+ Anterior: {data.PROPRIETARI_VECHI} +

+ )} +
+ )} + + {/* Area */} +
+ + +
+ + {/* Land use */} +
+ + + {data.INTRAVILAN || "-"} + + } + /> +
+ + {/* Building */} + {data.HAS_BUILDING === 1 && ( +
+ +
+ )} + + {/* Address */} + {data.ADRESA && data.ADRESA !== "-" && ( +
+

{data.ADRESA}

+
+ )} +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function Section({ + icon: Icon, + label, + children, +}: { + icon: typeof FileText; + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ + + {label} + +
+
{children}
+
+ ); +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + if (!value || value === "-" || value === "") return null; + return ( +
+ {label} + {typeof value === "string" ? value : value} +
+ ); +} + +function PropsTable({ properties }: { properties: Record }) { + const entries = Object.entries(properties).filter( + ([, v]) => v != null && v !== "" + ); + if (entries.length === 0) return

Fara atribute

; + return ( +
+ {entries.map(([key, value]) => ( + + ))} +
+ ); +} + +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`; +} diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx index af07837..d5b501a 100644 --- a/src/modules/geoportal/components/geoportal-module.tsx +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -4,8 +4,18 @@ import { useState, useRef, useCallback } from "react"; import dynamic from "next/dynamic"; import { Globe } from "lucide-react"; 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 { ClickedFeature, LayerVisibility } from "../types"; +import type { + BasemapId, + ClickedFeature, + LayerVisibility, + SearchResult, + SelectedFeature, +} from "../types"; /* MapLibre uses WebGL — must disable SSR */ const MapViewer = dynamic( @@ -29,20 +39,60 @@ const MapViewer = dynamic( export function GeoportalModule() { const mapHandleRef = useRef(null); + + // Map state + const [basemap, setBasemap] = useState("osm"); const [layerVisibility, setLayerVisibility] = useState( getDefaultVisibility ); + // Feature info + const [clickedFeature, setClickedFeature] = useState(null); + + // Selection + const [selectionMode, setSelectionMode] = useState(false); + const [selectedFeatures, setSelectedFeatures] = useState([]); + + // Fly-to target (from search) + const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>(); + const handleFeatureClick = useCallback((feature: ClickedFeature) => { - // Feature click is handled by the MapViewer popup internally. - // This callback is available for future integration (e.g., detail panel). - void feature; + setClickedFeature(feature); }, []); const handleVisibilityChange = useCallback((vis: LayerVisibility) => { 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 (
{/* Header */} @@ -61,17 +111,48 @@ export function GeoportalModule() { - {/* Layer panel overlay */} -
+ {/* Top-left controls: search + layers */} +
+
+ + {/* Top-right: basemap switcher */} +
+ +
+ + {/* Bottom-left: selection toolbar */} +
+ +
+ + {/* Right side: feature info panel */} + {clickedFeature && !selectionMode && ( +
+ setClickedFeature(null)} + /> +
+ )}
); diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx index 401abc7..46573c6 100644 --- a/src/modules/geoportal/components/map-viewer.tsx +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -4,17 +4,17 @@ import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardR import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { cn } from "@/shared/lib/utils"; -import type { ClickedFeature, LayerVisibility } from "../types"; +import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types"; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ /** - * Martin tile URL — use relative /tiles path (proxied by Traefik). - * This works both in production (HTTPS) and avoids mixed-content issues. + * Martin tile URL — relative /tiles is proxied by Next.js rewrite (dev) + * 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 */ const DEFAULT_CENTER: [number, number] = [23.8, 46.1]; @@ -36,8 +36,40 @@ const LAYER_IDS = { terenuriLine: "layer-terenuri-line", cladiriFill: "layer-cladiri-fill", cladiriLine: "layer-cladiri-line", + selectionFill: "layer-selection-fill", + selectionLine: "layer-selection-line", } as const; +/** Basemap tile definitions */ +const BASEMAP_TILES: Record = { + 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: '© OpenStreetMap', + tileSize: 256, + }, + satellite: { + tiles: [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + ], + attribution: '© Esri, 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: + '© OpenTopoMap (CC-BY-SA)', + tileSize: 256, + }, +}; + /* ------------------------------------------------------------------ */ /* Props */ /* ------------------------------------------------------------------ */ @@ -46,6 +78,7 @@ export type MapViewerHandle = { getMap: () => maplibregl.Map | null; setLayerVisibility: (visibility: LayerVisibility) => void; flyTo: (center: [number, number], zoom?: number) => void; + clearSelection: () => void; }; type MapViewerProps = { @@ -53,7 +86,10 @@ type MapViewerProps = { zoom?: number; martinUrl?: string; className?: string; + basemap?: BasemapId; + selectionMode?: boolean; onFeatureClick?: (feature: ClickedFeature) => void; + onSelectionChange?: (features: SelectedFeature[]) => void; /** External layer visibility control */ layerVisibility?: LayerVisibility; }; @@ -86,7 +122,10 @@ export const MapViewer = forwardRef( zoom, martinUrl, className, + basemap = "osm", + selectionMode = false, onFeatureClick, + onSelectionChange, layerVisibility, }, ref @@ -94,9 +133,49 @@ export const MapViewer = forwardRef( const containerRef = useRef(null); const mapRef = useRef(null); const popupRef = useRef(null); + const selectedRef = useRef>(new Map()); + const selectionModeRef = useRef(selectionMode); 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 ---- */ useImperativeHandle(ref, () => ({ @@ -107,6 +186,7 @@ export const MapViewer = forwardRef( flyTo: (c: [number, number], z?: number) => { mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 }); }, + clearSelection, })); /* ---- Apply layer visibility ---- */ @@ -139,32 +219,69 @@ export const MapViewer = forwardRef( } }, [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 ---- */ useEffect(() => { if (!containerRef.current) return; + const initialBasemap = BASEMAP_TILES[basemap]; + const map = new maplibregl.Map({ container: containerRef.current, style: { version: 8, sources: { - osm: { + basemap: { type: "raster", - 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", - ], - tileSize: 256, - attribution: - '© OpenStreetMap', + tiles: initialBasemap.tiles, + tileSize: initialBasemap.tileSize, + attribution: initialBasemap.attribution, }, }, layers: [ { - id: "osm-tiles", + id: "basemap-tiles", type: "raster", - source: "osm", + source: "basemap", minzoom: 0, maxzoom: 19, }, @@ -180,13 +297,6 @@ export const MapViewer = forwardRef( /* ---- Controls ---- */ map.addControl(new maplibregl.NavigationControl(), "top-right"); 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 ---- */ map.on("load", () => { @@ -254,7 +364,7 @@ export const MapViewer = forwardRef( minzoom: 13, paint: { "fill-color": "#22c55e", - "fill-opacity": 0.4, + "fill-opacity": 0.15, }, }); @@ -265,7 +375,7 @@ export const MapViewer = forwardRef( "source-layer": SOURCES.terenuri, minzoom: 13, paint: { - "line-color": "#1a1a1a", + "line-color": "#15803d", "line-width": 0.8, }, }); @@ -302,6 +412,34 @@ export const MapViewer = forwardRef( }, }); + // --- 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 if (layerVisibility) { applyLayerVisibility(layerVisibility); @@ -319,7 +457,13 @@ export const MapViewer = forwardRef( map.on("click", (e) => { const features = map.queryRenderedFeatures(e.point, { - layers: clickableLayers, + layers: clickableLayers.filter((l) => { + try { + return !!map.getLayer(l); + } catch { + return false; + } + }), }); // Close existing popup @@ -336,6 +480,25 @@ export const MapViewer = forwardRef( const props = (first.properties ?? {}) as Record; 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 if (onFeatureClick) { onFeatureClick({ @@ -346,7 +509,7 @@ export const MapViewer = forwardRef( }); } - // Show popup + // Show popup (only in non-selection mode) const popup = new maplibregl.Popup({ maxWidth: "360px", closeButton: true, diff --git a/src/modules/geoportal/components/search-bar.tsx b/src/modules/geoportal/components/search-bar.tsx new file mode 100644 index 0000000..24e44db --- /dev/null +++ b/src/modules/geoportal/components/search-bar.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [selectedIdx, setSelectedIdx] = useState(-1); + const containerRef = useRef(null); + const debounceRef = useRef>(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 ; + case "parcel": + return ; + case "building": + return ; + } + }; + + return ( +
+
+ + { + 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 && ( + + )} + {query && ( + + )} +
+ + {/* Results dropdown */} + {open && results.length > 0 && ( +
+ {results.map((r, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/modules/geoportal/components/selection-toolbar.tsx b/src/modules/geoportal/components/selection-toolbar.tsx new file mode 100644 index 0000000..a66c0e9 --- /dev/null +++ b/src/modules/geoportal/components/selection-toolbar.tsx @@ -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 ( +
+ {/* Toggle selection mode */} + + + {selectedFeatures.length > 0 && ( + <> + + {selectedFeatures.length} + + + {/* Export dropdown */} + + + + + + {EXPORT_FORMATS.map((fmt) => ( + handleExport(fmt.id)} + className="text-xs" + > + {fmt.label} + + ))} + + + + {/* Clear selection */} + + + )} +
+ ); +} diff --git a/src/modules/geoportal/types.ts b/src/modules/geoportal/types.ts index 9b8575a..7754e3d 100644 --- a/src/modules/geoportal/types.ts +++ b/src/modules/geoportal/types.ts @@ -41,3 +41,80 @@ export type MapViewState = { center: [number, 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; +}; + +/* ------------------------------------------------------------------ */ +/* 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; +}; + +/* ------------------------------------------------------------------ */ +/* 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";