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}
|
ref={mapHandleRef}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
basemap={basemap}
|
basemap={basemap}
|
||||||
selectionMode={selectionMode !== "off"}
|
selectionType={selectionMode}
|
||||||
onFeatureClick={handleFeatureClick}
|
onFeatureClick={handleFeatureClick}
|
||||||
onSelectionChange={setSelectedFeatures}
|
onSelectionChange={setSelectedFeatures}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ if (typeof document !== "undefined") {
|
|||||||
}
|
}
|
||||||
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Selection type (re-exported for convenience) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type SelectionType = "off" | "click" | "rect" | "freehand";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* Constants */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -54,8 +60,12 @@ const LAYER_IDS = {
|
|||||||
cladiriLine: "l-cladiri-line",
|
cladiriLine: "l-cladiri-line",
|
||||||
selectionFill: "l-selection-fill",
|
selectionFill: "l-selection-fill",
|
||||||
selectionLine: "l-selection-line",
|
selectionLine: "l-selection-line",
|
||||||
|
drawPolygonFill: "l-draw-polygon-fill",
|
||||||
|
drawPolygonLine: "l-draw-polygon-line",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const DRAW_SOURCE = "draw-polygon";
|
||||||
|
|
||||||
/* ---- Basemap definitions ---- */
|
/* ---- Basemap definitions ---- */
|
||||||
type BasemapDef =
|
type BasemapDef =
|
||||||
| { type: "style"; url: string; maxzoom?: number }
|
| { 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 */
|
/* Props & Handle */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -107,7 +145,7 @@ type MapViewerProps = {
|
|||||||
martinUrl?: string;
|
martinUrl?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
basemap?: BasemapId;
|
basemap?: BasemapId;
|
||||||
selectionMode?: boolean;
|
selectionType?: SelectionType;
|
||||||
onFeatureClick?: (feature: ClickedFeature) => void;
|
onFeatureClick?: (feature: ClickedFeature) => void;
|
||||||
onSelectionChange?: (features: SelectedFeature[]) => void;
|
onSelectionChange?: (features: SelectedFeature[]) => void;
|
||||||
layerVisibility?: LayerVisibility;
|
layerVisibility?: LayerVisibility;
|
||||||
@@ -122,7 +160,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
{
|
{
|
||||||
center, zoom, martinUrl, className,
|
center, zoom, martinUrl, className,
|
||||||
basemap = "liberty",
|
basemap = "liberty",
|
||||||
selectionMode = false,
|
selectionType = "off",
|
||||||
onFeatureClick, onSelectionChange, layerVisibility,
|
onFeatureClick, onSelectionChange, layerVisibility,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -130,13 +168,21 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
|
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
|
||||||
const selectionModeRef = useRef(selectionMode);
|
const selectionTypeRef = useRef<SelectionType>(selectionType);
|
||||||
const [mapReady, setMapReady] = useState(false);
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
// Persist view state across basemap switches
|
// Persist view state across basemap switches
|
||||||
const viewStateRef = useRef({ center: center ?? DEFAULT_CENTER, zoom: zoom ?? DEFAULT_ZOOM });
|
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 resolvedMartinUrl = (() => {
|
||||||
const raw = martinUrl ?? DEFAULT_MARTIN_URL;
|
const raw = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||||
@@ -166,6 +212,84 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
onSelectionChange?.([]);
|
onSelectionChange?.([]);
|
||||||
}, [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<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 ---- */
|
/* ---- Imperative handle ---- */
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
getMap: () => mapRef.current,
|
getMap: () => mapRef.current,
|
||||||
@@ -203,6 +327,11 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
if (mapReady && layerVisibility) applyLayerVisibility(layerVisibility);
|
if (mapReady && layerVisibility) applyLayerVisibility(layerVisibility);
|
||||||
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
||||||
|
|
||||||
|
/* ---- Clean up draw state when selectionType changes ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
clearDrawState();
|
||||||
|
}, [selectionType, clearDrawState]);
|
||||||
|
|
||||||
/* ---- Map initialization (recreates on basemap change) ---- */
|
/* ---- Map initialization (recreates on basemap change) ---- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -302,6 +431,18 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
filter: ["==", "object_id", "__NONE__"],
|
filter: ["==", "object_id", "__NONE__"],
|
||||||
paint: { "line-color": "#d97706", "line-width": 2.5 } });
|
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);
|
if (layerVisibility) applyLayerVisibility(layerVisibility);
|
||||||
setMapReady(true);
|
setMapReady(true);
|
||||||
});
|
});
|
||||||
@@ -313,6 +454,19 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
];
|
];
|
||||||
|
|
||||||
map.on("click", (e) => {
|
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, {
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
layers: clickableLayers.filter((l) => { try { return !!map.getLayer(l); } catch { return false; } }),
|
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 props = (first.properties ?? {}) as Record<string, unknown>;
|
||||||
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
||||||
|
|
||||||
// Selection mode
|
// Click selection mode
|
||||||
if (selectionModeRef.current && sourceLayer === SOURCES.terenuri) {
|
if (mode === "click" && sourceLayer === SOURCES.terenuri) {
|
||||||
const objectId = String(props.object_id ?? "");
|
const objectId = String(props.object_id ?? "");
|
||||||
if (!objectId) return;
|
if (!objectId) return;
|
||||||
if (selectedRef.current.has(objectId)) {
|
if (selectedRef.current.has(objectId)) {
|
||||||
@@ -342,15 +496,172 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
return;
|
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?.({
|
onFeatureClick?.({
|
||||||
layerId: first.layer?.id ?? "",
|
layerId: first.layer?.id ?? "",
|
||||||
sourceLayer,
|
sourceLayer,
|
||||||
properties: props,
|
properties: props,
|
||||||
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
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 ---- */
|
/* ---- Cursor change ---- */
|
||||||
for (const lid of clickableLayers) {
|
for (const lid of clickableLayers) {
|
||||||
map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; });
|
map.on("mouseenter", lid, () => { map.getCanvas().style.cursor = "pointer"; });
|
||||||
@@ -359,6 +670,14 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
|
|
||||||
/* ---- Cleanup ---- */
|
/* ---- Cleanup ---- */
|
||||||
return () => {
|
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();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
setMapReady(false);
|
setMapReady(false);
|
||||||
@@ -372,9 +691,20 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
|||||||
mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
|
mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
|
||||||
}, [center, zoom, mapReady]);
|
}, [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 (
|
return (
|
||||||
<div className={cn("absolute inset-0", className)}>
|
<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 && (
|
{!mapReady && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||||
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user