"use client"; import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react"; import maplibregl from "maplibre-gl"; import { cn } from "@/shared/lib/utils"; /* 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)) { const link = document.createElement("link"); link.id = LINK_ID; link.rel = "stylesheet"; link.href = "https://unpkg.com/maplibre-gl@5.21.0/dist/maplibre-gl.css"; document.head.appendChild(link); } } import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types"; /* ------------------------------------------------------------------ */ /* 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 = { uats: "gis_uats", terenuri: "gis_terenuri", cladiri: "gis_cladiri", } as const; /** Map layer IDs (prefixed to avoid collisions) */ const LAYER_IDS = { uatsFill: "layer-uats-fill", uatsLine: "layer-uats-line", uatsLabel: "layer-uats-label", terenuriFill: "layer-terenuri-fill", terenuriLine: "layer-terenuri-line", cladiriFill: "layer-cladiri-fill", cladiriLine: "layer-cladiri-line", selectionFill: "layer-selection-fill", selectionLine: "layer-selection-line", } as const; /** Basemap tile definitions */ const BASEMAP_TILES: Record = { osm: { tiles: [ "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", "https://b.tile.openstreetmap.org/{z}/{x}/{y}.png", "https://c.tile.openstreetmap.org/{z}/{x}/{y}.png", ], attribution: '© OpenStreetMap', tileSize: 256, }, satellite: { tiles: [ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", ], attribution: '© Esri, Maxar, Earthstar Geographics', tileSize: 256, }, topo: { tiles: [ "https://a.tile.opentopomap.org/{z}/{x}/{y}.png", "https://b.tile.opentopomap.org/{z}/{x}/{y}.png", "https://c.tile.opentopomap.org/{z}/{x}/{y}.png", ], attribution: '© OpenTopoMap (CC-BY-SA)', tileSize: 256, }, }; /* ------------------------------------------------------------------ */ /* Props */ /* ------------------------------------------------------------------ */ export type MapViewerHandle = { getMap: () => maplibregl.Map | null; setLayerVisibility: (visibility: LayerVisibility) => void; flyTo: (center: [number, number], zoom?: number) => void; clearSelection: () => void; }; type MapViewerProps = { center?: [number, number]; zoom?: number; martinUrl?: string; className?: string; basemap?: BasemapId; 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 */ /* ------------------------------------------------------------------ */ export const MapViewer = forwardRef( function MapViewer( { center, zoom, martinUrl, className, basemap = "osm", selectionMode = false, 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 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; if (typeof window !== "undefined") return `${window.location.origin}${raw}`; return raw; })(); /* ---- Selection helpers ---- */ const updateSelectionFilter = useCallback(() => { 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 } }, []); const clearSelection = useCallback(() => { selectedRef.current.clear(); updateSelectionFilter(); onSelectionChange?.([]); }, [updateSelectionFilter, onSelectionChange]); /* ---- Imperative handle ---- */ useImperativeHandle(ref, () => ({ getMap: () => mapRef.current, setLayerVisibility: (vis: LayerVisibility) => { applyLayerVisibility(vis); }, flyTo: (c: [number, number], z?: number) => { mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 }); }, clearSelection, })); /* ---- Apply layer visibility ---- */ const applyLayerVisibility = useCallback((vis: LayerVisibility) => { const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; const mapping: Record = { uats: [LAYER_IDS.uatsFill, LAYER_IDS.uatsLine, LAYER_IDS.uatsLabel], terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine], cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine], }; for (const [group, layerIds] of Object.entries(mapping)) { const visible = vis[group] !== false; // default visible for (const lid of layerIds) { try { map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none"); } catch { // layer might not exist yet } } } }, []); /* ---- Sync external visibility prop ---- */ useEffect(() => { if (mapReady && layerVisibility) { applyLayerVisibility(layerVisibility); } }, [mapReady, layerVisibility, applyLayerVisibility]); /* ---- Basemap switching ---- */ useEffect(() => { const map = mapRef.current; if (!map || !mapReady) return; const source = map.getSource("basemap") as maplibregl.RasterTileSource | undefined; if (!source) return; const def = BASEMAP_TILES[basemap]; // Update tiles by re-adding the source // MapLibre doesn't support changing tiles on existing source, so we rebuild try { // Remove all layers that depend on basemap source, then remove source if (map.getLayer("basemap-tiles")) map.removeLayer("basemap-tiles"); map.removeSource("basemap"); map.addSource("basemap", { type: "raster", tiles: def.tiles, tileSize: def.tileSize, attribution: def.attribution, }); // Re-add basemap layer at bottom const firstLayerId = map.getStyle().layers[0]?.id; map.addLayer( { id: "basemap-tiles", type: "raster", source: "basemap", minzoom: 0, maxzoom: 19, }, firstLayerId // insert before first existing layer ); } catch { // Fallback: if anything fails, the map still works } }, [basemap, mapReady]); /* ---- Map initialization ---- */ useEffect(() => { if (!containerRef.current) return; const initialBasemap = BASEMAP_TILES[basemap]; const map = new maplibregl.Map({ container: containerRef.current, style: { version: 8, sources: { basemap: { type: "raster", tiles: initialBasemap.tiles, tileSize: initialBasemap.tileSize, attribution: initialBasemap.attribution, }, }, layers: [ { id: "basemap-tiles", type: "raster", source: "basemap", minzoom: 0, maxzoom: 19, }, ], }, center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM, maxZoom: 20, }); mapRef.current = map; /* ---- Controls ---- */ map.addControl(new maplibregl.NavigationControl(), "top-right"); map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); /* ---- Add Martin sources + layers on load ---- */ map.on("load", () => { // --- UAT boundaries --- map.addSource(SOURCES.uats, { type: "vector", tiles: [`${resolvedMartinUrl}/${SOURCES.uats}/{z}/{x}/{y}.pbf`], minzoom: 0, maxzoom: 16, }); map.addLayer({ id: LAYER_IDS.uatsFill, type: "fill", source: SOURCES.uats, "source-layer": SOURCES.uats, paint: { "fill-color": "#8b5cf6", "fill-opacity": 0.05, }, }); map.addLayer({ id: LAYER_IDS.uatsLine, type: "line", source: SOURCES.uats, "source-layer": SOURCES.uats, paint: { "line-color": "#7c3aed", "line-width": 1.5, }, }); map.addLayer({ id: LAYER_IDS.uatsLabel, type: "symbol", source: SOURCES.uats, "source-layer": SOURCES.uats, layout: { "text-field": ["coalesce", ["get", "name"], ["get", "uat_name"], ""], "text-size": 12, "text-anchor": "center", "text-allow-overlap": false, }, paint: { "text-color": "#5b21b6", "text-halo-color": "#ffffff", "text-halo-width": 1.5, }, }); // --- Terenuri (parcels) --- map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}.pbf`], 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}.pbf`], 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, filter: ["==", "object_id", "__NONE__"], paint: { "line-color": "#d97706", "line-width": 2.5, }, }); // Apply initial visibility if provided if (layerVisibility) { applyLayerVisibility(layerVisibility); } setMapReady(true); }); /* ---- Click handler ---- */ const clickableLayers = [ LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill, LAYER_IDS.uatsFill, ]; map.on("click", (e) => { const features = map.queryRenderedFeatures(e.point, { 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) 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 if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) { const objectId = String(props.object_id ?? props.objectId ?? ""); if (!objectId) return; if (selectedRef.current.has(objectId)) { selectedRef.current.delete(objectId); } else { 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; }); /* ---- Cursor change on hover ---- */ for (const lid of clickableLayers) { 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); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedMartinUrl]); /* ---- Sync center/zoom prop changes ---- */ useEffect(() => { if (!mapReady || !mapRef.current) return; if (center) { mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500, }); } }, [center, zoom, mapReady]); return (
{!mapReady && (

Se incarca harta...

)}
); } );