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:
Zimin A.N.
2026-05-06 20:01:14 +03:00
parent 4fa96bffed
commit 05af8e1520
6 changed files with 405 additions and 8 deletions
+28 -5
View File
@@ -30,13 +30,33 @@ export const dictionaryDetailQuery = (name: string) =>
},
})
export const recordsQuery = (dictionaryName: string, scopeCsv: string) =>
export type RecordsFilter = {
/** CSV bbox: west,south,east,north */
bbox?: string
/** GeoJSON Polygon as raw object — serialized as JSON в query string. */
polygon?: GeoJSON.Polygon
}
export const recordsQuery = (
dictionaryName: string,
scopeCsv: string,
filter?: RecordsFilter,
) =>
queryOptions({
queryKey: ['records', dictionaryName, scopeCsv] as const,
queryKey: [
'records',
dictionaryName,
scopeCsv,
filter?.bbox ?? null,
filter?.polygon ? JSON.stringify(filter.polygon) : null,
] as const,
queryFn: async (): Promise<FlattenedRecord[]> => {
const params: Record<string, string> = { as_scope: scopeCsv }
if (filter?.bbox) params.bbox = filter.bbox
if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon)
const { data } = await apiClient.get<FlattenedRecord[]>(
`/${dictionaryName}/records`,
{ params: { as_scope: scopeCsv } },
{ params },
)
return data
},
@@ -235,8 +255,11 @@ export const useWebhookDlq = (page: number, size: number) =>
export const useDictionaries = () => useQuery(dictionariesQuery)
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
useQuery(recordsQuery(dictionaryName, scopeCsv))
export const useRecords = (
dictionaryName: string,
scopeCsv: string,
filter?: RecordsFilter,
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
export const useRecordRaw = (
dictionaryName: string,
businessKey: string | undefined,
@@ -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],
],
],
}
}
+20
View File
@@ -235,6 +235,16 @@ i18n
'error.failed': 'Не удалось загрузить данные',
'auth.login': 'Войти',
'auth.logout': 'Выйти',
'aoi.button': 'AOI фильтр',
'aoi.activeBbox': 'AOI: bbox {{value}}',
'aoi.activePolygon': 'AOI: полигон ({{points}} точек)',
'aoi.clear': 'Сбросить AOI',
'aoi.dialog.title': 'Выбор области интереса (AOI)',
'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.',
'aoi.dialog.apply': 'Применить',
'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса',
'aoi.error.noShape': 'Сначала нарисуйте область на карте',
'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать',
},
},
'en-US': {
@@ -460,6 +470,16 @@ i18n
'error.failed': 'Failed to load data',
'auth.login': 'Sign in',
'auth.logout': 'Sign out',
'aoi.button': 'AOI filter',
'aoi.activeBbox': 'AOI: bbox {{value}}',
'aoi.activePolygon': 'AOI: polygon ({{points}} points)',
'aoi.clear': 'Clear AOI',
'aoi.dialog.title': 'Select Area of Interest (AOI)',
'aoi.dialog.help': 'Draw a rectangle or polygon. Applied as filter to records with spatial geometry.',
'aoi.dialog.apply': 'Apply',
'aoi.dialog.mapAriaLabel': 'Map for selecting area of interest',
'aoi.error.noShape': 'Draw an area on the map first',
'aoi.error.invalid': 'Invalid geometry — please redraw',
},
},
},
@@ -20,13 +20,14 @@ import {
TableRow,
TextInput,
} from '@nstart/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon } from '@phosphor-icons/react'
import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { nowIsoLocal } from '@/lib/dates'
import { SCOPE_BORDER_TOP, SCOPE_DOT } from '@/lib/scope-style'
@@ -44,7 +45,13 @@ function DictionaryDetail() {
const { name } = Route.useParams()
const { t } = useTranslation()
const detailQuery = useDictionaryDetail(name)
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED')
const [aoi, setAoi] = useState<AoiResult | null>(null)
const filter: RecordsFilter | undefined = aoi
? aoi.kind === 'bbox'
? { bbox: aoi.bboxCsv }
: { polygon: aoi.geojson }
: undefined
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name)
const closeMut = useCloseRecord(name)
@@ -53,6 +60,7 @@ function DictionaryDetail() {
const [closeReason, setCloseReason] = useState('')
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
const [aoiOpen, setAoiOpen] = useState(false)
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
const rawRecordQuery = useRecordRaw(name, editingKey)
@@ -124,6 +132,14 @@ function DictionaryDetail() {
}
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
disabled={!detailQuery.data}
onClick={() => setAoiOpen(true)}
>
{t('aoi.button')}
</Button>
<Button
variant="secondary"
leftIcon={<GearIcon weight="bold" size={16} />}
@@ -144,6 +160,25 @@ function DictionaryDetail() {
}
/>
{aoi && (
<div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
<span className="font-mono text-xs text-carbon/80">
{aoi.kind === 'bbox'
? t('aoi.activeBbox', { value: aoi.bboxCsv })
: t('aoi.activePolygon', {
points: aoi.geojson.coordinates[0]?.length ?? 0,
})}
</span>
<button
type="button"
className="text-xs text-ultramarain hover:underline"
onClick={() => setAoi(null)}
>
{t('aoi.clear')}
</button>
</div>
)}
{recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}
{recordsResult.error && (
@@ -340,6 +375,13 @@ function DictionaryDetail() {
dictionaryName={name}
businessKey={historyKey}
/>
<AoiPickerDialog
isOpen={aoiOpen}
onClose={() => setAoiOpen(false)}
onApply={(result) => setAoi(result)}
initial={aoi?.geojson ?? null}
/>
</div>
)
}