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:
+18
@@ -112,6 +112,24 @@ postgres:
|
|||||||
has_building: int4
|
has_building: int4
|
||||||
build_legal: 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 ──
|
# ── Cladiri (buildings) — NO simplification ──
|
||||||
|
|
||||||
gis_cladiri:
|
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."cadastralRef" AS cadastral_ref,
|
||||||
f."areaValue" AS area_value,
|
f."areaValue" AS area_value,
|
||||||
f."isActive" AS is_active,
|
f."isActive" AS is_active,
|
||||||
|
COALESCE((p.enrichment->>'BUILD_LEGAL')::int, -1) AS build_legal,
|
||||||
f.geom
|
f.geom
|
||||||
FROM "GisFeature" f
|
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
|
WHERE f.geom IS NOT NULL
|
||||||
AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`,
|
AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
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 { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher";
|
import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher";
|
||||||
import {
|
import {
|
||||||
SelectionToolbar,
|
SelectionToolbar,
|
||||||
@@ -56,7 +57,7 @@ type MapTabProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Typed map handle (avoids importing maplibregl types) */
|
/* Typed map handle */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
type MapLike = {
|
type MapLike = {
|
||||||
@@ -64,10 +65,9 @@ type MapLike = {
|
|||||||
getSource(id: string): unknown;
|
getSource(id: string): unknown;
|
||||||
addSource(id: string, source: Record<string, unknown>): void;
|
addSource(id: string, source: Record<string, unknown>): void;
|
||||||
addLayer(layer: Record<string, unknown>, before?: string): void;
|
addLayer(layer: Record<string, unknown>, before?: string): void;
|
||||||
removeLayer(id: string): void;
|
|
||||||
removeSource(id: string): void;
|
|
||||||
setFilter(id: string, filter: unknown[] | null): void;
|
setFilter(id: string, filter: unknown[] | null): void;
|
||||||
setLayoutProperty(id: string, prop: string, value: unknown): void;
|
setLayoutProperty(id: string, prop: string, value: unknown): void;
|
||||||
|
setPaintProperty(id: string, prop: string, value: unknown): void;
|
||||||
fitBounds(
|
fitBounds(
|
||||||
bounds: [number, number, number, number],
|
bounds: [number, number, number, number],
|
||||||
opts?: Record<string, unknown>,
|
opts?: Record<string, unknown>,
|
||||||
@@ -98,7 +98,13 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
const appliedSirutaRef = useRef("");
|
const appliedSirutaRef = useRef("");
|
||||||
const boundsRef = useRef<[number, number, number, number] | null>(null);
|
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>({
|
const [layerVisibility] = useState<LayerVisibility>({
|
||||||
terenuri: true,
|
terenuri: true,
|
||||||
cladiri: true,
|
cladiri: true,
|
||||||
@@ -133,8 +139,6 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
if (data?.bounds) {
|
if (data?.bounds) {
|
||||||
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
|
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
|
||||||
boundsRef.current = [minLng, minLat, maxLng, maxLat];
|
boundsRef.current = [minLng, minLat, maxLng, maxLat];
|
||||||
|
|
||||||
// If map already ready, fitBounds immediately
|
|
||||||
const map = asMap(mapHandleRef.current);
|
const map = asMap(mapHandleRef.current);
|
||||||
if (map) {
|
if (map) {
|
||||||
map.fitBounds([minLng, minLat, maxLng, maxLat], {
|
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 {
|
try {
|
||||||
if (map.getLayer("l-terenuri-fill"))
|
if (map.getLayer("l-terenuri-fill"))
|
||||||
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
|
map.setPaintProperty("l-terenuri-fill", "fill-opacity", 0);
|
||||||
} catch {
|
} catch {
|
||||||
/* noop */
|
/* noop */
|
||||||
}
|
}
|
||||||
@@ -190,7 +194,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
? `${window.location.origin}/tiles`
|
? `${window.location.origin}/tiles`
|
||||||
: "/tiles";
|
: "/tiles";
|
||||||
|
|
||||||
// Add enrichment source + layers (or update filter if already added)
|
// ── Enrichment overlay for PARCELS ──
|
||||||
if (!map.getSource("gis_terenuri_status")) {
|
if (!map.getSource("gis_terenuri_status")) {
|
||||||
map.addSource("gis_terenuri_status", {
|
map.addSource("gis_terenuri_status", {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
@@ -199,7 +203,7 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
maxzoom: 18,
|
maxzoom: 18,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Data-driven fill: color by enrichment status
|
// Data-driven fill: yellowish = no enrichment, dark green = enriched
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "l-ps-terenuri-fill",
|
id: "l-ps-terenuri-fill",
|
||||||
@@ -212,16 +216,16 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
"fill-color": [
|
"fill-color": [
|
||||||
"case",
|
"case",
|
||||||
["==", ["get", "has_enrichment"], 1],
|
["==", ["get", "has_enrichment"], 1],
|
||||||
"#15803d", // dark green: enriched
|
"#22c55e", // green: enriched
|
||||||
"#86efac", // light green: no enrichment
|
"#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(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "l-ps-terenuri-line",
|
id: "l-ps-terenuri-line",
|
||||||
@@ -233,31 +237,27 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
paint: {
|
paint: {
|
||||||
"line-color": [
|
"line-color": [
|
||||||
"case",
|
"case",
|
||||||
// Has building without legal docs: red
|
|
||||||
[
|
[
|
||||||
"all",
|
"all",
|
||||||
["==", ["get", "has_building"], 1],
|
["==", ["get", "has_building"], 1],
|
||||||
["==", ["get", "build_legal"], 0],
|
["==", ["get", "build_legal"], 0],
|
||||||
],
|
],
|
||||||
"#ef4444",
|
"#ef4444", // red: building without legal
|
||||||
// Has building with legal: blue
|
|
||||||
["==", ["get", "has_building"], 1],
|
["==", ["get", "has_building"], 1],
|
||||||
"#3b82f6",
|
"#3b82f6", // blue: building with legal
|
||||||
// Default: green
|
"#15803d", // green: no building
|
||||||
"#15803d",
|
|
||||||
],
|
],
|
||||||
"line-width": [
|
"line-width": [
|
||||||
"case",
|
"case",
|
||||||
["==", ["get", "has_building"], 1],
|
["==", ["get", "has_building"], 1],
|
||||||
1.8,
|
2,
|
||||||
0.8,
|
0.8,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"l-cladiri-fill",
|
"l-terenuri-fill",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Source already exists — update filters for new siruta
|
|
||||||
try {
|
try {
|
||||||
if (map.getLayer("l-ps-terenuri-fill"))
|
if (map.getLayer("l-ps-terenuri-fill"))
|
||||||
map.setFilter("l-ps-terenuri-fill", filter);
|
map.setFilter("l-ps-terenuri-fill", filter);
|
||||||
@@ -267,6 +267,155 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
/* noop */
|
/* 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]);
|
}, [mapReady, siruta, sirutaValid]);
|
||||||
|
|
||||||
/* ── Feature click handler ─────────────────────────────────── */
|
/* ── Feature click handler ─────────────────────────────────── */
|
||||||
@@ -304,83 +453,153 @@ export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[600px] rounded-lg border overflow-hidden">
|
<div className="space-y-2">
|
||||||
{boundsLoading && (
|
{/* Boundary mismatch alert */}
|
||||||
<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">
|
{mismatchSummary && (mismatchSummary.foreign > 0 || mismatchSummary.edge > 0) && (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<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">
|
||||||
Se încarcă zona UAT...
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MapViewer
|
<div className="relative h-[600px] rounded-lg border overflow-hidden">
|
||||||
ref={mapHandleRef}
|
{boundsLoading && (
|
||||||
className="h-full w-full"
|
<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">
|
||||||
basemap={basemap}
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
selectionType={selectionMode}
|
Se încarcă zona UAT...
|
||||||
onFeatureClick={handleFeatureClick}
|
</div>
|
||||||
onSelectionChange={setSelectedFeatures}
|
|
||||||
layerVisibility={layerVisibility}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Top-right: basemap switcher + feature panel */}
|
|
||||||
<div className="absolute top-3 right-3 z-10 flex flex-col items-end gap-2">
|
|
||||||
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
|
||||||
{clickedFeature && selectionMode === "off" && (
|
|
||||||
<FeatureInfoPanel
|
|
||||||
feature={clickedFeature}
|
|
||||||
onClose={() => setClickedFeature(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom-left: selection toolbar */}
|
<MapViewer
|
||||||
<div className="absolute bottom-8 left-3 z-10">
|
ref={mapHandleRef}
|
||||||
<SelectionToolbar
|
className="h-full w-full"
|
||||||
selectedFeatures={selectedFeatures}
|
basemap={basemap}
|
||||||
selectionMode={selectionMode}
|
selectionType={selectionMode}
|
||||||
onSelectionModeChange={handleSelectionModeChange}
|
onFeatureClick={handleFeatureClick}
|
||||||
onClearSelection={() => {
|
onSelectionChange={setSelectedFeatures}
|
||||||
mapHandleRef.current?.clearSelection();
|
layerVisibility={layerVisibility}
|
||||||
setSelectedFeatures([]);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom-right: legend */}
|
{/* Top-right: basemap switcher + feature panel */}
|
||||||
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
|
<div className="absolute top-3 right-3 z-10 flex flex-col items-end gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
||||||
<span
|
{clickedFeature && selectionMode === "off" && (
|
||||||
className="inline-block h-3 w-3 rounded-sm border"
|
<FeatureInfoPanel
|
||||||
style={{
|
feature={clickedFeature}
|
||||||
backgroundColor: "rgba(134,239,172,0.25)",
|
onClose={() => setClickedFeature(null)}
|
||||||
borderColor: "#15803d",
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-left: selection toolbar */}
|
||||||
|
<div className="absolute bottom-8 left-3 z-10">
|
||||||
|
<SelectionToolbar
|
||||||
|
selectedFeatures={selectedFeatures}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
onSelectionModeChange={handleSelectionModeChange}
|
||||||
|
onClearSelection={() => {
|
||||||
|
mapHandleRef.current?.clearSelection();
|
||||||
|
setSelectedFeatures([]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
Fără enrichment
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
{/* Bottom-right: legend */}
|
||||||
className="inline-block h-3 w-3 rounded-sm border"
|
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
|
||||||
style={{
|
<div className="font-semibold text-[11px] mb-1">Parcele</div>
|
||||||
backgroundColor: "rgba(21,128,61,0.25)",
|
<div className="flex items-center gap-1.5">
|
||||||
borderColor: "#15803d",
|
<span
|
||||||
}}
|
className="inline-block h-3 w-3 rounded-sm border"
|
||||||
/>
|
style={{
|
||||||
Cu enrichment
|
backgroundColor: "rgba(251,191,36,0.3)",
|
||||||
</div>
|
borderColor: "#15803d",
|
||||||
<div className="flex items-center gap-1.5">
|
}}
|
||||||
<span
|
/>
|
||||||
className="inline-block h-3 w-3 rounded-sm"
|
Fără enrichment
|
||||||
style={{ border: "2px solid #3b82f6" }}
|
</div>
|
||||||
/>
|
<div className="flex items-center gap-1.5">
|
||||||
Cu clădire
|
<span
|
||||||
</div>
|
className="inline-block h-3 w-3 rounded-sm border"
|
||||||
<div className="flex items-center gap-1.5">
|
style={{
|
||||||
<span
|
backgroundColor: "rgba(34,197,94,0.3)",
|
||||||
className="inline-block h-3 w-3 rounded-sm"
|
borderColor: "#15803d",
|
||||||
style={{ border: "2px solid #ef4444" }}
|
}}
|
||||||
/>
|
/>
|
||||||
Clădire fără acte
|
Cu enrichment
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-sm"
|
||||||
|
style={{ border: "2px solid #3b82f6" }}
|
||||||
|
/>
|
||||||
|
Cu clădire (legal)
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-3 w-3 rounded-sm"
|
||||||
|
style={{ border: "2px solid #ef4444" }}
|
||||||
|
/>
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user