From 800c45916e54a2fec42af436f087bbef59dc6f23 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 24 Mar 2026 08:19:20 +0200 Subject: [PATCH] feat(geoportal): rectangle + freehand polygon selection drawing on map Rectangle mode (Dreptunghi): - Mousedown starts drawing, mousemove shows amber overlay, mouseup selects - All terenuri features in the drawn bbox are added to selection - Map panning disabled during draw, re-enabled after - Minimum 5px size to prevent accidental micro-selections Freehand mode (Desen): - Each click adds a point, polygon drawn with GeoJSON source - Double-click closes polygon, selects features whose centroid is inside - Ray-casting point-in-polygon algorithm for spatial filtering - Double-click zoom disabled during freehand mode Draw state clears when switching selection modes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../geoportal/components/geoportal-module.tsx | 2 +- .../geoportal/components/map-viewer.tsx | 358 +++++++++++++++++- 2 files changed, 345 insertions(+), 15 deletions(-) diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx index cbc4cac..8c37d8d 100644 --- a/src/modules/geoportal/components/geoportal-module.tsx +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -63,7 +63,7 @@ export function GeoportalModule() { ref={mapHandleRef} className="h-full w-full" basemap={basemap} - selectionMode={selectionMode !== "off"} + selectionType={selectionMode} onFeatureClick={handleFeatureClick} onSelectionChange={setSelectedFeatures} layerVisibility={layerVisibility} diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx index 6f34fb2..3d8e93d 100644 --- a/src/modules/geoportal/components/map-viewer.tsx +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -17,6 +17,12 @@ if (typeof document !== "undefined") { } import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types"; +/* ------------------------------------------------------------------ */ +/* Selection type (re-exported for convenience) */ +/* ------------------------------------------------------------------ */ + +export type SelectionType = "off" | "click" | "rect" | "freehand"; + /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ @@ -54,8 +60,12 @@ const LAYER_IDS = { 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 } @@ -90,6 +100,34 @@ function buildStyle(def: BasemapDef): string | maplibregl.StyleSpecification { }; } +/* ------------------------------------------------------------------ */ +/* 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 */ /* ------------------------------------------------------------------ */ @@ -107,7 +145,7 @@ type MapViewerProps = { martinUrl?: string; className?: string; basemap?: BasemapId; - selectionMode?: boolean; + selectionType?: SelectionType; onFeatureClick?: (feature: ClickedFeature) => void; onSelectionChange?: (features: SelectedFeature[]) => void; layerVisibility?: LayerVisibility; @@ -122,7 +160,7 @@ export const MapViewer = forwardRef( { center, zoom, martinUrl, className, basemap = "liberty", - selectionMode = false, + selectionType = "off", onFeatureClick, onSelectionChange, layerVisibility, }, ref @@ -130,13 +168,21 @@ export const MapViewer = forwardRef( const containerRef = useRef(null); const mapRef = useRef(null); const selectedRef = useRef>(new Map()); - const selectionModeRef = useRef(selectionMode); + 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 }); - selectionModeRef.current = selectionMode; + // --- 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; @@ -166,6 +212,84 @@ export const MapViewer = forwardRef( 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, @@ -203,6 +327,11 @@ export const MapViewer = forwardRef( 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; @@ -302,6 +431,18 @@ export const MapViewer = forwardRef( 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] }, + }); + if (layerVisibility) applyLayerVisibility(layerVisibility); setMapReady(true); }); @@ -313,6 +454,19 @@ export const MapViewer = forwardRef( ]; 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; } }), }); @@ -328,8 +482,8 @@ export const MapViewer = forwardRef( const props = (first.properties ?? {}) as Record; const sourceLayer = first.sourceLayer ?? first.source ?? ""; - // Selection mode - if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) { + // Click selection mode + if (mode === "click" && sourceLayer === SOURCES.terenuri) { const objectId = String(props.object_id ?? ""); if (!objectId) return; if (selectedRef.current.has(objectId)) { @@ -342,15 +496,172 @@ export const MapViewer = forwardRef( return; } - // Feature click — notify parent (no popup) - onFeatureClick?.({ - layerId: first.layer?.id ?? "", - sourceLayer, - properties: props, - coordinates: [e.lngLat.lng, e.lngLat.lat], - }); + // 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); + /* ---- Cursor change ---- */ for (const lid of clickableLayers) { map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; }); @@ -359,6 +670,14 @@ export const MapViewer = forwardRef( /* ---- Cleanup ---- */ return () => { + 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); @@ -372,9 +691,20 @@ export const MapViewer = forwardRef( 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...