8acafe958b
Freehand drawing fix: - Disable dragPan when in freehand mode (was only disabling dblclick zoom). Without this, clicks were interpreted as pan gestures. - Re-enable dragPan when exiting freehand mode. Click highlight: - Clicking a parcel in "off" mode now highlights it with the selection layer (amber fill + orange outline). Clicking empty space clears it. - Provides visual feedback for which parcel was clicked. Mobile toolbar: - Moved selection toolbar higher (bottom-12 on mobile) with z-20 to ensure it's above MapLibre attribution bar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
777 lines
32 KiB
TypeScript
777 lines
32 KiB
TypeScript
"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<BasemapId, BasemapDef> = {
|
|
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: '© <a href="https://www.esri.com">Esri</a>, 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: '© <a href="https://ancpi.ro">ANCPI</a> 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<MapViewerHandle, MapViewerProps>(
|
|
function MapViewer(
|
|
{
|
|
center, zoom, martinUrl, className,
|
|
basemap = "liberty",
|
|
selectionType = "off",
|
|
onFeatureClick, onSelectionChange, layerVisibility,
|
|
},
|
|
ref
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
|
const selectedRef = useRef<Map<string, SelectedFeature>>(new Map());
|
|
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 });
|
|
|
|
// --- 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;
|
|
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<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,
|
|
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<string, string[]> = {
|
|
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) {
|
|
// Clear highlight when clicking empty space
|
|
try {
|
|
map.setFilter(LAYER_IDS.selectionFill, ["==", "object_id", "__NONE__"]);
|
|
map.setFilter(LAYER_IDS.selectionLine, ["==", "object_id", "__NONE__"]);
|
|
} catch { /* noop */ }
|
|
onFeatureClick?.(null as unknown as ClickedFeature); // close panel
|
|
return;
|
|
}
|
|
|
|
const first = features[0];
|
|
if (!first) return;
|
|
|
|
const props = (first.properties ?? {}) as Record<string, unknown>;
|
|
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
|
|
if (mode === "off") {
|
|
// Highlight clicked parcel with selection layer
|
|
const objectId = String(props.object_id ?? "");
|
|
if (objectId && sourceLayer === SOURCES.terenuri) {
|
|
try {
|
|
map.setFilter(LAYER_IDS.selectionFill, ["==", "object_id", objectId]);
|
|
map.setFilter(LAYER_IDS.selectionLine, ["==", "object_id", objectId]);
|
|
} catch { /* noop */ }
|
|
}
|
|
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);
|
|
|
|
/* ---- 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 interactions when in drawing modes ---- */
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
if (selectionType === "freehand") {
|
|
map.doubleClickZoom.disable();
|
|
map.dragPan.disable();
|
|
} else if (selectionType === "rect") {
|
|
// rect handles dragPan itself in mousedown/mouseup
|
|
} else {
|
|
map.doubleClickZoom.enable();
|
|
map.dragPan.enable();
|
|
}
|
|
}, [selectionType, mapReady]);
|
|
|
|
return (
|
|
<div className={cn("absolute inset-0", className)}>
|
|
<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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
);
|