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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 08:19:20 +02:00
parent 3a2262edd0
commit 800c45916e
2 changed files with 345 additions and 15 deletions
@@ -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}
+338 -8
View File
@@ -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<MapViewerHandle, MapViewerProps>(
{
center, zoom, martinUrl, className,
basemap = "liberty",
selectionMode = false,
selectionType = "off",
onFeatureClick, onSelectionChange, layerVisibility,
},
ref
@@ -130,13 +168,21 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
const selectionModeRef = useRef(selectionMode);
const selectionTypeRef = useRef<SelectionType>(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<HTMLDivElement | null>(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<MapViewerHandle, MapViewerProps>(
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<string, unknown>;
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<MapViewerHandle, MapViewerProps>(
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<MapViewerHandle, MapViewerProps>(
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<MapViewerHandle, MapViewerProps>(
];
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<MapViewerHandle, MapViewerProps>(
const props = (first.properties ?? {}) as Record<string, unknown>;
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<MapViewerHandle, MapViewerProps>(
return;
}
// Feature click — notify parent (no popup)
// 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<string, unknown>;
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<MapViewerHandle, MapViewerProps>(
/* ---- 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<MapViewerHandle, MapViewerProps>(
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 (
<div className={cn("absolute inset-0", className)}>
<div ref={containerRef} className="w-full h-full" />
<div ref={containerRef} className="relative w-full h-full" />
{!mapReady && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<p className="text-sm text-muted-foreground">Se incarca harta...</p>