diff --git a/prisma/gisuat-postgis-setup.sql b/prisma/gisuat-postgis-setup.sql index 9fa9b18..7ca5c1f 100644 --- a/prisma/gisuat-postgis-setup.sql +++ b/prisma/gisuat-postgis-setup.sql @@ -182,10 +182,9 @@ SELECT siruta, name, county, ST_SimplifyPreserveTopology(geom, 50) AS geom FROM "GisUat" WHERE geom IS NOT NULL; --- z12+: Fine (10m tolerance) — near-original precision +-- z12+: Original geometry — full precision, no simplification CREATE OR REPLACE VIEW gis_uats_z12 AS -SELECT siruta, name, county, - ST_SimplifyPreserveTopology(geom, 10) AS geom +SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL; -- Keep the legacy gis_uats view for QGIS compatibility diff --git a/src/app/api/geoportal/export/route.ts b/src/app/api/geoportal/export/route.ts index f207e85..16879a7 100644 --- a/src/app/api/geoportal/export/route.ts +++ b/src/app/api/geoportal/export/route.ts @@ -133,17 +133,10 @@ export async function POST(req: Request) { const outputPath = join(tmpDir, `output.${ext}`); const ogrFormat = format === "dxf" ? "DXF" : "GPKG"; - // For DXF, convert to WGS84 (architects expect it). For GPKG keep native CRS. - const ogrArgs = [ - "-f", ogrFormat, - outputPath, - inputPath, - "-a_srs", "EPSG:3844", - ]; - - if (format === "dxf") { - ogrArgs.push("-t_srs", "EPSG:4326"); - } + // DXF: reproject to WGS84 (-s_srs + -t_srs). GPKG: assign CRS only (-a_srs). + const ogrArgs = format === "dxf" + ? ["-f", ogrFormat, outputPath, inputPath, "-s_srs", "EPSG:3844", "-t_srs", "EPSG:4326"] + : ["-f", ogrFormat, outputPath, inputPath, "-a_srs", "EPSG:3844"]; try { await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 }); diff --git a/src/modules/geoportal/components/feature-info-panel.tsx b/src/modules/geoportal/components/feature-info-panel.tsx index 20416ec..9ea0266 100644 --- a/src/modules/geoportal/components/feature-info-panel.tsx +++ b/src/modules/geoportal/components/feature-info-panel.tsx @@ -1,250 +1,109 @@ "use client"; import { useEffect, useState } from "react"; -import { - X, - MapPin, - User, - Ruler, - Building2, - FileText, - Loader2, - TreePine, -} from "lucide-react"; +import { X, Loader2 } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; -import { Badge } from "@/shared/components/ui/badge"; -import { cn } from "@/shared/lib/utils"; import type { ClickedFeature, FeatureDetail, FeatureEnrichmentData } from "../types"; type FeatureInfoPanelProps = { feature: ClickedFeature | null; onClose: () => void; - className?: string; }; -export function FeatureInfoPanel({ - feature, - onClose, - className, -}: FeatureInfoPanelProps) { +export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); useEffect(() => { - if (!feature) { - setDetail(null); - return; - } + if (!feature) { setDetail(null); return; } - // Try to load enrichment from API using the object_id from vector tile props const objectId = feature.properties.object_id ?? feature.properties.objectId; const siruta = feature.properties.siruta; - if (!objectId || !siruta) { - setDetail(null); - return; - } + if (!objectId || !siruta) { setDetail(null); return; } let cancelled = false; setLoading(true); - setError(null); fetch(`/api/geoportal/feature?objectId=${objectId}&siruta=${siruta}&sourceLayer=${feature.sourceLayer}`) - .then((r) => { - if (!r.ok) throw new Error(`${r.status}`); - return r.json(); - }) - .then((data: { feature: FeatureDetail }) => { - if (!cancelled) setDetail(data.feature); - }) - .catch((err: unknown) => { - if (!cancelled) setError(err instanceof Error ? err.message : "Eroare la incarcarea detaliilor"); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + .then((r) => r.ok ? r.json() : Promise.reject()) + .then((data: { feature: FeatureDetail }) => { if (!cancelled) setDetail(data.feature); }) + .catch(() => { if (!cancelled) setDetail(null); }) + .finally(() => { if (!cancelled) setLoading(false); }); - return () => { - cancelled = true; - }; + return () => { cancelled = true; }; }, [feature]); if (!feature) return null; - const enrichment = detail?.enrichment; + const e = detail?.enrichment as FeatureEnrichmentData | null | undefined; + const isUat = feature.sourceLayer?.includes("uat"); + const cadRef = e?.NR_CAD ?? feature.properties.cadastral_ref ?? ""; + const title = isUat + ? String(feature.properties.name ?? "UAT") + : cadRef ? `Parcela ${cadRef}` : `#${feature.properties.object_id ?? "?"}`; return ( -
+
{/* Header */} -
-

- {enrichment?.NR_CAD - ? `Parcela ${enrichment.NR_CAD}` - : feature.sourceLayer === "gis_uats" - ? `UAT ${feature.properties.name ?? ""}` - : `Obiect #${feature.properties.object_id ?? feature.properties.objectId ?? "?"}`} -

-
{/* Content */} -
+
{loading && ( -
- - Se incarca... +
+ Se incarca...
)} - {error && ( -

Nu s-au putut incarca detaliile ({error})

+ {!loading && isUat && ( + <> + + + + )} - {/* Basic props from vector tile */} - {!loading && !enrichment && ( - + {!loading && !isUat && ( + <> + + + + + + {e?.PROPRIETARI && e.PROPRIETARI !== "-" && ( + + )} + {e?.INTRAVILAN && e.INTRAVILAN !== "-" && ( + + )} + {e?.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-" && ( + + )} + )} - - {/* Enrichment data */} - {enrichment && } - - {/* Coordinates */} -
- - {feature.coordinates[1].toFixed(5)}, {feature.coordinates[0].toFixed(5)} -
); } -/* ------------------------------------------------------------------ */ -/* Enrichment view */ -/* ------------------------------------------------------------------ */ - -function EnrichmentView({ data }: { data: FeatureEnrichmentData }) { - return ( -
- {/* Cadastral info */} -
- - - {data.NR_CF_VECHI && data.NR_CF_VECHI !== "-" && ( - - )} - {data.NR_TOPO && data.NR_TOPO !== "-" && ( - - )} -
- - {/* Owners */} - {data.PROPRIETARI && data.PROPRIETARI !== "-" && ( -
-

{data.PROPRIETARI}

- {data.PROPRIETARI_VECHI && data.PROPRIETARI_VECHI !== "-" && ( -

- Anterior: {data.PROPRIETARI_VECHI} -

- )} -
- )} - - {/* Area */} -
- - -
- - {/* Land use */} -
- - - {data.INTRAVILAN || "-"} - - } - /> -
- - {/* Building */} - {data.HAS_BUILDING === 1 && ( -
- -
- )} - - {/* Address */} - {data.ADRESA && data.ADRESA !== "-" && ( -
-

{data.ADRESA}

-
- )} -
- ); -} - -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function Section({ - icon: Icon, - label, - children, -}: { - icon: typeof FileText; - label: string; - children: React.ReactNode; -}) { - return ( -
-
- - - {label} - -
-
{children}
-
- ); -} - -function Row({ label, value }: { label: string; value: React.ReactNode }) { +function Row({ label, value }: { label: string; value: unknown }) { if (!value || value === "-" || value === "") return null; return ( -
+
{label} - {typeof value === "string" ? value : value} + {String(value)}
); } -function PropsTable({ properties }: { properties: Record }) { - const entries = Object.entries(properties).filter( - ([, v]) => v != null && v !== "" - ); - if (entries.length === 0) return

Fara atribute

; - return ( -
- {entries.map(([key, value]) => ( - - ))} -
- ); -} - -function formatArea(v: number | string): string { - if (v === "" || v == null) return "-"; - const n = typeof v === "string" ? parseFloat(v) : v; +function formatArea(v: unknown): string { + if (!v || v === "") return ""; + const n = typeof v === "number" ? v : parseFloat(String(v)); if (isNaN(n)) return String(v); return `${n.toLocaleString("ro-RO")} mp`; } diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx index 9ea315e..163d091 100644 --- a/src/modules/geoportal/components/geoportal-module.tsx +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -9,19 +9,11 @@ import { SelectionToolbar } from "./selection-toolbar"; import { FeatureInfoPanel } from "./feature-info-panel"; import type { MapViewerHandle } from "./map-viewer"; import type { - BasemapId, - ClickedFeature, - LayerVisibility, - SearchResult, - SelectedFeature, + BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature, } from "../types"; -/* MapLibre uses WebGL — must disable SSR */ const MapViewer = dynamic( - () => - import("./map-viewer").then((m) => ({ - default: m.MapViewer, - })), + () => import("./map-viewer").then((m) => ({ default: m.MapViewer })), { ssr: false, loading: () => ( @@ -32,54 +24,33 @@ const MapViewer = dynamic( } ); -/* ------------------------------------------------------------------ */ -/* Component */ -/* ------------------------------------------------------------------ */ - export function GeoportalModule() { const mapHandleRef = useRef(null); - - // Map state const [basemap, setBasemap] = useState("liberty"); - const [layerVisibility, setLayerVisibility] = useState( - getDefaultVisibility - ); - - // Feature info + const [layerVisibility, setLayerVisibility] = useState(getDefaultVisibility); const [clickedFeature, setClickedFeature] = useState(null); - - // Selection const [selectionMode, setSelectionMode] = useState(false); const [selectedFeatures, setSelectedFeatures] = useState([]); - - // Fly-to target (from search) const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>(); - const handleFeatureClick = useCallback((feature: ClickedFeature) => { + const handleFeatureClick = useCallback((feature: ClickedFeature | null) => { + // null = clicked on empty space, close panel + if (!feature || !feature.properties) { + setClickedFeature(null); + return; + } setClickedFeature(feature); }, []); - const handleVisibilityChange = useCallback((vis: LayerVisibility) => { - setLayerVisibility(vis); - }, []); - const handleSearchResult = useCallback((result: SearchResult) => { if (result.coordinates) { - setFlyTarget({ - center: result.coordinates, - zoom: result.type === "uat" ? 12 : 17, - }); + setFlyTarget({ center: result.coordinates, zoom: result.type === "uat" ? 12 : 17 }); } }, []); - const handleSelectionChange = useCallback((features: SelectedFeature[]) => { - setSelectedFeatures(features); - }, []); - const handleToggleSelectionMode = useCallback(() => { setSelectionMode((prev) => { if (prev) { - // Turning off: clear selection mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); } @@ -87,38 +58,32 @@ export function GeoportalModule() { }); }, []); - const handleClearSelection = useCallback(() => { - mapHandleRef.current?.clearSelection(); - setSelectedFeatures([]); - }, []); - return (
- {/* Full-bleed map */} - {/* Top-left controls: search + layers */} + {/* Top-left: search + layers */}
- +
- {/* Top-right: basemap switcher (offset to avoid nav controls) */} -
+ {/* Top-right: basemap switcher + feature panel (aligned) */} +
+ {clickedFeature && !selectionMode && ( + setClickedFeature(null)} /> + )}
{/* Bottom-left: selection toolbar */} @@ -127,19 +92,9 @@ export function GeoportalModule() { selectedFeatures={selectedFeatures} selectionMode={selectionMode} onToggleSelectionMode={handleToggleSelectionMode} - onClearSelection={handleClearSelection} + onClearSelection={() => { mapHandleRef.current?.clearSelection(); setSelectedFeatures([]); }} />
- - {/* Right side: feature info panel */} - {clickedFeature && !selectionMode && ( -
- setClickedFeature(null)} - /> -
- )}
); } diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx index fc8faed..6f34fb2 100644 --- a/src/modules/geoportal/components/map-viewer.tsx +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -4,8 +4,7 @@ import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardR import maplibregl from "maplibre-gl"; import { cn } from "@/shared/lib/utils"; -/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone. - Load from /public to avoid CDN/CSP issues. */ +/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */ if (typeof document !== "undefined") { const LINK_ID = "maplibre-gl-css"; if (!document.getElementById(LINK_ID)) { @@ -22,68 +21,53 @@ import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from /* Constants */ /* ------------------------------------------------------------------ */ -/** - * Martin tile URL — relative /tiles is proxied by Next.js rewrite (dev) - * or Traefik (production). Falls back to env var if set. - */ const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles"; - -/** Default center: Romania roughly centered */ const DEFAULT_CENTER: [number, number] = [23.8, 46.1]; const DEFAULT_ZOOM = 7; -/** Source/layer IDs used on the map */ const SOURCES = { - uatsZ0: "gis_uats_z0", // z0-5: 2000m simplification - uatsZ5: "gis_uats_z5", // z5-8: 500m - uatsZ8: "gis_uats_z8", // z8-12: 50m - uatsZ12: "gis_uats_z12", // z12+: 10m (near-original) + uatsZ0: "gis_uats_z0", + uatsZ5: "gis_uats_z5", + uatsZ8: "gis_uats_z8", + uatsZ12: "gis_uats_z12", terenuri: "gis_terenuri", cladiri: "gis_cladiri", administrativ: "gis_administrativ", } as const; -/** Map layer IDs (prefixed to avoid collisions) */ const LAYER_IDS = { - uatsZ0Line: "layer-uats-z0-line", - uatsZ5Fill: "layer-uats-z5-fill", - uatsZ5Line: "layer-uats-z5-line", - uatsZ8Fill: "layer-uats-z8-fill", - uatsZ8Line: "layer-uats-z8-line", - uatsZ8Label: "layer-uats-z8-label", - uatsZ12Fill: "layer-uats-z12-fill", - uatsZ12Line: "layer-uats-z12-line", - uatsZ12Label: "layer-uats-z12-label", - adminFill: "layer-admin-fill", - adminLine: "layer-admin-line", - terenuriFill: "layer-terenuri-fill", - terenuriLine: "layer-terenuri-line", - cladiriFill: "layer-cladiri-fill", - cladiriLine: "layer-cladiri-line", - selectionFill: "layer-selection-fill", - selectionLine: "layer-selection-line", + uatsZ0Line: "l-uats-z0-line", + uatsZ5Fill: "l-uats-z5-fill", + uatsZ5Line: "l-uats-z5-line", + uatsZ8Fill: "l-uats-z8-fill", + uatsZ8Line: "l-uats-z8-line", + uatsZ8Label: "l-uats-z8-label", + uatsZ12Fill: "l-uats-z12-fill", + uatsZ12Line: "l-uats-z12-line", + uatsZ12Label: "l-uats-z12-label", + adminLineOuter: "l-admin-line-outer", + adminLineInner: "l-admin-line-inner", + terenuriFill: "l-terenuri-fill", + terenuriLine: "l-terenuri-line", + terenuriLabel: "l-terenuri-label", + cladiriFill: "l-cladiri-fill", + cladiriLine: "l-cladiri-line", + selectionFill: "l-selection-fill", + selectionLine: "l-selection-line", } as const; -/** Basemap definitions — vector style URL or inline raster config */ +/* ---- Basemap definitions ---- */ type BasemapDef = | { type: "style"; url: string; maxzoom?: number } | { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }; const BASEMAPS: Record = { - liberty: { - type: "style", - url: "https://tiles.openfreemap.org/styles/liberty", - }, - dark: { - type: "style", - url: "https://tiles.openfreemap.org/styles/dark", - }, + liberty: { type: "style", url: "https://tiles.openfreemap.org/styles/liberty" }, + dark: { type: "style", url: "https://tiles.openfreemap.org/styles/dark" }, satellite: { type: "raster", - tiles: [ - "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", - ], - attribution: '© Esri, Maxar, Earthstar Geographics', + tiles: ["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"], + attribution: '© Esri, Maxar', tileSize: 256, }, orto: { @@ -100,27 +84,14 @@ function buildStyle(def: BasemapDef): string | maplibregl.StyleSpecification { return { version: 8 as const, sources: { - basemap: { - type: "raster" as const, - tiles: def.tiles, - tileSize: def.tileSize, - attribution: def.attribution, - }, + basemap: { type: "raster" as const, tiles: def.tiles, tileSize: def.tileSize, attribution: def.attribution }, }, - layers: [ - { - id: "basemap-tiles", - type: "raster" as const, - source: "basemap", - minzoom: 0, - maxzoom: def.maxzoom ?? 19, - }, - ], + layers: [{ id: "basemap-tiles", type: "raster" as const, source: "basemap", minzoom: 0, maxzoom: def.maxzoom ?? 19 }], }; } /* ------------------------------------------------------------------ */ -/* Props */ +/* Props & Handle */ /* ------------------------------------------------------------------ */ export type MapViewerHandle = { @@ -139,27 +110,9 @@ type MapViewerProps = { selectionMode?: boolean; onFeatureClick?: (feature: ClickedFeature) => void; onSelectionChange?: (features: SelectedFeature[]) => void; - /** External layer visibility control */ layerVisibility?: LayerVisibility; }; -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - -function formatPopupContent(properties: Record): string { - const rows: string[] = []; - for (const [key, value] of Object.entries(properties)) { - if (value == null || value === "") continue; - const displayKey = key.replace(/_/g, " "); - rows.push( - `${displayKey}${String(value)}` - ); - } - if (rows.length === 0) return "

Fara atribute

"; - return `${rows.join("")}
`; -} - /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ @@ -167,29 +120,24 @@ function formatPopupContent(properties: Record): string { export const MapViewer = forwardRef( function MapViewer( { - center, - zoom, - martinUrl, - className, + center, zoom, martinUrl, className, basemap = "liberty", selectionMode = false, - onFeatureClick, - onSelectionChange, - layerVisibility, + onFeatureClick, onSelectionChange, layerVisibility, }, ref ) { const containerRef = useRef(null); const mapRef = useRef(null); - const popupRef = useRef(null); const selectedRef = useRef>(new Map()); const selectionModeRef = useRef(selectionMode); const [mapReady, setMapReady] = useState(false); - // Keep ref in sync + // Persist view state across basemap switches + const viewStateRef = useRef({ center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM }); + selectionModeRef.current = selectionMode; - // MapLibre web workers can't resolve relative URLs — need absolute const resolvedMartinUrl = (() => { const raw = martinUrl ?? DEFAULT_MARTIN_URL; if (raw.startsWith("http")) return raw; @@ -202,22 +150,14 @@ export const MapViewer = forwardRef( const map = mapRef.current; if (!map) return; const ids = Array.from(selectedRef.current.keys()); - // Use objectId matching for the selection highlight layer const filter: maplibregl.FilterSpecification = ids.length > 0 ? ["in", ["to-string", ["get", "object_id"]], ["literal", ids]] : ["==", "object_id", "__NONE__"]; - try { - if (map.getLayer(LAYER_IDS.selectionFill)) { - map.setFilter(LAYER_IDS.selectionFill, filter); - } - if (map.getLayer(LAYER_IDS.selectionLine)) { - map.setFilter(LAYER_IDS.selectionLine, filter); - } - } catch { - // layers might not exist yet - } + if (map.getLayer(LAYER_IDS.selectionFill)) map.setFilter(LAYER_IDS.selectionFill, filter); + if (map.getLayer(LAYER_IDS.selectionLine)) map.setFilter(LAYER_IDS.selectionLine, filter); + } catch { /* noop */ } }, []); const clearSelection = useCallback(() => { @@ -229,9 +169,7 @@ export const MapViewer = forwardRef( /* ---- Imperative handle ---- */ useImperativeHandle(ref, () => ({ getMap: () => mapRef.current, - setLayerVisibility: (vis: LayerVisibility) => { - applyLayerVisibility(vis); - }, + setLayerVisibility: (vis: LayerVisibility) => applyLayerVisibility(vis), flyTo: (c: [number, number], z?: number) => { mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 }); }, @@ -242,7 +180,6 @@ export const MapViewer = forwardRef( const applyLayerVisibility = useCallback((vis: LayerVisibility) => { const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; - const mapping: Record = { uats: [ LAYER_IDS.uatsZ0Line, @@ -250,52 +187,45 @@ export const MapViewer = forwardRef( LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ8Line, LAYER_IDS.uatsZ8Label, LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label, ], - administrativ: [LAYER_IDS.adminFill, LAYER_IDS.adminLine], - terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine], + administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner], + terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel], cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine], }; - for (const [group, layerIds] of Object.entries(mapping)) { - const visible = vis[group] !== false; // default visible + const visible = vis[group] !== false; for (const lid of layerIds) { - try { - map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none"); - } catch { - // layer might not exist yet - } + try { map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none"); } catch { /* noop */ } } } }, []); - /* ---- Sync external visibility prop ---- */ useEffect(() => { - if (mapReady && layerVisibility) { - applyLayerVisibility(layerVisibility); - } + if (mapReady && layerVisibility) applyLayerVisibility(layerVisibility); }, [mapReady, layerVisibility, applyLayerVisibility]); /* ---- Map initialization (recreates on basemap change) ---- */ useEffect(() => { if (!containerRef.current) return; - // Preserve current view when switching basemaps - const prevMap = mapRef.current; - const currentCenter = prevMap ? prevMap.getCenter().toArray() as [number, number] : (center ?? DEFAULT_CENTER); - const currentZoom = prevMap ? prevMap.getZoom() : (zoom ?? DEFAULT_ZOOM); - const basemapDef = BASEMAPS[basemap]; - const map = new maplibregl.Map({ container: containerRef.current, style: buildStyle(basemapDef), - center: currentCenter, - zoom: currentZoom, + center: viewStateRef.current.center, + zoom: viewStateRef.current.zoom, maxZoom: basemapDef.maxzoom ?? 20, }); mapRef.current = map; - /* ---- Controls ---- */ + // Save view state on every move (for basemap switch preservation) + map.on("moveend", () => { + viewStateRef.current = { + center: map.getCenter().toArray() as [number, number], + zoom: map.getZoom(), + }; + }); + map.addControl(new maplibregl.NavigationControl(), "top-right"); map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); @@ -303,307 +233,132 @@ export const MapViewer = forwardRef( map.on("load", () => { const m = resolvedMartinUrl; - // === UAT z0-5: very coarse (2000m) — lines only === - map.addSource(SOURCES.uatsZ0, { - type: "vector", - tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], - minzoom: 0, maxzoom: 5, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ0Line, type: "line", - source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, - maxzoom: 5, - paint: { "line-color": "#7c3aed", "line-width": 0.3 }, - }); + // === UAT z0-5: very coarse — lines only === + map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 }); + map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5, + paint: { "line-color": "#7c3aed", "line-width": 0.3 } }); - // === UAT z5-8: coarse (500m) — lines + faint fill === - map.addSource(SOURCES.uatsZ5, { - type: "vector", - tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], - minzoom: 5, maxzoom: 8, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ5Fill, type: "fill", - source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, - minzoom: 5, maxzoom: 8, - paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.03 }, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ5Line, type: "line", - source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, - minzoom: 5, maxzoom: 8, - paint: { "line-color": "#7c3aed", "line-width": 0.6 }, - }); + // === UAT z5-8: coarse === + map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 }); + map.addLayer({ id: LAYER_IDS.uatsZ5Fill, type: "fill", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.03 } }); + map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8, + paint: { "line-color": "#7c3aed", "line-width": 0.6 } }); - // === UAT z8-12: moderate (50m) — lines + fill + labels === - map.addSource(SOURCES.uatsZ8, { - type: "vector", - tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], - minzoom: 8, maxzoom: 12, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ8Fill, type: "fill", - source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, - minzoom: 8, maxzoom: 12, - paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05 }, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ8Line, type: "line", - source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, - minzoom: 8, maxzoom: 12, - paint: { "line-color": "#7c3aed", "line-width": 1 }, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ8Label, type: "symbol", - source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, - minzoom: 9, maxzoom: 12, + // === UAT z8-12: moderate === + map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 }); + map.addLayer({ id: LAYER_IDS.uatsZ8Fill, type: "fill", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05 } }); + map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12, + paint: { "line-color": "#7c3aed", "line-width": 1 } }); + map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12, + layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false }, + paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } }); + + // === UAT z12+: full detail (no simplification) === + map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 }); + map.addLayer({ id: LAYER_IDS.uatsZ12Fill, type: "fill", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12, + paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.08 } }); + map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12, + paint: { "line-color": "#7c3aed", "line-width": 2 } }); + map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12, + layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false }, + paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } }); + + // === Intravilan — double line (black outer + orange inner), no fill, z13+ === + map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 }); + map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13, + paint: { "line-color": "#000000", "line-width": 3 } }); + map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13, + paint: { "line-color": "#f97316", "line-width": 1.5 } }); + + // === Terenuri (parcels) — no simplification === + map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 }); + map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13, + paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } }); + map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13, + paint: { "line-color": "#15803d", "line-width": 0.8 } }); + // Parcel cadastral number label + map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16, layout: { - "text-field": ["coalesce", ["get", "name"], ""], + "text-field": ["coalesce", ["get", "cadastral_ref"], ""], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false, + "text-max-width": 8, }, - paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 }, - }); + paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } }); - // === UAT z12+: fine (10m) — full detail === - map.addSource(SOURCES.uatsZ12, { - type: "vector", - tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], - minzoom: 12, maxzoom: 16, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ12Fill, type: "fill", - source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, - minzoom: 12, - paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.08 }, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ12Line, type: "line", - source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, - minzoom: 12, - paint: { "line-color": "#7c3aed", "line-width": 2 }, - }); - map.addLayer({ - id: LAYER_IDS.uatsZ12Label, type: "symbol", - source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, - minzoom: 12, - layout: { - "text-field": ["coalesce", ["get", "name"], ""], - "text-size": 13, "text-anchor": "center", "text-allow-overlap": false, - }, - paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 }, - }); + // === Cladiri (buildings) — no simplification === + map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 }); + map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14, + paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } }); + map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14, + paint: { "line-color": "#1e3a5f", "line-width": 0.6 } }); - // === Administrativ (intravilan, arii speciale) === - map.addSource(SOURCES.administrativ, { - type: "vector", - tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], - minzoom: 10, maxzoom: 16, - }); - map.addLayer({ - id: LAYER_IDS.adminFill, type: "fill", - source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, - minzoom: 11, - paint: { "fill-color": "#f97316", "fill-opacity": 0.06 }, - }); - map.addLayer({ - id: LAYER_IDS.adminLine, type: "line", - source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, - minzoom: 10, - paint: { "line-color": "#ea580c", "line-width": 1.2, "line-dasharray": [4, 2] }, - }); - - // --- Terenuri (parcels) — NO simplification --- - map.addSource(SOURCES.terenuri, { - type: "vector", - tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}`], - minzoom: 10, - maxzoom: 18, - }); - - map.addLayer({ - id: LAYER_IDS.terenuriFill, - type: "fill", - source: SOURCES.terenuri, - "source-layer": SOURCES.terenuri, - minzoom: 13, - paint: { - "fill-color": "#22c55e", - "fill-opacity": 0.15, - }, - }); - - map.addLayer({ - id: LAYER_IDS.terenuriLine, - type: "line", - source: SOURCES.terenuri, - "source-layer": SOURCES.terenuri, - minzoom: 13, - paint: { - "line-color": "#15803d", - "line-width": 0.8, - }, - }); - - // --- Cladiri (buildings) --- - map.addSource(SOURCES.cladiri, { - type: "vector", - tiles: [`${resolvedMartinUrl}/${SOURCES.cladiri}/{z}/{x}/{y}`], - minzoom: 12, - maxzoom: 18, - }); - - map.addLayer({ - id: LAYER_IDS.cladiriFill, - type: "fill", - source: SOURCES.cladiri, - "source-layer": SOURCES.cladiri, - minzoom: 14, - paint: { - "fill-color": "#3b82f6", - "fill-opacity": 0.5, - }, - }); - - map.addLayer({ - id: LAYER_IDS.cladiriLine, - type: "line", - source: SOURCES.cladiri, - "source-layer": SOURCES.cladiri, - minzoom: 14, - paint: { - "line-color": "#1e3a5f", - "line-width": 0.6, - }, - }); - - // --- Selection highlight layer (uses same sources) --- - // We add a highlight layer on top for terenuri (primary selection target) - map.addLayer({ - id: LAYER_IDS.selectionFill, - type: "fill", - source: SOURCES.terenuri, - "source-layer": SOURCES.terenuri, - minzoom: 13, - filter: ["==", "object_id", "__NONE__"], // empty initially - paint: { - "fill-color": "#f59e0b", - "fill-opacity": 0.5, - }, - }); - - map.addLayer({ - id: LAYER_IDS.selectionLine, - type: "line", - source: SOURCES.terenuri, - "source-layer": SOURCES.terenuri, - minzoom: 13, + // === Selection highlight === + map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13, filter: ["==", "object_id", "__NONE__"], - paint: { - "line-color": "#d97706", - "line-width": 2.5, - }, - }); - - // Apply initial visibility if provided - if (layerVisibility) { - applyLayerVisibility(layerVisibility); - } + paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } }); + map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13, + filter: ["==", "object_id", "__NONE__"], + paint: { "line-color": "#d97706", "line-width": 2.5 } }); + if (layerVisibility) applyLayerVisibility(layerVisibility); setMapReady(true); }); - /* ---- Click handler ---- */ + /* ---- Click handler — NO popup, only callback ---- */ const clickableLayers = [ - LAYER_IDS.terenuriFill, - LAYER_IDS.cladiriFill, - LAYER_IDS.uatsZ5Fill, - LAYER_IDS.uatsZ8Fill, - LAYER_IDS.uatsZ12Fill, + LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill, + LAYER_IDS.uatsZ5Fill, LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ12Fill, ]; map.on("click", (e) => { const features = map.queryRenderedFeatures(e.point, { - layers: clickableLayers.filter((l) => { - try { - return !!map.getLayer(l); - } catch { - return false; - } - }), + layers: clickableLayers.filter((l) => { try { return !!map.getLayer(l); } catch { return false; } }), }); - // Close existing popup - if (popupRef.current) { - popupRef.current.remove(); - popupRef.current = null; + if (features.length === 0) { + onFeatureClick?.(null as unknown as ClickedFeature); // close panel + return; } - if (features.length === 0) return; - const first = features[0]; if (!first) return; const props = (first.properties ?? {}) as Record; const sourceLayer = first.sourceLayer ?? first.source ?? ""; - // Selection mode: toggle feature in selection + // Selection mode if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) { - const objectId = String(props.object_id ?? props.objectId ?? ""); + const objectId = String(props.object_id ?? ""); if (!objectId) return; - if (selectedRef.current.has(objectId)) { selectedRef.current.delete(objectId); } else { - selectedRef.current.set(objectId, { - id: objectId, - sourceLayer, - properties: props, - }); + selectedRef.current.set(objectId, { id: objectId, sourceLayer, properties: props }); } updateSelectionFilter(); onSelectionChange?.(Array.from(selectedRef.current.values())); return; } - // Notify parent - if (onFeatureClick) { - onFeatureClick({ - layerId: first.layer?.id ?? "", - sourceLayer, - properties: props, - coordinates: [e.lngLat.lng, e.lngLat.lat], - }); - } - - // Show popup (only in non-selection mode) - const popup = new maplibregl.Popup({ - maxWidth: "360px", - closeButton: true, - closeOnClick: true, - }) - .setLngLat(e.lngLat) - .setHTML(formatPopupContent(props)) - .addTo(map); - - popupRef.current = popup; + // Feature click — notify parent (no popup) + onFeatureClick?.({ + layerId: first.layer?.id ?? "", + sourceLayer, + properties: props, + coordinates: [e.lngLat.lng, e.lngLat.lat], + }); }); - /* ---- Cursor change on hover ---- */ + /* ---- Cursor change ---- */ for (const lid of clickableLayers) { - map.on("mouseenter", lid, () => { - map.getCanvas().style.cursor = "pointer"; - }); - map.on("mouseleave", lid, () => { - map.getCanvas().style.cursor = ""; - }); + map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; }); + map.on("mouseleave", lid, () => { map.getCanvas().style.cursor = ""; }); } /* ---- Cleanup ---- */ return () => { - if (popupRef.current) { - popupRef.current.remove(); - popupRef.current = null; - } map.remove(); mapRef.current = null; setMapReady(false); @@ -611,16 +366,10 @@ export const MapViewer = forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedMartinUrl, basemap]); - /* ---- Sync center/zoom prop changes ---- */ + /* ---- Sync center/zoom prop changes (from search flyTo) ---- */ useEffect(() => { - if (!mapReady || !mapRef.current) return; - if (center) { - mapRef.current.flyTo({ - center, - zoom: zoom ?? mapRef.current.getZoom(), - duration: 1500, - }); - } + if (!mapReady || !mapRef.current || !center) return; + mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 }); }, [center, zoom, mapReady]); return (