From c0444ccfec227b9de6c5ab0f30503337a364bb93 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 22:22:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(geo):=20replace=20leaflet-draw=20=D0=BD?= =?UTF-8?q?=D0=B0=20custom=20DIY=20draw=20=E2=80=94=20fix=20instant-finish?= =?UTF-8?q?=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leaflet-draw 1.0.4 (последний release 2017) сломан с Leaflet 1.8+: mousedown сразу триггерит mouseup, прямоугольник схлопывается в точку при первом нажатии. На пользовательском скрине quadrate ~5px вместо нарисованной области. Заменил полностью на свою draw-implementation (~80 строк): - Toolbar UI: 3 кнопки (Прямоугольник / Полигон / Очистить) - Active mode highlights ultramarain background - Rectangle: mousedown → preview → mouseup finalize. Map.dragging отключается в rectangle mode чтобы drag не панил карту. - Polygon: click добавляет вершину + dashed mouse-line preview к курсору. Double-click closes (min 3 vertices). doubleClickZoom disabled на map чтобы не конфликтовало. - DrawMode read через ref чтобы listeners не пересоздавались на каждом state change. Удалены deps: - leaflet-draw 1.0.4 (broken) - @types/leaflet-draw Bundle: -68KB minified / -23KB gzip (less broken legacy code). Tests: 89 passed. i18n: RU/EN ключи для новой toolbar. --- ordinis-admin-ui/package.json | 2 - ordinis-admin-ui/pnpm-lock.yaml | 18 -- .../src/components/geo/AoiPickerDialog.tsx | 238 ++++++++++++------ ordinis-admin-ui/src/i18n.ts | 8 + 4 files changed, 175 insertions(+), 91 deletions(-) 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',