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],
],
],
}
}