Files
mdm-ordinis/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx
T
Zimin A.N. 9aa3d842d2 fix(geo): AOI map blank — RAF poll до появления div + shrink-0 защита
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.
2026-05-06 22:12:17 +03:00

267 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
],
],
}
}