ab35fc4df7
Instead of trying to color buildings directly (which requires an unreliable cadastralRef join), the parcel itself gets a strong red fill (opacity 0.45) when has_building=1 AND build_legal=0. Buildings sitting on these parcels are visually on a red background. Color scheme: - Red fill: building without legal docs - Light blue fill: building with legal - Green fill: enriched, no building - Yellow/amber fill: no enrichment Removed broken gis_cladiri_status overlay. Simplified legend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import dynamic from "next/dynamic";
|
|
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,
|
|
type SelectionMode,
|
|
} from "@/modules/geoportal/components/selection-toolbar";
|
|
import { FeatureInfoPanel } from "@/modules/geoportal/components/feature-info-panel";
|
|
import type { MapViewerHandle } from "@/modules/geoportal/components/map-viewer";
|
|
import type {
|
|
BasemapId,
|
|
ClickedFeature,
|
|
LayerVisibility,
|
|
SelectedFeature,
|
|
} from "@/modules/geoportal/types";
|
|
|
|
/* MapLibre uses WebGL — must disable SSR */
|
|
const MapViewer = dynamic(
|
|
() =>
|
|
import("@/modules/geoportal/components/map-viewer").then((m) => ({
|
|
default: m.MapViewer,
|
|
})),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Layer IDs — must match map-viewer.tsx LAYER_IDS */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const BASE_LAYERS = [
|
|
"l-terenuri-fill",
|
|
"l-terenuri-line",
|
|
"l-terenuri-label",
|
|
"l-cladiri-fill",
|
|
"l-cladiri-line",
|
|
];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Props */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type MapTabProps = {
|
|
siruta: string;
|
|
sirutaValid: boolean;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Typed map handle */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type MapLike = {
|
|
getLayer(id: string): unknown;
|
|
getSource(id: string): unknown;
|
|
addSource(id: string, source: Record<string, unknown>): void;
|
|
addLayer(layer: Record<string, unknown>, before?: 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>,
|
|
): void;
|
|
isStyleLoaded(): boolean;
|
|
};
|
|
|
|
function asMap(handle: MapViewerHandle | null): MapLike | null {
|
|
const m = handle?.getMap();
|
|
return m ? (m as unknown as MapLike) : null;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function MapTab({ siruta, sirutaValid }: MapTabProps) {
|
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
|
const [clickedFeature, setClickedFeature] =
|
|
useState<ClickedFeature | null>(null);
|
|
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
|
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>(
|
|
[],
|
|
);
|
|
const [boundsLoading, setBoundsLoading] = useState(false);
|
|
const [mapReady, setMapReady] = useState(false);
|
|
const appliedSirutaRef = useRef("");
|
|
const boundsRef = useRef<[number, number, number, number] | null>(null);
|
|
|
|
/* 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,
|
|
administrativ: false,
|
|
});
|
|
|
|
/* ── Detect when map is ready ──────────────────────────────── */
|
|
useEffect(() => {
|
|
if (!sirutaValid) return;
|
|
const check = setInterval(() => {
|
|
const map = asMap(mapHandleRef.current);
|
|
if (map && map.isStyleLoaded()) {
|
|
setMapReady(true);
|
|
clearInterval(check);
|
|
}
|
|
}, 200);
|
|
return () => clearInterval(check);
|
|
}, [sirutaValid]);
|
|
|
|
/* ── Fetch UAT bounds ──────────────────────────────────────── */
|
|
const prevBoundsSirutaRef = useRef("");
|
|
useEffect(() => {
|
|
if (!sirutaValid || !siruta) return;
|
|
if (prevBoundsSirutaRef.current === siruta) return;
|
|
prevBoundsSirutaRef.current = siruta;
|
|
|
|
setBoundsLoading(true);
|
|
fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`)
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then(
|
|
(data: { bounds?: [[number, number], [number, number]] } | null) => {
|
|
if (data?.bounds) {
|
|
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
|
|
boundsRef.current = [minLng, minLat, maxLng, maxLat];
|
|
const map = asMap(mapHandleRef.current);
|
|
if (map) {
|
|
map.fitBounds([minLng, minLat, maxLng, maxLat], {
|
|
padding: 40,
|
|
duration: 1500,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
)
|
|
.catch(() => {})
|
|
.finally(() => setBoundsLoading(false));
|
|
}, [siruta, sirutaValid]);
|
|
|
|
/* ── When map becomes ready, fitBounds if we have bounds ───── */
|
|
useEffect(() => {
|
|
if (!mapReady || !boundsRef.current) return;
|
|
const map = asMap(mapHandleRef.current);
|
|
if (!map) return;
|
|
map.fitBounds(boundsRef.current, { padding: 40, duration: 1500 });
|
|
}, [mapReady]);
|
|
|
|
/* ── Apply siruta filter + enrichment overlay ──────────────── */
|
|
useEffect(() => {
|
|
if (!mapReady || !sirutaValid || !siruta) return;
|
|
if (appliedSirutaRef.current === siruta) return;
|
|
|
|
const map = asMap(mapHandleRef.current);
|
|
if (!map) return;
|
|
|
|
appliedSirutaRef.current = siruta;
|
|
const filter = ["==", ["get", "siruta"], siruta];
|
|
|
|
// Filter base layers by siruta
|
|
for (const layerId of BASE_LAYERS) {
|
|
try {
|
|
if (map.getLayer(layerId)) map.setFilter(layerId, filter);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
// Keep l-terenuri-fill VISIBLE but fully transparent (catches clicks!)
|
|
try {
|
|
if (map.getLayer("l-terenuri-fill"))
|
|
map.setPaintProperty("l-terenuri-fill", "fill-opacity", 0);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
|
|
const martinBase =
|
|
typeof window !== "undefined"
|
|
? `${window.location.origin}/tiles`
|
|
: "/tiles";
|
|
|
|
// ── Enrichment overlay for PARCELS ──
|
|
if (!map.getSource("gis_terenuri_status")) {
|
|
map.addSource("gis_terenuri_status", {
|
|
type: "vector",
|
|
tiles: [`${martinBase}/gis_terenuri_status/{z}/{x}/{y}`],
|
|
minzoom: 10,
|
|
maxzoom: 18,
|
|
});
|
|
|
|
// Data-driven fill: red = building no legal, green = enriched, yellow = no enrichment
|
|
map.addLayer(
|
|
{
|
|
id: "l-ps-terenuri-fill",
|
|
type: "fill",
|
|
source: "gis_terenuri_status",
|
|
"source-layer": "gis_terenuri_status",
|
|
minzoom: 13,
|
|
filter,
|
|
paint: {
|
|
"fill-color": [
|
|
"case",
|
|
// Building without legal docs: RED fill (covers building too)
|
|
[
|
|
"all",
|
|
["==", ["get", "has_building"], 1],
|
|
["==", ["get", "build_legal"], 0],
|
|
],
|
|
"#ef4444",
|
|
// Building with legal: light blue
|
|
["==", ["get", "has_building"], 1],
|
|
"#93c5fd",
|
|
// Enriched, no building: green
|
|
["==", ["get", "has_enrichment"], 1],
|
|
"#22c55e",
|
|
// No enrichment: amber/yellow
|
|
"#fbbf24",
|
|
],
|
|
"fill-opacity": [
|
|
"case",
|
|
// Stronger opacity for building issues so it shows through
|
|
[
|
|
"all",
|
|
["==", ["get", "has_building"], 1],
|
|
["==", ["get", "build_legal"], 0],
|
|
],
|
|
0.45,
|
|
0.25,
|
|
],
|
|
},
|
|
},
|
|
"l-terenuri-fill", // below the transparent click-catcher
|
|
);
|
|
|
|
// Data-driven outline: red = building no legal, blue = building legal, green = default
|
|
map.addLayer(
|
|
{
|
|
id: "l-ps-terenuri-line",
|
|
type: "line",
|
|
source: "gis_terenuri_status",
|
|
"source-layer": "gis_terenuri_status",
|
|
minzoom: 13,
|
|
filter,
|
|
paint: {
|
|
"line-color": [
|
|
"case",
|
|
[
|
|
"all",
|
|
["==", ["get", "has_building"], 1],
|
|
["==", ["get", "build_legal"], 0],
|
|
],
|
|
"#ef4444", // red: building without legal
|
|
["==", ["get", "has_building"], 1],
|
|
"#3b82f6", // blue: building with legal
|
|
"#15803d", // green: no building
|
|
],
|
|
"line-width": [
|
|
"case",
|
|
["==", ["get", "has_building"], 1],
|
|
2,
|
|
0.8,
|
|
],
|
|
},
|
|
},
|
|
"l-terenuri-fill",
|
|
);
|
|
} else {
|
|
try {
|
|
if (map.getLayer("l-ps-terenuri-fill"))
|
|
map.setFilter("l-ps-terenuri-fill", filter);
|
|
if (map.getLayer("l-ps-terenuri-line"))
|
|
map.setFilter("l-ps-terenuri-line", filter);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
}
|
|
|
|
// Buildings: keep base layer visible with siruta filter (already applied above)
|
|
}, [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 ─────────────────────────────────── */
|
|
const handleFeatureClick = useCallback(
|
|
(feature: ClickedFeature | null) => {
|
|
if (!feature || !feature.properties) {
|
|
setClickedFeature(null);
|
|
return;
|
|
}
|
|
setClickedFeature(feature);
|
|
},
|
|
[],
|
|
);
|
|
|
|
/* ── Selection mode handler ────────────────────────────────── */
|
|
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
|
|
if (mode === "off") {
|
|
mapHandleRef.current?.clearSelection();
|
|
setSelectedFeatures([]);
|
|
}
|
|
setSelectionMode(mode);
|
|
}, []);
|
|
|
|
/* ── Render ─────────────────────────────────────────────────── */
|
|
|
|
if (!sirutaValid) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
<MapIcon className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
|
<p>Selectează un UAT din lista de mai sus</p>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Se încarcă zona UAT...
|
|
</div>
|
|
)}
|
|
|
|
<MapViewer
|
|
ref={mapHandleRef}
|
|
className="h-full w-full"
|
|
basemap={basemap}
|
|
selectionType={selectionMode}
|
|
onFeatureClick={handleFeatureClick}
|
|
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 */}
|
|
<div className="absolute bottom-8 left-3 z-10">
|
|
<SelectionToolbar
|
|
selectedFeatures={selectedFeatures}
|
|
selectionMode={selectionMode}
|
|
onSelectionModeChange={handleSelectionModeChange}
|
|
onClearSelection={() => {
|
|
mapHandleRef.current?.clearSelection();
|
|
setSelectedFeatures([]);
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 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="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-3 w-3 rounded-sm"
|
|
style={{
|
|
backgroundColor: "rgba(251,191,36,0.25)",
|
|
border: "1px solid #15803d",
|
|
}}
|
|
/>
|
|
Fără enrichment
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-3 w-3 rounded-sm"
|
|
style={{
|
|
backgroundColor: "rgba(34,197,94,0.25)",
|
|
border: "1px solid #15803d",
|
|
}}
|
|
/>
|
|
Cu enrichment
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className="inline-block h-3 w-3 rounded-sm"
|
|
style={{
|
|
backgroundColor: "rgba(147,197,253,0.25)",
|
|
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={{
|
|
backgroundColor: "rgba(239,68,68,0.45)",
|
|
border: "2px solid #ef4444",
|
|
}}
|
|
/>
|
|
Clădire 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>
|
|
);
|
|
}
|