refactor(parcel-sync): split 4800-line module into 9 files + Harta tab + enrichment views
Split parcel-sync-module.tsx (4800 lines) into modular files: - Orchestrator (452 lines): shared state (session, UAT, sync) + tab routing - Types + helpers, ConnectionPill, 6 tab components (search, layers, export, database, cf, map) New ParcelSync Harta tab: - UAT-scoped map: zoom to extent, filter parcels/buildings by siruta - Data-driven styling via gis_terenuri_status enrichment overlay (green=no enrichment, dark green=enriched, blue outline=building, red=no legal docs) - Reuses Geoportal components (MapViewer, SelectionToolbar, FeatureInfoPanel, BasemapSwitcher) - Export DXF/GPKG for selection, legend New PostGIS views (gis_terenuri_status, gis_cladiri_status): - has_enrichment, has_building, build_legal columns from enrichment JSON - Auto-created via /api/geoportal/setup-enrichment-views - Does not modify existing Geoportal views New API: /api/geoportal/uat-bounds (WGS84 bbox from PostGIS geometry) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Map as MapIcon, Loader2 } from "lucide-react";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
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;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers — typed map operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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;
|
||||
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 [flyTarget, setFlyTarget] = useState<
|
||||
{ center: [number, number]; zoom?: number } | undefined
|
||||
>();
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
const [viewsReady, setViewsReady] = useState<boolean | null>(null);
|
||||
const appliedSirutaRef = useRef("");
|
||||
|
||||
/* Layer visibility: show terenuri + cladiri, hide admin */
|
||||
const [layerVisibility] = useState<LayerVisibility>({
|
||||
terenuri: true,
|
||||
cladiri: true,
|
||||
administrativ: false,
|
||||
});
|
||||
|
||||
/* ── Check if enrichment views exist, create if not ────────── */
|
||||
useEffect(() => {
|
||||
fetch("/api/geoportal/setup-enrichment-views")
|
||||
.then((r) => r.json())
|
||||
.then((data: { ready?: boolean }) => {
|
||||
if (data.ready) {
|
||||
setViewsReady(true);
|
||||
} else {
|
||||
// Auto-create views
|
||||
fetch("/api/geoportal/setup-enrichment-views", { method: "POST" })
|
||||
.then((r) => r.json())
|
||||
.then((res: { status?: string }) => {
|
||||
setViewsReady(res.status === "ok");
|
||||
})
|
||||
.catch(() => setViewsReady(false));
|
||||
}
|
||||
})
|
||||
.catch(() => setViewsReady(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]);
|
||||
|
||||
/* ── Apply siruta filter on base map layers ────────────────── */
|
||||
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];
|
||||
|
||||
for (const layerId of BASE_LAYERS) {
|
||||
try {
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
map.setFilter(layerId, filter);
|
||||
} catch {
|
||||
/* layer may not exist */
|
||||
}
|
||||
}
|
||||
}, [mapReady, siruta, sirutaValid]);
|
||||
|
||||
/* ── Add enrichment overlay source + layers ────────────────── */
|
||||
useEffect(() => {
|
||||
if (!mapReady || !viewsReady || !sirutaValid || !siruta) return;
|
||||
|
||||
const map = asMap(mapHandleRef.current);
|
||||
if (!map) return;
|
||||
|
||||
const martinBase = typeof window !== "undefined"
|
||||
? `${window.location.origin}/tiles`
|
||||
: "/tiles";
|
||||
|
||||
// Add gis_terenuri_status source (only once)
|
||||
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: color by enrichment status
|
||||
map.addLayer(
|
||||
{
|
||||
id: "l-ps-terenuri-fill",
|
||||
type: "fill",
|
||||
source: "gis_terenuri_status",
|
||||
"source-layer": "gis_terenuri_status",
|
||||
minzoom: 13,
|
||||
filter: ["==", ["get", "siruta"], siruta],
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"case",
|
||||
// Enriched parcels: darker green
|
||||
["==", ["get", "has_enrichment"], 1],
|
||||
"#15803d",
|
||||
// No enrichment: lighter green
|
||||
"#86efac",
|
||||
],
|
||||
"fill-opacity": 0.25,
|
||||
},
|
||||
},
|
||||
"l-terenuri-line", // insert before line layer
|
||||
);
|
||||
|
||||
// Data-driven outline
|
||||
map.addLayer(
|
||||
{
|
||||
id: "l-ps-terenuri-line",
|
||||
type: "line",
|
||||
source: "gis_terenuri_status",
|
||||
"source-layer": "gis_terenuri_status",
|
||||
minzoom: 13,
|
||||
filter: ["==", ["get", "siruta"], siruta],
|
||||
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
|
||||
["==", ["get", "has_building"], 1],
|
||||
"#3b82f6",
|
||||
// Default: green
|
||||
"#15803d",
|
||||
],
|
||||
"line-width": [
|
||||
"case",
|
||||
["==", ["get", "has_building"], 1],
|
||||
1.8,
|
||||
0.8,
|
||||
],
|
||||
},
|
||||
},
|
||||
"l-cladiri-fill",
|
||||
);
|
||||
} else {
|
||||
// Source already exists — just update filters for new siruta
|
||||
const sirutaFilter = ["==", ["get", "siruta"], siruta];
|
||||
try {
|
||||
if (map.getLayer("l-ps-terenuri-fill"))
|
||||
map.setFilter("l-ps-terenuri-fill", sirutaFilter);
|
||||
if (map.getLayer("l-ps-terenuri-line"))
|
||||
map.setFilter("l-ps-terenuri-line", sirutaFilter);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the base terenuri-fill (we replaced it with enrichment-aware version)
|
||||
try {
|
||||
if (map.getLayer("l-terenuri-fill"))
|
||||
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, [mapReady, viewsReady, siruta, sirutaValid]);
|
||||
|
||||
/* ── Fetch UAT bounds and zoom ─────────────────────────────── */
|
||||
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;
|
||||
const centerLng = (minLng + maxLng) / 2;
|
||||
const centerLat = (minLat + maxLat) / 2;
|
||||
setFlyTarget({ center: [centerLng, centerLat], zoom: 13 });
|
||||
|
||||
// Fit bounds if map is already ready
|
||||
const map = asMap(mapHandleRef.current);
|
||||
if (map) {
|
||||
map.fitBounds([minLng, minLat, maxLng, maxLat], {
|
||||
padding: 40,
|
||||
duration: 1500,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(() => setBoundsLoading(false));
|
||||
}, [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\u0103 un UAT din lista de mai sus</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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 \u00eencarc\u0103 zona UAT...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapViewer
|
||||
ref={mapHandleRef}
|
||||
className="h-full w-full"
|
||||
basemap={basemap}
|
||||
selectionType={selectionMode}
|
||||
onFeatureClick={handleFeatureClick}
|
||||
onSelectionChange={setSelectedFeatures}
|
||||
layerVisibility={layerVisibility}
|
||||
center={flyTarget?.center}
|
||||
zoom={flyTarget?.zoom}
|
||||
/>
|
||||
|
||||
{/* 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 border" style={{ backgroundColor: "rgba(134,239,172,0.25)", borderColor: "#15803d" }} />
|
||||
F\u0103r\u0103 enrichment
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(21,128,61,0.25)", borderColor: "#15803d" }} />
|
||||
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\u0103dire
|
||||
</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\u0103dire f\u0103r\u0103 acte
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user