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:
@@ -22,7 +22,6 @@
|
|||||||
"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": "^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",
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
"@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": "^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
-18
@@ -41,9 +41,6 @@ importers:
|
|||||||
leaflet:
|
leaflet:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 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
|
||||||
@@ -84,9 +81,6 @@ importers:
|
|||||||
'@types/leaflet':
|
'@types/leaflet':
|
||||||
specifier: ^1.9.21
|
specifier: ^1.9.21
|
||||||
version: 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
|
||||||
@@ -975,9 +969,6 @@ packages:
|
|||||||
'@types/geojson@7946.0.16':
|
'@types/geojson@7946.0.16':
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
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':
|
'@types/leaflet@1.9.21':
|
||||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
@@ -1391,9 +1382,6 @@ 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:
|
leaflet@1.9.4:
|
||||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
@@ -2581,10 +2569,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/leaflet-draw@1.0.13':
|
|
||||||
dependencies:
|
|
||||||
'@types/leaflet': 1.9.21
|
|
||||||
|
|
||||||
'@types/leaflet@1.9.21':
|
'@types/leaflet@1.9.21':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
@@ -3034,8 +3018,6 @@ snapshots:
|
|||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
leaflet-draw@1.0.4: {}
|
|
||||||
|
|
||||||
leaflet@1.9.4: {}
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Alert, Button, FormActions, Modal } from '@nstart/ui'
|
import { Alert, Button, FormActions, Modal } from '@nstart/ui'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import 'leaflet-draw'
|
|
||||||
import 'leaflet-draw/dist/leaflet.draw.css'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Результат AOI picker'а:
|
* Результат AOI picker'а:
|
||||||
@@ -27,17 +25,19 @@ type Props = {
|
|||||||
initial?: GeoJSON.Polygon | null
|
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'ах →
|
* Используем свою draw-implementation вместо `leaflet-draw` 1.0.4 —
|
||||||
* onApply получает CSV bbox и GeoJSON polygon одновременно. Caller
|
* последний release lib'ы был в 2017, плохо совместим с Leaflet 1.8+:
|
||||||
* выбирает что использовать для filter (bbox дешевле для server,
|
* `_onMouseDown` сразу триггерит `_onMouseUp` на rectangle drag, и
|
||||||
* polygon точнее).
|
* прямоугольник схлопывается в точку. Custom logic — ~80 строк, полный
|
||||||
|
* контроль, без сломанной зависимости.
|
||||||
*
|
*
|
||||||
* <p>Map монтируется только когда isOpen=true чтобы Leaflet
|
* Rectangle: mousedown → mousemove preview → mouseup finalize.
|
||||||
* корректно посчитал размеры контейнера (иначе серый экран до
|
* Polygon: click добавляет вершину, dblclick закрывает (≥3 точки).
|
||||||
* первого resize).
|
|
||||||
*/
|
*/
|
||||||
export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => {
|
export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -46,14 +46,11 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
const drawnRef = useRef<L.FeatureGroup | null>(null)
|
const drawnRef = useRef<L.FeatureGroup | null>(null)
|
||||||
const [shape, setShape] = useState<L.Layer | null>(null)
|
const [shape, setShape] = useState<L.Layer | null>(null)
|
||||||
const [error, setError] = useState<string | 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
|
// Init map после mount + isOpen=true. RAF poll до non-zero size.
|
||||||
// получит 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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -62,16 +59,9 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
|
|
||||||
const tryInit = () => {
|
const tryInit = () => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (mapRef.current) return // уже init'нут
|
if (mapRef.current) return
|
||||||
const div = mapDivRef.current
|
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 (!div || div.clientWidth === 0 || div.clientHeight === 0) {
|
||||||
if (rafId < 100) {
|
if (rafId < 100) {
|
||||||
rafId += 1
|
rafId += 1
|
||||||
@@ -80,9 +70,6 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
return
|
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 })
|
const map = L.map(div, { zoomControl: true, attributionControl: false })
|
||||||
.setView([60, 90], 3)
|
.setView([60, 90], 3)
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
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] })
|
map.fitBounds(poly.getBounds(), { padding: [20, 20] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawControl = new L.Control.Draw({
|
attachDrawHandlers(map, drawn, drawModeRef, (newShape) => {
|
||||||
draw: {
|
setShape(newShape)
|
||||||
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)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
})
|
setDrawMode('idle')
|
||||||
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
|
mapRef.current = map
|
||||||
|
|
||||||
// Window resize / dev tools / responsive layout — invalidate.
|
|
||||||
ro = new ResizeObserver(() => {
|
ro = new ResizeObserver(() => {
|
||||||
if (mapRef.current) mapRef.current.invalidateSize()
|
if (mapRef.current) mapRef.current.invalidateSize()
|
||||||
})
|
})
|
||||||
@@ -150,7 +107,23 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
}
|
}
|
||||||
}, [isOpen, initial])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) return
|
if (isOpen) return
|
||||||
if (mapRef.current) {
|
if (mapRef.current) {
|
||||||
@@ -159,9 +132,16 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
drawnRef.current = null
|
drawnRef.current = null
|
||||||
setShape(null)
|
setShape(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setDrawMode('idle')
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
drawnRef.current?.clearLayers()
|
||||||
|
setShape(null)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (!shape) {
|
if (!shape) {
|
||||||
setError(t('aoi.error.noShape'))
|
setError(t('aoi.error.noShape'))
|
||||||
@@ -176,6 +156,24 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
|
|||||||
onClose()
|
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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
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>
|
<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
|
<div
|
||||||
ref={mapDivRef}
|
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"
|
className="rounded-sm border border-regolith shrink-0"
|
||||||
style={{ display: 'block', width: '100%', height: 520, minHeight: 520 }}
|
style={{ display: 'block', width: '100%', height: 520, minHeight: 520 }}
|
||||||
aria-label={t('aoi.dialog.mapAriaLabel')}
|
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 +
|
* Конвертирует Leaflet layer (rectangle / polygon) в bbox CSV +
|
||||||
* GeoJSON Polygon. Возвращает null если geometry invalid.
|
* GeoJSON Polygon. Возвращает null если geometry invalid.
|
||||||
*
|
|
||||||
* Для rectangle — bbox-режим (server использует ST_MakeEnvelope,
|
|
||||||
* быстрее polygon ST_Intersects), но GeoJSON тоже даём для
|
|
||||||
* обратной совместимости / preview.
|
|
||||||
*/
|
*/
|
||||||
const layerToResult = (layer: L.Layer): AoiResult | null => {
|
const layerToResult = (layer: L.Layer): AoiResult | null => {
|
||||||
if (layer instanceof L.Rectangle) {
|
if (layer instanceof L.Rectangle) {
|
||||||
@@ -232,7 +329,6 @@ const layerToResult = (layer: L.Layer): AoiResult | null => {
|
|||||||
}
|
}
|
||||||
if (layer instanceof L.Polygon) {
|
if (layer instanceof L.Polygon) {
|
||||||
const latlngsRaw = layer.getLatLngs()
|
const latlngsRaw = layer.getLatLngs()
|
||||||
// Polygon.getLatLngs() returns LatLng[][] для simple polygon.
|
|
||||||
const ring = (Array.isArray(latlngsRaw[0]) ? latlngsRaw[0] : latlngsRaw) as L.LatLng[]
|
const ring = (Array.isArray(latlngsRaw[0]) ? latlngsRaw[0] : latlngsRaw) as L.LatLng[]
|
||||||
if (!ring || ring.length < 3) return null
|
if (!ring || ring.length < 3) return null
|
||||||
const closed = [...ring, ring[0]]
|
const closed = [...ring, ring[0]]
|
||||||
|
|||||||
@@ -266,6 +266,10 @@ i18n
|
|||||||
'aoi.dialog.title': 'Выбор области интереса (AOI)',
|
'aoi.dialog.title': 'Выбор области интереса (AOI)',
|
||||||
'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.',
|
'aoi.dialog.help': 'Нарисуйте прямоугольник или полигон. Применяется как фильтр к записям со spatial geometry.',
|
||||||
'aoi.dialog.apply': 'Применить',
|
'aoi.dialog.apply': 'Применить',
|
||||||
|
'aoi.dialog.drawRectangle': 'Прямоугольник',
|
||||||
|
'aoi.dialog.drawPolygon': 'Полигон',
|
||||||
|
'aoi.dialog.clear': 'Очистить',
|
||||||
|
'aoi.dialog.polygonHint': 'Клик — добавить точку, двойной клик — закрыть полигон (минимум 3 точки)',
|
||||||
'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса',
|
'aoi.dialog.mapAriaLabel': 'Карта для выбора области интереса',
|
||||||
'aoi.error.noShape': 'Сначала нарисуйте область на карте',
|
'aoi.error.noShape': 'Сначала нарисуйте область на карте',
|
||||||
'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать',
|
'aoi.error.invalid': 'Геометрия некорректна — попробуйте перерисовать',
|
||||||
@@ -525,6 +529,10 @@ i18n
|
|||||||
'aoi.dialog.title': 'Select Area of Interest (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.help': 'Draw a rectangle or polygon. Applied as filter to records with spatial geometry.',
|
||||||
'aoi.dialog.apply': 'Apply',
|
'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.dialog.mapAriaLabel': 'Map for selecting area of interest',
|
||||||
'aoi.error.noShape': 'Draw an area on the map first',
|
'aoi.error.noShape': 'Draw an area on the map first',
|
||||||
'aoi.error.invalid': 'Invalid geometry — please redraw',
|
'aoi.error.invalid': 'Invalid geometry — please redraw',
|
||||||
|
|||||||
Reference in New Issue
Block a user