import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert, Button, FormActions, Modal } from '@/ui' import L from 'leaflet' import 'leaflet/dist/leaflet.css' /** * Результат AOI picker'а: * - bbox: `west,south,east,north` (CSV для ?bbox= query) * - polygon: GeoJSON Polygon (для ?polygon= query) * * Match'ит API ordinis-rest-api: `BoundingBox.parse` для bbox CSV, * `GeoJsonPolygon.parse` для polygon JSON. Server validates lon/lat * range, anti-meridian, polygon ring closure. */ export type AoiResult = | { kind: 'bbox'; bboxCsv: string; geojson: GeoJSON.Polygon } | { kind: 'polygon'; bboxCsv: string; geojson: GeoJSON.Polygon } type Props = { isOpen: boolean onClose: () => void onApply: (result: AoiResult) => void /** Optional initial geometry to render and edit. */ initial?: GeoJSON.Polygon | null } type DrawMode = 'idle' | 'rectangle' | 'polygon' /** * AOI picker на Leaflet с custom draw logic (rectangle + polygon). * * Используем свою draw-implementation вместо `leaflet-draw` 1.0.4 — * последний release lib'ы был в 2017, плохо совместим с Leaflet 1.8+: * `_onMouseDown` сразу триггерит `_onMouseUp` на rectangle drag, и * прямоугольник схлопывается в точку. Custom logic — ~80 строк, полный * контроль, без сломанной зависимости. * * Rectangle: mousedown → mousemove preview → mouseup finalize. * Polygon: click добавляет вершину, dblclick закрывает (≥3 точки). */ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => { const { t } = useTranslation() const mapDivRef = useRef(null) const mapRef = useRef(null) const drawnRef = useRef(null) const [shape, setShape] = useState(null) const [error, setError] = useState(null) const [drawMode, setDrawMode] = useState('idle') const drawModeRef = useRef('idle') drawModeRef.current = drawMode // Init map после mount + isOpen=true. RAF poll до non-zero size. useEffect(() => { if (!isOpen) return let cancelled = false let ro: ResizeObserver | null = null let rafId = 0 const tryInit = () => { if (cancelled) return if (mapRef.current) return const div = mapDivRef.current if (!div || div.clientWidth === 0 || div.clientHeight === 0) { if (rafId < 100) { rafId += 1 requestAnimationFrame(tryInit) } return } const map = L.map(div, { zoomControl: true, attributionControl: false }) .setView([60, 90], 3) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, }).addTo(map) const drawn = new L.FeatureGroup().addTo(map) drawnRef.current = drawn if (initial && initial.coordinates?.[0]?.length >= 4) { const ring = initial.coordinates[0].map(([lng, lat]) => L.latLng(lat, lng)) const poly = L.polygon(ring, { color: '#1d4ed8', weight: 2 }) drawn.addLayer(poly) setShape(poly) map.fitBounds(poly.getBounds(), { padding: [20, 20] }) } attachDrawHandlers(map, drawn, drawModeRef, (newShape) => { setShape(newShape) setError(null) setDrawMode('idle') }) mapRef.current = map ro = new ResizeObserver(() => { if (mapRef.current) mapRef.current.invalidateSize() }) ro.observe(div) } tryInit() return () => { cancelled = true if (ro) ro.disconnect() } }, [isOpen, initial]) // Cursor + map drag state в зависимости от drawMode. useEffect(() => { const map = mapRef.current if (!map) return const container = map.getContainer() if (drawMode === 'idle') { container.style.cursor = '' map.dragging.enable() } else { container.style.cursor = 'crosshair' // Disable map drag в rectangle mode чтобы mousedown+drag рисовал, не панил. if (drawMode === 'rectangle') map.dragging.disable() else map.dragging.enable() } }, [drawMode]) // Cleanup при close. useEffect(() => { if (isOpen) return if (mapRef.current) { mapRef.current.remove() mapRef.current = null drawnRef.current = null setShape(null) setError(null) setDrawMode('idle') } }, [isOpen]) const handleClear = () => { drawnRef.current?.clearLayers() setShape(null) setError(null) } const handleApply = () => { if (!shape) { setError(t('aoi.error.noShape')) return } const result = layerToResult(shape) if (!result) { setError(t('aoi.error.invalid')) return } onApply(result) onClose() } const toolbarBtn = (mode: DrawMode, label: string) => { const active = drawMode === mode return ( ) } return (

{t('aoi.dialog.help')}

{toolbarBtn('rectangle', t('aoi.dialog.drawRectangle'))} {toolbarBtn('polygon', t('aoi.dialog.drawPolygon'))} {drawMode === 'polygon' && ( {t('aoi.dialog.polygonHint')} )}
{error && ( {error} )} ) } /** * Custom draw handlers: подписываемся на map events, рисуем в зависимости * от текущего drawMode (читаемого через ref чтобы не пересоздавать * listeners на каждое изменение state). */ const attachDrawHandlers = ( map: L.Map, drawn: L.FeatureGroup, modeRef: React.RefObject, onComplete: (layer: L.Layer) => void, ) => { const STYLE = { color: '#1d4ed8', weight: 2 } // Rectangle: mousedown→start, mousemove→preview, mouseup→finalize. let rectStart: L.LatLng | null = null let rectPreview: L.Rectangle | null = null map.on('mousedown', (e) => { if (modeRef.current !== 'rectangle') return rectStart = (e as L.LeafletMouseEvent).latlng }) map.on('mousemove', (e) => { if (modeRef.current !== 'rectangle' || !rectStart) return const cur = (e as L.LeafletMouseEvent).latlng if (rectPreview) { rectPreview.setBounds(L.latLngBounds(rectStart, cur)) } else { rectPreview = L.rectangle(L.latLngBounds(rectStart, cur), STYLE).addTo(drawn) } }) map.on('mouseup', () => { if (modeRef.current !== 'rectangle' || !rectStart) return if (rectPreview) { // Finalize: replace previous shape с новым rectangle. const finalRect = rectPreview drawn.clearLayers() drawn.addLayer(finalRect) onComplete(finalRect) } rectStart = null rectPreview = null }) // Polygon: click→add vertex, dblclick→close, mousemove→preview line. let polyPoints: L.LatLng[] = [] let polyPreview: L.Polyline | null = null let polyMouseLine: L.Polyline | null = null map.on('click', (e) => { if (modeRef.current !== 'polygon') return const pt = (e as L.LeafletMouseEvent).latlng polyPoints.push(pt) if (polyPreview) { polyPreview.setLatLngs(polyPoints) } else { polyPreview = L.polyline(polyPoints, STYLE).addTo(drawn) } }) map.on('mousemove', (e) => { if (modeRef.current !== 'polygon' || polyPoints.length === 0) return const cur = (e as L.LeafletMouseEvent).latlng const last = polyPoints[polyPoints.length - 1] if (polyMouseLine) { polyMouseLine.setLatLngs([last, cur]) } else { polyMouseLine = L.polyline([last, cur], { ...STYLE, dashArray: '4 4' }).addTo(drawn) } }) map.on('dblclick', () => { if (modeRef.current !== 'polygon') return if (polyPoints.length < 3) return // Finalize: replace с финальным polygon. const finalPoly = L.polygon(polyPoints, STYLE) drawn.clearLayers() drawn.addLayer(finalPoly) onComplete(finalPoly) polyPoints = [] polyPreview = null polyMouseLine = null }) // Disable Leaflet's default zoom-on-doubleclick — конфликтует с polygon close. map.doubleClickZoom.disable() } /** * Конвертирует Leaflet layer (rectangle / polygon) в bbox CSV + * GeoJSON Polygon. Возвращает null если geometry invalid. */ const layerToResult = (layer: L.Layer): AoiResult | null => { if (layer instanceof L.Rectangle) { const b = layer.getBounds() const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}` return { kind: 'bbox', bboxCsv: bbox, geojson: rectangleToGeoJSON(b) } } if (layer instanceof L.Polygon) { const latlngsRaw = layer.getLatLngs() const ring = (Array.isArray(latlngsRaw[0]) ? latlngsRaw[0] : latlngsRaw) as L.LatLng[] if (!ring || ring.length < 3) return null const closed = [...ring, ring[0]] const coords = closed.map((p) => [p.lng, p.lat] as [number, number]) const b = layer.getBounds() const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}` return { kind: 'polygon', bboxCsv: bbox, geojson: { type: 'Polygon', coordinates: [coords] }, } } return null } const rectangleToGeoJSON = (b: L.LatLngBounds): GeoJSON.Polygon => { const w = b.getWest() const s = b.getSouth() const e = b.getEast() const n = b.getNorth() return { type: 'Polygon', coordinates: [ [ [w, s], [e, s], [e, n], [w, n], [w, s], ], ], } }