9aa3d842d2
DevTools на пользовательском скрине показал: - Map div СУЩЕСТВУЕТ в DOM (display:block, height:520 inline) - Реальный размер 974×254 — flex-shrink порезал до ~50% запрошенного потому что parent (Modal body) overflow-hidden + max-h:calc(100vh-2rem) на узком viewport (497px) не вмещал 520+ контента. - Класса 'leaflet-container' на div НЕТ — L.map() так и не вызвался. Корень: Modal от @nstart/ui рендерит children только после useAnimatedPresence фликает isMounted=true (на след. рендер после isOpen=true). Мой useEffect фаерится РАНЬШЕ — div ещё не в DOM, mapDivRef.current=null. Старый guard 'if (!div) return' выходил БЕЗ schedule RAF — больше никогда не пробовал. Фикс в tryInit: schedule RAF и для null div, и для 0-size. Cap 100 кадров (~1.6s). Это finally доводит до L.map() когда div появится и получит размер. Plus: shrink-0 + min-height: 520 на map div чтобы flex-shrink не ужимал до 254px на узких viewport. Visual quality 520px stable.
267 lines
9.3 KiB
TypeScript
267 lines
9.3 KiB
TypeScript
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. 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.
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
let cancelled = false
|
||
let ro: ResizeObserver | null = null
|
||
let rafId = 0
|
||
|
||
const tryInit = () => {
|
||
if (cancelled) return
|
||
if (mapRef.current) return // уже init'нут
|
||
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
|
||
requestAnimationFrame(tryInit)
|
||
}
|
||
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
|
||
|
||
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] })
|
||
}
|
||
|
||
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)
|
||
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
|
||
|
||
// Window resize / dev tools / responsive layout — invalidate.
|
||
ro = new ResizeObserver(() => {
|
||
if (mapRef.current) mapRef.current.invalidateSize()
|
||
})
|
||
ro.observe(div)
|
||
}
|
||
|
||
tryInit()
|
||
return () => {
|
||
cancelled = true
|
||
if (ro) ro.disconnect()
|
||
}
|
||
}, [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}
|
||
// 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')}
|
||
/>
|
||
|
||
{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],
|
||
],
|
||
],
|
||
}
|
||
}
|