import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert, Button, FormActions, Modal } from '@nstart/ui' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import 'leaflet-draw' import 'leaflet-draw/dist/leaflet.draw.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 } /** * AOI picker на Leaflet с draw controls (rectangle + polygon). * * Admin рисует bbox или произвольный полигон на OSM tile'ах → * onApply получает CSV bbox и GeoJSON polygon одновременно. Caller * выбирает что использовать для filter (bbox дешевле для server, * polygon точнее). * *

Map монтируется только когда isOpen=true чтобы Leaflet * корректно посчитал размеры контейнера (иначе серый экран до * первого resize). */ 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) // Init map после mount + isOpen=true. Defer'им init до того как div // получит non-zero clientWidth/Height — иначе Leaflet вычисляет tile // grid от 0×0 и карта остаётся пустой даже после `invalidateSize` // (некоторые internal collections не recompute'ятся after init). // // Modal от @nstart/ui использует display animation, RAF poll'им до // первого размера. После init — ResizeObserver на window resize. useEffect(() => { if (!isOpen) return let cancelled = false let ro: ResizeObserver | null = null let rafId = 0 const tryInit = () => { if (cancelled) return if (mapRef.current) return // уже init'нут const div = mapDivRef.current // Modal от @nstart/ui рендерит children только после useAnimatedPresence // флипает isMounted=true (на след. рендер после isOpen=true). Наш useEffect // фаерится РАНЬШЕ — div ещё не в DOM. Plus modal transform animation // изначально даёт 0×0 размер. Poll'им RAF до тех пор пока: // - div существует (ref populated на render когда modal mounted'нул его) // - clientWidth и clientHeight > 0 (после layout & transform settled) // Cap на 100 кадров (~1.6s) — fallback если что-то совсем странное. if (!div || div.clientWidth === 0 || div.clientHeight === 0) { if (rafId < 100) { rafId += 1 requestAnimationFrame(tryInit) } return } // Россия по умолчанию: центр ~Урал, zoom видит всю страну. const map = L.map(div, { zoomControl: true }).setView([60, 90], 3) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', 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] }) } const drawControl = new L.Control.Draw({ draw: { rectangle: { shapeOptions: { color: '#1d4ed8', weight: 2 } }, polygon: { shapeOptions: { color: '#1d4ed8', weight: 2 }, allowIntersection: false, showArea: false, }, marker: false, circle: false, circlemarker: false, polyline: false, }, edit: { featureGroup: drawn, edit: { selectedPathOptions: { color: '#dc2626' } }, remove: true, }, }) map.addControl(drawControl) map.on(L.Draw.Event.CREATED, (e) => { const event = e as L.LeafletEvent & { layerType: string; layer: L.Layer } drawn.clearLayers() drawn.addLayer(event.layer) setShape(event.layer) setError(null) }) map.on(L.Draw.Event.DELETED, () => setShape(null)) map.on(L.Draw.Event.EDITED, () => { const layers = drawn.getLayers() setShape(layers[0] ?? null) }) mapRef.current = map // Window resize / dev tools / responsive layout — invalidate. ro = new ResizeObserver(() => { if (mapRef.current) mapRef.current.invalidateSize() }) ro.observe(div) } tryInit() return () => { cancelled = true if (ro) ro.disconnect() } }, [isOpen, initial]) // Cleanup при close: уничтожить map чтобы re-open был чистый старт. useEffect(() => { if (isOpen) return if (mapRef.current) { mapRef.current.remove() mapRef.current = null drawnRef.current = null setShape(null) setError(null) } }, [isOpen]) 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() } return (

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

{error && ( {error} )} ) } /** * Конвертирует Leaflet layer (rectangle / polygon) в bbox CSV + * GeoJSON Polygon. Возвращает null если geometry invalid. * * Для rectangle — bbox-режим (server использует ST_MakeEnvelope, * быстрее polygon ST_Intersects), но GeoJSON тоже даём для * обратной совместимости / preview. */ 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() // Polygon.getLatLngs() returns LatLng[][] для simple polygon. 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], ], ], } }