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:
@@ -21,11 +21,14 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
"i18next-browser-languagedetector": "^8.0.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet-draw": "^1.0.4",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-oidc-context": "^3.3.1"
|
"react-oidc-context": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -34,6 +37,8 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/leaflet-draw": "^1.0.13",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
Generated
+71
@@ -38,6 +38,12 @@ importers:
|
|||||||
i18next-browser-languagedetector:
|
i18next-browser-languagedetector:
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.2.1
|
version: 8.2.1
|
||||||
|
leaflet:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
|
leaflet-draw:
|
||||||
|
specifier: ^1.0.4
|
||||||
|
version: 1.0.4
|
||||||
oidc-client-ts:
|
oidc-client-ts:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -53,6 +59,9 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.2
|
specifier: ^17.0.2
|
||||||
version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
|
version: 17.0.6(i18next@26.0.8(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
|
||||||
|
react-leaflet:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
react-oidc-context:
|
react-oidc-context:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5)
|
version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.5)
|
||||||
@@ -72,6 +81,12 @@ importers:
|
|||||||
'@testing-library/user-event':
|
'@testing-library/user-event':
|
||||||
specifier: ^14.6.1
|
specifier: ^14.6.1
|
||||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
|
'@types/leaflet':
|
||||||
|
specifier: ^1.9.21
|
||||||
|
version: 1.9.21
|
||||||
|
'@types/leaflet-draw':
|
||||||
|
specifier: ^1.0.13
|
||||||
|
version: 1.0.13
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
@@ -596,6 +611,13 @@ packages:
|
|||||||
react: '>= 16.8'
|
react: '>= 16.8'
|
||||||
react-dom: '>= 16.8'
|
react-dom: '>= 16.8'
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0':
|
||||||
|
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -950,6 +972,15 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
|
'@types/leaflet-draw@1.0.13':
|
||||||
|
resolution: {integrity: sha512-YU82kilOaU+wPNbqKCCDfHH3hqepN6XilrBwG/mSeZ/z4ewumaRCOah44s3FMxSu/Aa0SVa3PPJvhIZDUA09mw==}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1360,6 +1391,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
leaflet-draw@1.0.4:
|
||||||
|
resolution: {integrity: sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==}
|
||||||
|
|
||||||
|
leaflet@1.9.4:
|
||||||
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -1573,6 +1610,13 @@ packages:
|
|||||||
react-is@17.0.2:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0:
|
||||||
|
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
react-oidc-context@3.3.1:
|
react-oidc-context@3.3.1:
|
||||||
resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==}
|
resolution: {integrity: sha512-/Azvm9W4DhhOtSDBE73kFInh1b6zZRRfILKbgmk2syExMF0PCYJOn/dGdOOi2BFX8x0rCeUe45NXHU+/+xDcrQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2230,6 +2274,12 @@ snapshots:
|
|||||||
react: 19.2.5
|
react: 19.2.5
|
||||||
react-dom: 19.2.5(react@19.2.5)
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||||
|
dependencies:
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.2':
|
'@rollup/rollup-android-arm-eabi@4.60.2':
|
||||||
@@ -2529,6 +2579,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
|
'@types/leaflet-draw@1.0.13':
|
||||||
|
dependencies:
|
||||||
|
'@types/leaflet': 1.9.21
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@@ -2974,6 +3034,10 @@ snapshots:
|
|||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
|
leaflet-draw@1.0.4: {}
|
||||||
|
|
||||||
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3125,6 +3189,13 @@ snapshots:
|
|||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.5
|
||||||
|
react-dom: 19.2.5(react@19.2.5)
|
||||||
|
|
||||||
react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.5):
|
react-oidc-context@3.3.1(oidc-client-ts@3.5.0)(react@19.2.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
oidc-client-ts: 3.5.0
|
oidc-client-ts: 3.5.0
|
||||||
|
|||||||
@@ -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({
|
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[]> => {
|
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[]>(
|
const { data } = await apiClient.get<FlattenedRecord[]>(
|
||||||
`/${dictionaryName}/records`,
|
`/${dictionaryName}/records`,
|
||||||
{ params: { as_scope: scopeCsv } },
|
{ params },
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
@@ -235,8 +255,11 @@ export const useWebhookDlq = (page: number, size: number) =>
|
|||||||
|
|
||||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||||
export const useRecords = (dictionaryName: string, scopeCsv: string) =>
|
export const useRecords = (
|
||||||
useQuery(recordsQuery(dictionaryName, scopeCsv))
|
dictionaryName: string,
|
||||||
|
scopeCsv: string,
|
||||||
|
filter?: RecordsFilter,
|
||||||
|
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
|
||||||
export const useRecordRaw = (
|
export const useRecordRaw = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
businessKey: string | undefined,
|
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],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,6 +235,16 @@ i18n
|
|||||||
'error.failed': 'Не удалось загрузить данные',
|
'error.failed': 'Не удалось загрузить данные',
|
||||||
'auth.login': 'Войти',
|
'auth.login': 'Войти',
|
||||||
'auth.logout': 'Выйти',
|
'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': {
|
'en-US': {
|
||||||
@@ -460,6 +470,16 @@ i18n
|
|||||||
'error.failed': 'Failed to load data',
|
'error.failed': 'Failed to load data',
|
||||||
'auth.login': 'Sign in',
|
'auth.login': 'Sign in',
|
||||||
'auth.logout': 'Sign out',
|
'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,
|
TableRow,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@nstart/ui'
|
} from '@nstart/ui'
|
||||||
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon } from '@phosphor-icons/react'
|
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon } from '@phosphor-icons/react'
|
||||||
import { useDictionaryDetail, useRecordRaw, useRecords } from '@/api/queries'
|
import { useDictionaryDetail, useRecordRaw, useRecords, type RecordsFilter } from '@/api/queries'
|
||||||
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
import { useCreateRecord, useUpdateRecord, useCloseRecord } from '@/api/mutations'
|
||||||
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
|
import type { CreateRecordRequest, FlattenedRecord } from '@/api/client'
|
||||||
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
|
||||||
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
|
||||||
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
|
||||||
|
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
|
||||||
import { nowIsoLocal } from '@/lib/dates'
|
import { nowIsoLocal } from '@/lib/dates'
|
||||||
import { SCOPE_BORDER_TOP, SCOPE_DOT } from '@/lib/scope-style'
|
import { SCOPE_BORDER_TOP, SCOPE_DOT } from '@/lib/scope-style'
|
||||||
|
|
||||||
@@ -44,7 +45,13 @@ function DictionaryDetail() {
|
|||||||
const { name } = Route.useParams()
|
const { name } = Route.useParams()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const detailQuery = useDictionaryDetail(name)
|
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 createMut = useCreateRecord(name)
|
||||||
const updateMut = useUpdateRecord(name)
|
const updateMut = useUpdateRecord(name)
|
||||||
const closeMut = useCloseRecord(name)
|
const closeMut = useCloseRecord(name)
|
||||||
@@ -53,6 +60,7 @@ function DictionaryDetail() {
|
|||||||
const [closeReason, setCloseReason] = useState('')
|
const [closeReason, setCloseReason] = useState('')
|
||||||
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
const [schemaEditOpen, setSchemaEditOpen] = useState(false)
|
||||||
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
|
||||||
|
const [aoiOpen, setAoiOpen] = useState(false)
|
||||||
|
|
||||||
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||||||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||||
@@ -124,6 +132,14 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
leftIcon={<GearIcon weight="bold" size={16} />}
|
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.isLoading && <LoadingBlock size="md" label={t('loading')} />}
|
||||||
|
|
||||||
{recordsResult.error && (
|
{recordsResult.error && (
|
||||||
@@ -340,6 +375,13 @@ function DictionaryDetail() {
|
|||||||
dictionaryName={name}
|
dictionaryName={name}
|
||||||
businessKey={historyKey}
|
businessKey={historyKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AoiPickerDialog
|
||||||
|
isOpen={aoiOpen}
|
||||||
|
onClose={() => setAoiOpen(false)}
|
||||||
|
onApply={(result) => setAoi(result)}
|
||||||
|
initial={aoi?.geojson ?? null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user