feat(parcel-sync): fix click, color styling, UAT boundary cross-check

Click fix:
- Keep l-terenuri-fill visible but transparent (opacity 0) so it still
  catches click events for FeatureInfoPanel. Enrichment overlay renders
  underneath.

Color changes:
- No enrichment: amber/yellow fill (was light green)
- With enrichment: green fill
- Buildings: red fill = no legal docs, blue = legal, gray = unknown
- Parcel outline: red = building no legal, blue = building legal

Boundary cross-check (/api/geoportal/boundary-check?siruta=X):
- Finds "foreign" parcels: registered in other UATs but geometrically
  within this UAT boundary (orange dashed)
- Finds "edge" parcels: registered here but centroid outside boundary
  (purple dashed)
- Alert banner shows count, legend updated with mismatch indicators

Martin config: added gis_cladiri_status source with build_legal property.
Enrichment views: gis_cladiri_status now JOINs parent parcel's BUILD_LEGAL.

Requires: docker restart martin + POST /api/geoportal/setup-enrichment-views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 16:05:12 +02:00
parent 2848868263
commit ba71ca3ef5
4 changed files with 450 additions and 92 deletions
+18
View File
@@ -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:
@@ -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 });
}
}
@@ -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%')`,
},
@@ -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<string, unknown>): void;
addLayer(layer: Record<string, unknown>, 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<string, unknown>,
@@ -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<LayerVisibility>({
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<string, unknown>,
});
// 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,6 +453,34 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
}
return (
<div className="space-y-2">
{/* Boundary mismatch alert */}
{mismatchSummary && (mismatchSummary.foreign > 0 || mismatchSummary.edge > 0) && (
<div className="flex items-center gap-2 rounded-lg border border-orange-200 bg-orange-50/50 dark:border-orange-800 dark:bg-orange-950/20 px-3 py-2 text-xs">
<AlertTriangle className="h-3.5 w-3.5 text-orange-500 shrink-0" />
<span>
<strong>Verificare limită UAT:</strong>{" "}
{mismatchSummary.foreign > 0 && (
<>
<Badge variant="outline" className="text-[10px] border-orange-300 text-orange-700 dark:text-orange-400 mx-0.5">
{mismatchSummary.foreign} parcele străine
</Badge>
(din alt UAT dar geometric în acest UAT)
</>
)}
{mismatchSummary.foreign > 0 && mismatchSummary.edge > 0 && " · "}
{mismatchSummary.edge > 0 && (
<>
<Badge variant="outline" className="text-[10px] border-purple-300 text-purple-700 dark:text-purple-400 mx-0.5">
{mismatchSummary.edge} parcele la limită
</Badge>
(înregistrate aici dar centroid în afara limitei)
</>
)}
</span>
</div>
)}
<div className="relative h-[600px] rounded-lg border overflow-hidden">
{boundsLoading && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-background/90 border px-3 py-1.5 text-xs shadow-sm">
@@ -348,11 +525,12 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
{/* Bottom-right: legend */}
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
<div className="font-semibold text-[11px] mb-1">Parcele</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm border"
style={{
backgroundColor: "rgba(134,239,172,0.25)",
backgroundColor: "rgba(251,191,36,0.3)",
borderColor: "#15803d",
}}
/>
@@ -362,7 +540,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
<span
className="inline-block h-3 w-3 rounded-sm border"
style={{
backgroundColor: "rgba(21,128,61,0.25)",
backgroundColor: "rgba(34,197,94,0.3)",
borderColor: "#15803d",
}}
/>
@@ -373,7 +551,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
className="inline-block h-3 w-3 rounded-sm"
style={{ border: "2px solid #3b82f6" }}
/>
Cu clădire
Cu clădire (legal)
</div>
<div className="flex items-center gap-1.5">
<span
@@ -382,6 +560,47 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
/>
Clădire fără acte
</div>
<div className="font-semibold text-[11px] mt-1.5 mb-1">Clădiri</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm"
style={{ backgroundColor: "rgba(59,130,246,0.5)" }}
/>
Legală
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm"
style={{ backgroundColor: "rgba(239,68,68,0.5)" }}
/>
Fără acte
</div>
{mismatchSummary && (mismatchSummary.foreign > 0 || mismatchSummary.edge > 0) && (
<>
<div className="font-semibold text-[11px] mt-1.5 mb-1">Limită UAT</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm"
style={{
backgroundColor: "rgba(249,115,22,0.35)",
border: "2px dashed #ea580c",
}}
/>
Parcele străine
</div>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm"
style={{
backgroundColor: "rgba(168,85,247,0.35)",
border: "2px dashed #9333ea",
}}
/>
La limită
</div>
</>
)}
</div>
</div>
</div>
);