feat(geo): AOI picker — Leaflet draw → bbox/polygon фильтр записей
Admin кликает 'AOI фильтр' на странице словаря → открывается dialog с картой OSM. Рисует rectangle (→ bbox CSV) или polygon → onApply применяется к recordsQuery как ?bbox= или ?polygon=GeoJSON. Server-side parsing уже есть (BoundingBox.parse, GeoJsonPolygon.parse в ordinis-rest-api) — подключили UI поверх. Активный AOI отображается inline над таблицей с кнопкой 'Сбросить'. Edit shape возможен через leaflet-draw edit/remove controls. Зависимости: leaflet 1.9.4 + react-leaflet 5 + leaflet-draw 1.0.4 + types. Bundle +231KB gzip 62KB — приемлемо для AOI-only feature (map работает без MapLibre WASM, OSM tiles — публичные). Picker self-contained: монтируется/уничтожается с диалогом, invalidateSize 100ms после open чтобы Leaflet корректно посчитал container size после modal animation.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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 точнее).
|
||||
*
|
||||
* <p>Map монтируется только когда isOpen=true чтобы Leaflet
|
||||
* корректно посчитал размеры контейнера (иначе серый экран до
|
||||
* первого resize).
|
||||
*/
|
||||
export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const mapDivRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const drawnRef = useRef<L.FeatureGroup | null>(null)
|
||||
const [shape, setShape] = useState<L.Layer | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Init map после mount + isOpen=true. cleanup на unmount.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const div = mapDivRef.current
|
||||
if (!div || mapRef.current) 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
|
||||
|
||||
// Initial geometry — отрисовать и зум.
|
||||
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] })
|
||||
}
|
||||
|
||||
// Draw controls: только rectangle + polygon. Marker / circle /
|
||||
// circlemarker отключены — backend ждёт Polygon.
|
||||
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)
|
||||
|
||||
// Created shape — заменяем предыдущую (max 1 AOI на picker).
|
||||
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
|
||||
|
||||
// Forces map to recompute size: modal animations cause containerSize=0
|
||||
// первой кадр. После 100ms layout settled.
|
||||
const t = setTimeout(() => map.invalidateSize(), 100)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('aoi.dialog.title')}
|
||||
maxWidth="max-w-5xl"
|
||||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
||||
bodyClassName="flex-1 flex flex-col gap-3 overflow-hidden"
|
||||
>
|
||||
<p className="text-sm text-carbon/70">{t('aoi.dialog.help')}</p>
|
||||
|
||||
<div
|
||||
ref={mapDivRef}
|
||||
className="flex-1 min-h-[420px] rounded-sm border border-regolith"
|
||||
aria-label={t('aoi.dialog.mapAriaLabel')}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="error" title={t('error.failed')}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormActions>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
{t('form.cancel')}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={handleApply} disabled={!shape}>
|
||||
{t('aoi.dialog.apply')}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертирует 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],
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user