"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 = "/maplibre-gl.css"; document.head.appendChild(link); } } import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types"; /* ------------------------------------------------------------------ */ /* Selection type (re-exported for convenience) */ /* ------------------------------------------------------------------ */ export type SelectionType = "off" | "click" | "rect" | "freehand"; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles"; const DEFAULT_CENTER: [number, number] = [23.8, 46.1]; const DEFAULT_ZOOM = 7; const SOURCES = { 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; const LAYER_IDS = { 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", drawPolygonFill: "l-draw-polygon-fill", drawPolygonLine: "l-draw-polygon-line", } as const; const DRAW_SOURCE = "draw-polygon"; /* ---- 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" }, satellite: { type: "raster", tiles: ["https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"], attribution: '© Esri, Maxar', tileSize: 256, }, google: { type: "raster", tiles: ["https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"], attribution: '© Google', tileSize: 256, maxzoom: 20, }, orto: { type: "raster", tiles: ["/api/eterra/tiles/orto?z={z}&x={x}&y={y}"], attribution: '© ANCPI Ortofoto 2024', tileSize: 512, maxzoom: 19, }, }; function buildStyle(def: BasemapDef): string | maplibregl.StyleSpecification { if (def.type === "style") return def.url; return { version: 8 as const, sources: { 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 }], }; } /* ------------------------------------------------------------------ */ /* Point-in-polygon (ray casting) */ /* ------------------------------------------------------------------ */ function pointInPolygon(point: [number, number], polygon: [number, number][]): boolean { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const pi = polygon[i]; const pj = polygon[j]; if (!pi || !pj) continue; const xi = pi[0], yi = pi[1]; const xj = pj[0], yj = pj[1]; if ((yi > point[1]) !== (yj > point[1]) && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; } /* ------------------------------------------------------------------ */ /* Empty GeoJSON constant */ /* ------------------------------------------------------------------ */ const EMPTY_GEOJSON: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [], }; /* ------------------------------------------------------------------ */ /* Props & Handle */ /* ------------------------------------------------------------------ */ 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; selectionType?: SelectionType; onFeatureClick?: (feature: ClickedFeature) => void; onSelectionChange?: (features: SelectedFeature[]) => void; layerVisibility?: LayerVisibility; }; /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export const MapViewer = forwardRef( function MapViewer( { center, zoom, martinUrl, className, basemap = "liberty", selectionType = "off", onFeatureClick, onSelectionChange, layerVisibility, }, ref ) { const containerRef = useRef(null); const mapRef = useRef(null); const selectedRef = useRef>(new Map()); const selectionTypeRef = useRef(selectionType); const [mapReady, setMapReady] = useState(false); // Persist view state across basemap switches const viewStateRef = useRef({ center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM }); // --- Rectangle drawing state --- const rectStartRef = useRef<{ x: number; y: number } | null>(null); const rectOverlayRef = useRef(null); const rectDrawingRef = useRef(false); // --- Freehand polygon drawing state --- const freehandPointsRef = useRef<[number, number][]>([]); selectionTypeRef.current = selectionType; 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()); 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 { /* noop */ } }, []); const clearSelection = useCallback(() => { selectedRef.current.clear(); updateSelectionFilter(); onSelectionChange?.([]); }, [updateSelectionFilter, onSelectionChange]); /* ---- Clear draw state helper ---- */ const clearDrawState = useCallback(() => { // Clear rect state rectStartRef.current = null; rectDrawingRef.current = false; if (rectOverlayRef.current) { rectOverlayRef.current.remove(); rectOverlayRef.current = null; } // Clear freehand state freehandPointsRef.current = []; // Clear draw polygon source on map const map = mapRef.current; if (map) { try { const src = map.getSource(DRAW_SOURCE) as maplibregl.GeoJSONSource | undefined; if (src) src.setData(EMPTY_GEOJSON); } catch { /* noop */ } // Re-enable drag pan in case it was disabled try { map.dragPan.enable(); } catch { /* noop */ } } }, []); /* ---- Update draw polygon source ---- */ const updateDrawPolygon = useCallback((points: [number, number][]) => { const map = mapRef.current; if (!map) return; try { const src = map.getSource(DRAW_SOURCE) as maplibregl.GeoJSONSource | undefined; if (!src) return; if (points.length < 2) { src.setData(EMPTY_GEOJSON); return; } // Show as polygon if 3+ points, otherwise as line const coords = [...points]; if (points.length >= 3) { // Close the ring for display const first = points[0]; if (first) coords.push(first); } const features: GeoJSON.Feature[] = []; if (points.length >= 3) { features.push({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [coords] }, }); } // Always draw the line features.push({ type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: coords }, }); src.setData({ type: "FeatureCollection", features }); } catch { /* noop */ } }, []); /* ---- Add terenuri features from a pixel bbox to selection ---- */ const addFeaturesFromBbox = useCallback((bbox: [maplibregl.PointLike, maplibregl.PointLike]) => { const map = mapRef.current; if (!map) return; try { const features = map.queryRenderedFeatures(bbox); for (const f of features) { const sl = f.sourceLayer ?? f.source ?? ""; if (sl !== SOURCES.terenuri) continue; const props = (f.properties ?? {}) as Record; const objectId = String(props.object_id ?? ""); if (!objectId) continue; selectedRef.current.set(objectId, { id: objectId, sourceLayer: sl, properties: props }); } updateSelectionFilter(); onSelectionChange?.(Array.from(selectedRef.current.values())); } catch { /* noop */ } }, [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) return; const mapping: Record = { uats: [ LAYER_IDS.uatsZ0Line, LAYER_IDS.uatsZ5Fill, LAYER_IDS.uatsZ5Line, LAYER_IDS.uatsZ8Fill, LAYER_IDS.uatsZ8Line, LAYER_IDS.uatsZ8Label, LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label, ], 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; for (const lid of layerIds) { try { map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none"); } catch { /* noop */ } } } }, []); useEffect(() => { if (mapReady && layerVisibility) applyLayerVisibility(layerVisibility); }, [mapReady, layerVisibility, applyLayerVisibility]); /* ---- Clean up draw state when selectionType changes ---- */ useEffect(() => { clearDrawState(); }, [selectionType, clearDrawState]); /* ---- Map initialization (recreates on basemap change) ---- */ useEffect(() => { if (!containerRef.current) return; const basemapDef = BASEMAPS[basemap]; const map = new maplibregl.Map({ container: containerRef.current, style: buildStyle(basemapDef), center: viewStateRef.current.center, zoom: viewStateRef.current.zoom, maxZoom: basemapDef.maxzoom ?? 20, }); mapRef.current = map; // 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"); /* ---- Add Martin sources + layers on load ---- */ map.on("load", () => { const m = resolvedMartinUrl; // Hide OpenFreeMap's built-in boundary/admin layers for (const layer of map.getStyle().layers) { const id = layer.id; // Match common OpenMapTiles boundary layer naming patterns if (/boundar|admin|border(?!.*water)|oneway|arrow/i.test(id)) { try { map.setLayoutProperty(layer.id, "visibility", "none"); } catch { /* noop */ } } // Also hide by source-layer (more reliable) const sl = ("source-layer" in layer && typeof layer["source-layer"] === "string") ? layer["source-layer"] : ""; if (sl === "boundary" || sl === "admin") { try { map.setLayoutProperty(layer.id, "visibility", "none"); } catch { /* noop */ } } } // === 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 === map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 }); 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 === map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 }); 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-font": ["Noto Sans Regular"], "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.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-font": ["Noto Sans Regular"], "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", "cadastral_ref"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false, "text-max-width": 8, }, paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } }); // === 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 } }); // === Selection highlight === map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13, filter: ["==", "object_id", "__NONE__"], 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 } }); // === Draw polygon source + layers (for freehand) === map.addSource(DRAW_SOURCE, { type: "geojson", data: EMPTY_GEOJSON }); map.addLayer({ id: LAYER_IDS.drawPolygonFill, type: "fill", source: DRAW_SOURCE, filter: ["==", "$type", "Polygon"], paint: { "fill-color": "#f59e0b", "fill-opacity": 0.2 }, }); map.addLayer({ id: LAYER_IDS.drawPolygonLine, type: "line", source: DRAW_SOURCE, paint: { "line-color": "#f59e0b", "line-width": 2, "line-dasharray": [3, 2] }, }); // HIDE ALL data layers immediately after creation const allLayerIds = [ ...Object.values(LAYER_IDS).filter(id => !id.includes("draw") && !id.includes("selection")), ]; for (const lid of allLayerIds) { try { map.setLayoutProperty(lid, "visibility", "none"); } catch { /* noop */ } } // Then show only the ones that should be visible if (layerVisibility) applyLayerVisibility(layerVisibility); setMapReady(true); }); /* ---- Click handler — NO popup, only callback ---- */ const clickableLayers = [ LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill, ]; map.on("click", (e) => { const mode = selectionTypeRef.current; // --- Freehand mode: clicks add points --- if (mode === "freehand") { const lngLat: [number, number] = [e.lngLat.lng, e.lngLat.lat]; freehandPointsRef.current.push(lngLat); updateDrawPolygon(freehandPointsRef.current); return; } // --- Rect mode: clicks are handled by mousedown/up, ignore normal clicks --- if (mode === "rect") return; const features = map.queryRenderedFeatures(e.point, { layers: clickableLayers.filter((l) => { try { return !!map.getLayer(l); } catch { return false; } }), }); if (features.length === 0) { onFeatureClick?.(null as unknown as ClickedFeature); // close panel return; } const first = features[0]; if (!first) return; const props = (first.properties ?? {}) as Record; const sourceLayer = first.sourceLayer ?? first.source ?? ""; // Click selection mode if (mode === "click" && sourceLayer === SOURCES.terenuri) { 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 }); } updateSelectionFilter(); onSelectionChange?.(Array.from(selectedRef.current.values())); return; } // Feature click — notify parent (no popup) — only when off or click mode on non-terenuri if (mode === "off") { onFeatureClick?.({ layerId: first.layer?.id ?? "", sourceLayer, properties: props, coordinates: [e.lngLat.lng, e.lngLat.lat], }); } }); /* ---- Double-click handler for freehand polygon completion ---- */ map.on("dblclick", (e) => { const mode = selectionTypeRef.current; if (mode !== "freehand") return; // Prevent default double-click zoom e.preventDefault(); const points = freehandPointsRef.current; if (points.length < 3) { // Not enough points, clear freehandPointsRef.current = []; updateDrawPolygon([]); return; } // Close polygon — compute pixel bbox of all points let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const pt of points) { const px = map.project(pt); if (px.x < minX) minX = px.x; if (px.y < minY) minY = px.y; if (px.x > maxX) maxX = px.x; if (px.y > maxY) maxY = px.y; } // Query all rendered features in the bbox try { const allFeatures = map.queryRenderedFeatures( [[minX, minY], [maxX, maxY]] as [maplibregl.PointLike, maplibregl.PointLike] ); // For each terenuri feature, check if center is inside the drawn polygon for (const f of allFeatures) { const sl = f.sourceLayer ?? f.source ?? ""; if (sl !== SOURCES.terenuri) continue; const props = (f.properties ?? {}) as Record; const objectId = String(props.object_id ?? ""); if (!objectId) continue; // Get feature center in lng/lat // Use the feature's geometry bbox center via queryRenderedFeatures pixel point // Since we're dealing with rendered features, use the feature geometry const geom = f.geometry; let centerLngLat: [number, number] | null = null; if (geom.type === "Polygon" || geom.type === "MultiPolygon") { // Compute centroid from coordinates const coords = geom.type === "Polygon" ? geom.coordinates[0] : geom.coordinates[0]?.[0]; if (coords && coords.length > 0) { let cx = 0, cy = 0; for (const c of coords) { const coord = c as [number, number]; cx += coord[0]; cy += coord[1]; } centerLngLat = [cx / coords.length, cy / coords.length]; } } else if (geom.type === "Point") { centerLngLat = geom.coordinates as [number, number]; } if (!centerLngLat) continue; if (pointInPolygon(centerLngLat, points)) { selectedRef.current.set(objectId, { id: objectId, sourceLayer: sl, properties: props }); } } updateSelectionFilter(); onSelectionChange?.(Array.from(selectedRef.current.values())); } catch { /* noop */ } // Clear draw polygon freehandPointsRef.current = []; updateDrawPolygon([]); }); /* ---- Rectangle selection: mousedown/mousemove/mouseup on canvas ---- */ const canvas = map.getCanvas(); const onRectMouseDown = (e: MouseEvent) => { if (selectionTypeRef.current !== "rect") return; // Only respond to left-click (button 0) if (e.button !== 0) return; rectStartRef.current = { x: e.offsetX, y: e.offsetY }; rectDrawingRef.current = true; // Disable map drag pan while drawing rectangle map.dragPan.disable(); // Create overlay div const container = containerRef.current; if (!container) return; const overlay = document.createElement("div"); overlay.style.position = "absolute"; overlay.style.pointerEvents = "none"; overlay.style.backgroundColor = "rgba(245, 158, 11, 0.2)"; overlay.style.border = "2px solid rgba(245, 158, 11, 0.8)"; overlay.style.left = `${e.offsetX}px`; overlay.style.top = `${e.offsetY}px`; overlay.style.width = "0px"; overlay.style.height = "0px"; overlay.style.zIndex = "10"; container.appendChild(overlay); rectOverlayRef.current = overlay; }; const onRectMouseMove = (e: MouseEvent) => { if (!rectDrawingRef.current || !rectStartRef.current || !rectOverlayRef.current) return; const start = rectStartRef.current; const overlay = rectOverlayRef.current; const x = Math.min(start.x, e.offsetX); const y = Math.min(start.y, e.offsetY); const w = Math.abs(e.offsetX - start.x); const h = Math.abs(e.offsetY - start.y); overlay.style.left = `${x}px`; overlay.style.top = `${y}px`; overlay.style.width = `${w}px`; overlay.style.height = `${h}px`; }; const onRectMouseUp = (e: MouseEvent) => { if (!rectDrawingRef.current || !rectStartRef.current) return; rectDrawingRef.current = false; const start = rectStartRef.current; rectStartRef.current = null; // Re-enable drag pan map.dragPan.enable(); // Remove overlay if (rectOverlayRef.current) { rectOverlayRef.current.remove(); rectOverlayRef.current = null; } // Compute pixel bbox const x1 = Math.min(start.x, e.offsetX); const y1 = Math.min(start.y, e.offsetY); const x2 = Math.max(start.x, e.offsetX); const y2 = Math.max(start.y, e.offsetY); // Minimum size check (avoid accidental tiny rectangles) if (Math.abs(x2 - x1) < 5 && Math.abs(y2 - y1) < 5) return; addFeaturesFromBbox([[x1, y1], [x2, y2]]); }; canvas.addEventListener("mousedown", onRectMouseDown); canvas.addEventListener("mousemove", onRectMouseMove); canvas.addEventListener("mouseup", onRectMouseUp); /* ---- ESC key exits selection mode ---- */ const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && selectionTypeRef.current !== "off") { onFeatureClick?.(null as unknown as ClickedFeature); clearDrawState(); // Notify parent to turn off selection onSelectionChange?.([]); } }; document.addEventListener("keydown", onKeyDown); /* ---- Right-click exits selection / cancels draw ---- */ map.on("contextmenu", (e) => { if (selectionTypeRef.current !== "off") { e.preventDefault(); clearDrawState(); } }); /* ---- Cursor change ---- */ for (const lid of clickableLayers) { map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", lid, () => { map.getCanvas().style.cursor = ""; }); } /* ---- Cleanup ---- */ return () => { document.removeEventListener("keydown", onKeyDown); canvas.removeEventListener("mousedown", onRectMouseDown); canvas.removeEventListener("mousemove", onRectMouseMove); canvas.removeEventListener("mouseup", onRectMouseUp); // Clean up any lingering overlay if (rectOverlayRef.current) { rectOverlayRef.current.remove(); rectOverlayRef.current = null; } map.remove(); mapRef.current = null; setMapReady(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [resolvedMartinUrl, basemap]); /* ---- Sync center/zoom prop changes (from search flyTo) ---- */ useEffect(() => { if (!mapReady || !mapRef.current || !center) return; mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 }); }, [center, zoom, mapReady]); /* ---- Disable double-click zoom when in freehand mode ---- */ useEffect(() => { const map = mapRef.current; if (!map) return; if (selectionType === "freehand") { map.doubleClickZoom.disable(); } else { map.doubleClickZoom.enable(); } }, [selectionType, mapReady]); return (
{!mapReady && (

Se incarca harta...

)}
); } );