fix(geo): replace leaflet-draw на custom DIY draw — fix instant-finish bug

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.
This commit is contained in:
Zimin A.N.
2026-05-06 22:22:09 +03:00
parent 7a8bfedfa5
commit c0444ccfec
4 changed files with 175 additions and 91 deletions
@@ -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 строк, полный
* контроль, без сломанной зависимости.
*
* <p>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<L.FeatureGroup | null>(null)
const [shape, setShape] = useState<L.Layer | null>(null)
const [error, setError] = useState<string | null>(null)
const [drawMode, setDrawMode] = useState<DrawMode>('idle')
const drawModeRef = useRef<DrawMode>('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 (
<button
type="button"
onClick={() => setDrawMode(active ? 'idle' : mode)}
className={[
'px-3 py-1.5 text-xs font-secondary rounded-sm border transition-colors',
active
? 'border-ultramarain bg-ultramarain text-white'
: 'border-regolith bg-white text-carbon hover:border-carbon/40',
].join(' ')}
>
{label}
</button>
)
}
return (
<Modal
isOpen={isOpen}
@@ -187,12 +185,26 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
>
<p className="text-sm text-carbon/70">{t('aoi.dialog.help')}</p>
<div className="flex flex-wrap items-center gap-2">
{toolbarBtn('rectangle', t('aoi.dialog.drawRectangle'))}
{toolbarBtn('polygon', t('aoi.dialog.drawPolygon'))}
<button
type="button"
onClick={handleClear}
disabled={!shape}
className="px-3 py-1.5 text-xs font-secondary rounded-sm border border-regolith bg-white text-carbon hover:border-carbon/40 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('aoi.dialog.clear')}
</button>
{drawMode === 'polygon' && (
<span className="text-2xs text-carbon/60">
{t('aoi.dialog.polygonHint')}
</span>
)}
</div>
<div
ref={mapDivRef}
// shrink-0 защищает от flex-shrink в overflow-hidden parent body Modal'а:
// на узком viewport (height < 700px) panel max-h сжимал body, flex-shrink
// обрезал map с 520px до ~250px (DevTools confirmed). shrink-0 + min-h
// фиксируют 520 даже когда panel переполнен.
className="rounded-sm border border-regolith shrink-0"
style={{ display: 'block', width: '100%', height: 520, minHeight: 520 }}
aria-label={t('aoi.dialog.mapAriaLabel')}
@@ -216,13 +228,98 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
)
}
/**
* Custom draw handlers: подписываемся на map events, рисуем в зависимости
* от текущего drawMode (читаемого через ref чтобы не пересоздавать
* listeners на каждое изменение state).
*/
const attachDrawHandlers = (
map: L.Map,
drawn: L.FeatureGroup,
modeRef: React.RefObject<DrawMode>,
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]]