diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json
index 996fb80..e9ad732 100644
--- a/ordinis-admin-ui/package.json
+++ b/ordinis-admin-ui/package.json
@@ -22,7 +22,6 @@
"i18next": "^26.0.3",
"i18next-browser-languagedetector": "^8.0.2",
"leaflet": "^1.9.4",
- "leaflet-draw": "^1.0.4",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -38,7 +37,6 @@
"@testing-library/react": "^16.3.2",
"@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-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml
index cfcae11..7217219 100644
--- a/ordinis-admin-ui/pnpm-lock.yaml
+++ b/ordinis-admin-ui/pnpm-lock.yaml
@@ -41,9 +41,6 @@ importers:
leaflet:
specifier: ^1.9.4
version: 1.9.4
- leaflet-draw:
- specifier: ^1.0.4
- version: 1.0.4
oidc-client-ts:
specifier: ^3.5.0
version: 3.5.0
@@ -84,9 +81,6 @@ importers:
'@types/leaflet':
specifier: ^1.9.21
version: 1.9.21
- '@types/leaflet-draw':
- specifier: ^1.0.13
- version: 1.0.13
'@types/react':
specifier: ^19.0.0
version: 19.2.14
@@ -975,9 +969,6 @@ packages:
'@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==}
@@ -1391,9 +1382,6 @@ packages:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
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==}
@@ -2581,10 +2569,6 @@ snapshots:
'@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
@@ -3034,8 +3018,6 @@ snapshots:
jwt-decode@4.0.0: {}
- leaflet-draw@1.0.4: {}
-
leaflet@1.9.4: {}
lightningcss-android-arm64@1.32.0:
diff --git a/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx
index be50a93..417de71 100644
--- a/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx
+++ b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx
@@ -3,8 +3,6 @@ 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'а:
@@ -27,17 +25,19 @@ type Props = {
initial?: GeoJSON.Polygon | null
}
+type DrawMode = 'idle' | 'rectangle' | 'polygon'
+
/**
- * AOI picker на Leaflet с draw controls (rectangle + polygon).
+ * AOI picker на Leaflet с custom draw logic (rectangle + polygon).
*
- * Admin рисует bbox или произвольный полигон на OSM tile'ах →
- * onApply получает CSV bbox и GeoJSON polygon одновременно. Caller
- * выбирает что использовать для filter (bbox дешевле для server,
- * polygon точнее).
+ * Используем свою draw-implementation вместо `leaflet-draw` 1.0.4 —
+ * последний release lib'ы был в 2017, плохо совместим с Leaflet 1.8+:
+ * `_onMouseDown` сразу триггерит `_onMouseUp` на rectangle drag, и
+ * прямоугольник схлопывается в точку. Custom logic — ~80 строк, полный
+ * контроль, без сломанной зависимости.
*
- *
Map монтируется только когда isOpen=true чтобы Leaflet
- * корректно посчитал размеры контейнера (иначе серый экран до
- * первого resize).
+ * Rectangle: mousedown → mousemove preview → mouseup finalize.
+ * Polygon: click добавляет вершину, dblclick закрывает (≥3 точки).
*/
export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => {
const { t } = useTranslation()
@@ -46,14 +46,11 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
const drawnRef = useRef(null)
const [shape, setShape] = useState(null)
const [error, setError] = useState(null)
+ const [drawMode, setDrawMode] = useState('idle')
+ const drawModeRef = useRef('idle')
+ drawModeRef.current = drawMode
- // 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.
+ // Init map после mount + isOpen=true. RAF poll до non-zero size.
useEffect(() => {
if (!isOpen) return
let cancelled = false
@@ -62,16 +59,9 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
const tryInit = () => {
if (cancelled) return
- if (mapRef.current) return // уже init'нут
+ if (mapRef.current) return
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
@@ -80,9 +70,6 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
return
}
- // Россия по умолчанию: центр ~Урал, zoom видит всю страну.
- // attributionControl: false убирает Leaflet-флаг + OSM credit
- // (ukrainian flag emoji в Leaflet 1.9.4 default attribution).
const map = L.map(div, { zoomControl: true, attributionControl: false })
.setView([60, 90], 3)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -100,43 +87,13 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
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)
+ attachDrawHandlers(map, drawn, drawModeRef, (newShape) => {
+ setShape(newShape)
setError(null)
- })
- map.on(L.Draw.Event.DELETED, () => setShape(null))
- map.on(L.Draw.Event.EDITED, () => {
- const layers = drawn.getLayers()
- setShape(layers[0] ?? null)
+ setDrawMode('idle')
})
mapRef.current = map
-
- // Window resize / dev tools / responsive layout — invalidate.
ro = new ResizeObserver(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
@@ -150,7 +107,23 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
}
}, [isOpen, initial])
- // Cleanup при close: уничтожить map чтобы re-open был чистый старт.
+ // Cursor + map drag state в зависимости от drawMode.
+ useEffect(() => {
+ const map = mapRef.current
+ if (!map) return
+ const container = map.getContainer()
+ if (drawMode === 'idle') {
+ container.style.cursor = ''
+ map.dragging.enable()
+ } else {
+ container.style.cursor = 'crosshair'
+ // Disable map drag в rectangle mode чтобы mousedown+drag рисовал, не панил.
+ if (drawMode === 'rectangle') map.dragging.disable()
+ else map.dragging.enable()
+ }
+ }, [drawMode])
+
+ // Cleanup при close.
useEffect(() => {
if (isOpen) return
if (mapRef.current) {
@@ -159,9 +132,16 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
drawnRef.current = null
setShape(null)
setError(null)
+ setDrawMode('idle')
}
}, [isOpen])
+ const handleClear = () => {
+ drawnRef.current?.clearLayers()
+ setShape(null)
+ setError(null)
+ }
+
const handleApply = () => {
if (!shape) {
setError(t('aoi.error.noShape'))
@@ -176,6 +156,24 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
onClose()
}
+ const toolbarBtn = (mode: DrawMode, label: string) => {
+ const active = drawMode === mode
+ return (
+
+ )
+ }
+
return (
>
{t('aoi.dialog.help')}
+
+ {toolbarBtn('rectangle', t('aoi.dialog.drawRectangle'))}
+ {toolbarBtn('polygon', t('aoi.dialog.drawPolygon'))}
+
+ {drawMode === 'polygon' && (
+
+ {t('aoi.dialog.polygonHint')}
+
+ )}
+
+
)
}
+/**
+ * Custom draw handlers: подписываемся на map events, рисуем в зависимости
+ * от текущего drawMode (читаемого через ref чтобы не пересоздавать
+ * listeners на каждое изменение state).
+ */
+const attachDrawHandlers = (
+ map: L.Map,
+ drawn: L.FeatureGroup,
+ modeRef: React.RefObject,
+ onComplete: (layer: L.Layer) => void,
+) => {
+ const STYLE = { color: '#1d4ed8', weight: 2 }
+
+ // Rectangle: mousedown→start, mousemove→preview, mouseup→finalize.
+ let rectStart: L.LatLng | null = null
+ let rectPreview: L.Rectangle | null = null
+
+ map.on('mousedown', (e) => {
+ if (modeRef.current !== 'rectangle') return
+ rectStart = (e as L.LeafletMouseEvent).latlng
+ })
+
+ map.on('mousemove', (e) => {
+ if (modeRef.current !== 'rectangle' || !rectStart) return
+ const cur = (e as L.LeafletMouseEvent).latlng
+ if (rectPreview) {
+ rectPreview.setBounds(L.latLngBounds(rectStart, cur))
+ } else {
+ rectPreview = L.rectangle(L.latLngBounds(rectStart, cur), STYLE).addTo(drawn)
+ }
+ })
+
+ map.on('mouseup', () => {
+ if (modeRef.current !== 'rectangle' || !rectStart) return
+ if (rectPreview) {
+ // Finalize: replace previous shape с новым rectangle.
+ const finalRect = rectPreview
+ drawn.clearLayers()
+ drawn.addLayer(finalRect)
+ onComplete(finalRect)
+ }
+ rectStart = null
+ rectPreview = null
+ })
+
+ // Polygon: click→add vertex, dblclick→close, mousemove→preview line.
+ let polyPoints: L.LatLng[] = []
+ let polyPreview: L.Polyline | null = null
+ let polyMouseLine: L.Polyline | null = null
+
+ map.on('click', (e) => {
+ if (modeRef.current !== 'polygon') return
+ const pt = (e as L.LeafletMouseEvent).latlng
+ polyPoints.push(pt)
+ if (polyPreview) {
+ polyPreview.setLatLngs(polyPoints)
+ } else {
+ polyPreview = L.polyline(polyPoints, STYLE).addTo(drawn)
+ }
+ })
+
+ map.on('mousemove', (e) => {
+ if (modeRef.current !== 'polygon' || polyPoints.length === 0) return
+ const cur = (e as L.LeafletMouseEvent).latlng
+ const last = polyPoints[polyPoints.length - 1]
+ if (polyMouseLine) {
+ polyMouseLine.setLatLngs([last, cur])
+ } else {
+ polyMouseLine = L.polyline([last, cur], { ...STYLE, dashArray: '4 4' }).addTo(drawn)
+ }
+ })
+
+ map.on('dblclick', () => {
+ if (modeRef.current !== 'polygon') return
+ if (polyPoints.length < 3) return
+ // Finalize: replace с финальным polygon.
+ const finalPoly = L.polygon(polyPoints, STYLE)
+ drawn.clearLayers()
+ drawn.addLayer(finalPoly)
+ onComplete(finalPoly)
+ polyPoints = []
+ polyPreview = null
+ polyMouseLine = null
+ })
+
+ // Disable Leaflet's default zoom-on-doubleclick — конфликтует с polygon close.
+ map.doubleClickZoom.disable()
+}
+
/**
* Конвертирует 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) {
@@ -232,7 +329,6 @@ const layerToResult = (layer: L.Layer): AoiResult | null => {
}
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]]
diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts
index 09f4d22..fbc1593 100644
--- a/ordinis-admin-ui/src/i18n.ts
+++ b/ordinis-admin-ui/src/i18n.ts
@@ -266,6 +266,10 @@ i18n
'aoi.dialog.title': 'Выбор области интереса (AOI)',
'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.',
'aoi.dialog.apply': 'Применить',
+ 'aoi.dialog.drawRectangle': 'Прямоугольник',
+ 'aoi.dialog.drawPolygon': 'Полигон',
+ 'aoi.dialog.clear': 'Очистить',
+ 'aoi.dialog.polygonHint': 'Клик — добавить точку, двойной клик — закрыть полигон (минимум 3 точки)',
'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса',
'aoi.error.noShape': 'Сначала нарисуйте область на карте',
'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать',
@@ -525,6 +529,10 @@ i18n
'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.drawRectangle': 'Rectangle',
+ 'aoi.dialog.drawPolygon': 'Polygon',
+ 'aoi.dialog.clear': 'Clear',
+ 'aoi.dialog.polygonHint': 'Click adds vertex, double-click closes polygon (min 3 vertices)',
'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',