diff --git a/martin.yaml b/martin.yaml index af9c505..86191fa 100644 --- a/martin.yaml +++ b/martin.yaml @@ -112,6 +112,24 @@ postgres: has_building: int4 build_legal: int4 + # ── Cladiri cu status legal (ParcelSync Harta tab) ── + + gis_cladiri_status: + schema: public + table: gis_cladiri_status + geometry_column: geom + srid: 3844 + bounds: [20.2, 43.5, 30.0, 48.3] + minzoom: 12 + maxzoom: 18 + properties: + object_id: text + siruta: text + cadastral_ref: text + area_value: float8 + layer_id: text + build_legal: int4 + # ── Cladiri (buildings) — NO simplification ── gis_cladiri: diff --git a/src/app/api/geoportal/boundary-check/route.ts b/src/app/api/geoportal/boundary-check/route.ts new file mode 100644 index 0000000..5bed97b --- /dev/null +++ b/src/app/api/geoportal/boundary-check/route.ts @@ -0,0 +1,115 @@ +/** + * GET /api/geoportal/boundary-check?siruta=57582 + * + * Spatial cross-check: finds parcels that geometrically fall within the + * given UAT boundary but are registered under a DIFFERENT siruta. + * + * Also detects the reverse: parcels registered in this UAT whose centroid + * falls outside its boundary (edge parcels). + * + * Returns GeoJSON FeatureCollection in EPSG:4326 (WGS84) for direct + * map overlay. + */ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type RawRow = { + id: string; + siruta: string; + object_id: number; + cadastral_ref: string | null; + area_value: number | null; + layer_id: string; + mismatch_type: string; + geojson: string; +}; + +export async function GET(req: NextRequest) { + const siruta = req.nextUrl.searchParams.get("siruta"); + if (!siruta) { + return NextResponse.json({ error: "siruta required" }, { status: 400 }); + } + + try { + // 1. Foreign parcels: registered in OTHER UATs but geometrically overlap this UAT + const foreign = await prisma.$queryRaw` + SELECT + f.id, + f.siruta, + f."objectId" AS object_id, + f."cadastralRef" AS cadastral_ref, + f."areaValue" AS area_value, + f."layerId" AS layer_id, + 'foreign' AS mismatch_type, + ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson + FROM "GisFeature" f + JOIN "GisUat" u ON u.siruta = ${siruta} + WHERE f.siruta != ${siruta} + AND ST_Intersects(f.geom, u.geom) + AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%') + AND f.geom IS NOT NULL + LIMIT 500 + ` as RawRow[]; + + // 2. Edge parcels: registered in this UAT but centroid falls outside boundary + const edge = await prisma.$queryRaw` + SELECT + f.id, + f.siruta, + f."objectId" AS object_id, + f."cadastralRef" AS cadastral_ref, + f."areaValue" AS area_value, + f."layerId" AS layer_id, + 'edge' AS mismatch_type, + ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson + FROM "GisFeature" f + JOIN "GisUat" u ON u.siruta = f.siruta AND u.siruta = ${siruta} + WHERE NOT ST_Contains(u.geom, ST_Centroid(f.geom)) + AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%') + AND f.geom IS NOT NULL + LIMIT 500 + ` as RawRow[]; + + const allRows = [...foreign, ...edge]; + + // Build GeoJSON FeatureCollection + const features = allRows + .map((row) => { + try { + const geometry = JSON.parse(row.geojson) as GeoJSON.Geometry; + return { + type: "Feature" as const, + geometry, + properties: { + id: row.id, + siruta: row.siruta, + object_id: row.object_id, + cadastral_ref: row.cadastral_ref, + area_value: row.area_value, + layer_id: row.layer_id, + mismatch_type: row.mismatch_type, + }, + }; + } catch { + return null; + } + }) + .filter(Boolean); + + return NextResponse.json({ + type: "FeatureCollection", + features, + summary: { + foreign: foreign.length, + edge: edge.length, + total: allRows.length, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/geoportal/setup-enrichment-views/route.ts b/src/app/api/geoportal/setup-enrichment-views/route.ts index 971e906..5c4ebd5 100644 --- a/src/app/api/geoportal/setup-enrichment-views/route.ts +++ b/src/app/api/geoportal/setup-enrichment-views/route.ts @@ -47,8 +47,14 @@ const VIEWS = [ f."cadastralRef" AS cadastral_ref, f."areaValue" AS area_value, f."isActive" AS is_active, + COALESCE((p.enrichment->>'BUILD_LEGAL')::int, -1) AS build_legal, f.geom FROM "GisFeature" f + LEFT JOIN "GisFeature" p + ON p.siruta = f.siruta + AND p."cadastralRef" = f."cadastralRef" + AND (p."layerId" LIKE 'TERENURI%' OR p."layerId" LIKE 'CADGEN_LAND%') + AND p.enrichment IS NOT NULL WHERE f.geom IS NOT NULL AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`, }, diff --git a/src/modules/parcel-sync/components/tabs/map-tab.tsx b/src/modules/parcel-sync/components/tabs/map-tab.tsx index 5c20c8d..4533a67 100644 --- a/src/modules/parcel-sync/components/tabs/map-tab.tsx +++ b/src/modules/parcel-sync/components/tabs/map-tab.tsx @@ -2,8 +2,9 @@ import { useState, useRef, useCallback, useEffect } from "react"; import dynamic from "next/dynamic"; -import { Map as MapIcon, Loader2 } from "lucide-react"; +import { Map as MapIcon, Loader2, AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@/shared/components/ui/card"; +import { Badge } from "@/shared/components/ui/badge"; import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher"; import { SelectionToolbar, @@ -56,7 +57,7 @@ type MapTabProps = { }; /* ------------------------------------------------------------------ */ -/* Typed map handle (avoids importing maplibregl types) */ +/* Typed map handle */ /* ------------------------------------------------------------------ */ type MapLike = { @@ -64,10 +65,9 @@ type MapLike = { getSource(id: string): unknown; addSource(id: string, source: Record): void; addLayer(layer: Record, before?: string): void; - removeLayer(id: string): void; - removeSource(id: string): void; setFilter(id: string, filter: unknown[] | null): void; setLayoutProperty(id: string, prop: string, value: unknown): void; + setPaintProperty(id: string, prop: string, value: unknown): void; fitBounds( bounds: [number, number, number, number], opts?: Record, @@ -98,7 +98,13 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { const appliedSirutaRef = useRef(""); const boundsRef = useRef<[number, number, number, number] | null>(null); - /* Layer visibility: show terenuri + cladiri, hide admin + UATs */ + /* Boundary check results */ + const [mismatchSummary, setMismatchSummary] = useState<{ + foreign: number; + edge: number; + } | null>(null); + + /* Layer visibility: show terenuri + cladiri, hide admin */ const [layerVisibility] = useState({ terenuri: true, cladiri: true, @@ -133,8 +139,6 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { if (data?.bounds) { const [[minLng, minLat], [maxLng, maxLat]] = data.bounds; boundsRef.current = [minLng, minLat, maxLng, maxLat]; - - // If map already ready, fitBounds immediately const map = asMap(mapHandleRef.current); if (map) { map.fitBounds([minLng, minLat, maxLng, maxLat], { @@ -177,10 +181,10 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { } } - // Hide base terenuri fill — we'll add enrichment overlay instead + // Keep l-terenuri-fill VISIBLE but fully transparent (catches clicks!) try { if (map.getLayer("l-terenuri-fill")) - map.setLayoutProperty("l-terenuri-fill", "visibility", "none"); + map.setPaintProperty("l-terenuri-fill", "fill-opacity", 0); } catch { /* noop */ } @@ -190,7 +194,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { ? `${window.location.origin}/tiles` : "/tiles"; - // Add enrichment source + layers (or update filter if already added) + // ── Enrichment overlay for PARCELS ── if (!map.getSource("gis_terenuri_status")) { map.addSource("gis_terenuri_status", { type: "vector", @@ -199,7 +203,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { maxzoom: 18, }); - // Data-driven fill: color by enrichment status + // Data-driven fill: yellowish = no enrichment, dark green = enriched map.addLayer( { id: "l-ps-terenuri-fill", @@ -212,16 +216,16 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { "fill-color": [ "case", ["==", ["get", "has_enrichment"], 1], - "#15803d", // dark green: enriched - "#86efac", // light green: no enrichment + "#22c55e", // green: enriched + "#fbbf24", // amber/yellow: no enrichment ], - "fill-opacity": 0.25, + "fill-opacity": 0.3, }, }, - "l-terenuri-line", // insert before outline + "l-terenuri-fill", // below the transparent click-catcher ); - // Data-driven outline: color by building status + // Data-driven outline: red = building no legal, blue = building legal, green = default map.addLayer( { id: "l-ps-terenuri-line", @@ -233,31 +237,27 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { paint: { "line-color": [ "case", - // Has building without legal docs: red [ "all", ["==", ["get", "has_building"], 1], ["==", ["get", "build_legal"], 0], ], - "#ef4444", - // Has building with legal: blue + "#ef4444", // red: building without legal ["==", ["get", "has_building"], 1], - "#3b82f6", - // Default: green - "#15803d", + "#3b82f6", // blue: building with legal + "#15803d", // green: no building ], "line-width": [ "case", ["==", ["get", "has_building"], 1], - 1.8, + 2, 0.8, ], }, }, - "l-cladiri-fill", + "l-terenuri-fill", ); } else { - // Source already exists — update filters for new siruta try { if (map.getLayer("l-ps-terenuri-fill")) map.setFilter("l-ps-terenuri-fill", filter); @@ -267,6 +267,155 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { /* noop */ } } + + // ── Enrichment overlay for BUILDINGS ── + if (!map.getSource("gis_cladiri_status")) { + map.addSource("gis_cladiri_status", { + type: "vector", + tiles: [`${martinBase}/gis_cladiri_status/{z}/{x}/{y}`], + minzoom: 12, + maxzoom: 18, + }); + + // Hide base cladiri layers + try { + if (map.getLayer("l-cladiri-fill")) + map.setPaintProperty("l-cladiri-fill", "fill-opacity", 0); + } catch { + /* noop */ + } + + // Buildings: red fill = no legal, blue fill = legal, gray = unknown + map.addLayer( + { + id: "l-ps-cladiri-fill", + type: "fill", + source: "gis_cladiri_status", + "source-layer": "gis_cladiri_status", + minzoom: 14, + filter, + paint: { + "fill-color": [ + "case", + ["==", ["get", "build_legal"], 0], + "#ef4444", // red: no legal + ["==", ["get", "build_legal"], 1], + "#3b82f6", // blue: legal + "#6b7280", // gray: unknown (-1) + ], + "fill-opacity": 0.5, + }, + }, + "l-cladiri-line", + ); + + map.addLayer( + { + id: "l-ps-cladiri-line", + type: "line", + source: "gis_cladiri_status", + "source-layer": "gis_cladiri_status", + minzoom: 14, + filter, + paint: { + "line-color": [ + "case", + ["==", ["get", "build_legal"], 0], + "#dc2626", // dark red + "#1e3a5f", // dark blue + ], + "line-width": 0.8, + }, + }, + "l-cladiri-line", + ); + } else { + try { + if (map.getLayer("l-ps-cladiri-fill")) + map.setFilter("l-ps-cladiri-fill", filter); + if (map.getLayer("l-ps-cladiri-line")) + map.setFilter("l-ps-cladiri-line", filter); + } catch { + /* noop */ + } + } + }, [mapReady, siruta, sirutaValid]); + + /* ── Boundary cross-check: load mismatched parcels ─────────── */ + const prevCheckSirutaRef = useRef(""); + useEffect(() => { + if (!mapReady || !sirutaValid || !siruta) return; + if (prevCheckSirutaRef.current === siruta) return; + prevCheckSirutaRef.current = siruta; + + fetch(`/api/geoportal/boundary-check?siruta=${siruta}`) + .then((r) => (r.ok ? r.json() : null)) + .then( + ( + data: { + type?: string; + features?: GeoJSON.Feature[]; + summary?: { foreign: number; edge: number }; + } | null, + ) => { + if (!data || !data.features || data.features.length === 0) { + setMismatchSummary(null); + return; + } + + setMismatchSummary(data.summary ?? null); + + const map = asMap(mapHandleRef.current); + if (!map) return; + + // Add or update GeoJSON source for mismatched parcels + if (map.getSource("boundary-mismatch")) { + // Update data + const src = map.getSource("boundary-mismatch") as unknown as { + setData(d: unknown): void; + }; + src.setData(data); + } else { + map.addSource("boundary-mismatch", { + type: "geojson", + data: data as unknown as Record, + }); + + // Orange dashed fill for mismatched parcels + map.addLayer({ + id: "l-mismatch-fill", + type: "fill", + source: "boundary-mismatch", + paint: { + "fill-color": [ + "case", + ["==", ["get", "mismatch_type"], "foreign"], + "#f97316", // orange: foreign parcel in this UAT + "#a855f7", // purple: edge parcel (registered here but centroid outside) + ], + "fill-opacity": 0.35, + }, + }); + + map.addLayer({ + id: "l-mismatch-line", + type: "line", + source: "boundary-mismatch", + paint: { + "line-color": [ + "case", + ["==", ["get", "mismatch_type"], "foreign"], + "#ea580c", + "#9333ea", + ], + "line-width": 2.5, + "line-dasharray": [4, 2], + }, + }); + } + }, + ) + .catch(() => {}); }, [mapReady, siruta, sirutaValid]); /* ── Feature click handler ─────────────────────────────────── */ @@ -304,83 +453,153 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) { } return ( -
- {boundsLoading && ( -
- - Se încarcă zona UAT... +
+ {/* Boundary mismatch alert */} + {mismatchSummary && (mismatchSummary.foreign > 0 || mismatchSummary.edge > 0) && ( +
+ + + Verificare limită UAT:{" "} + {mismatchSummary.foreign > 0 && ( + <> + + {mismatchSummary.foreign} parcele străine + + (din alt UAT dar geometric în acest UAT) + + )} + {mismatchSummary.foreign > 0 && mismatchSummary.edge > 0 && " · "} + {mismatchSummary.edge > 0 && ( + <> + + {mismatchSummary.edge} parcele la limită + + (înregistrate aici dar centroid în afara limitei) + + )} +
)} - - - {/* Top-right: basemap switcher + feature panel */} -
- - {clickedFeature && selectionMode === "off" && ( - setClickedFeature(null)} - /> +
+ {boundsLoading && ( +
+ + Se încarcă zona UAT... +
)} -
- {/* Bottom-left: selection toolbar */} -
- { - mapHandleRef.current?.clearSelection(); - setSelectedFeatures([]); - }} + -
- {/* Bottom-right: legend */} -
-
- + + {clickedFeature && selectionMode === "off" && ( + setClickedFeature(null)} + /> + )} +
+ + {/* Bottom-left: selection toolbar */} +
+ { + mapHandleRef.current?.clearSelection(); + setSelectedFeatures([]); }} /> - Fără enrichment
-
- - Cu enrichment -
-
- - Cu clădire -
-
- - Clădire fără acte + + {/* Bottom-right: legend */} +
+
Parcele
+
+ + Fără enrichment +
+
+ + Cu enrichment +
+
+ + Cu clădire (legal) +
+
+ + Clădire fără acte +
+
Clădiri
+
+ + Legală +
+
+ + Fără acte +
+ {mismatchSummary && (mismatchSummary.foreign > 0 || mismatchSummary.edge > 0) && ( + <> +
Limită UAT
+
+ + Parcele străine +
+
+ + La limită +
+ + )}