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:
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
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<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>
|
||||
|
||||
Reference in New Issue
Block a user