fix(geo): AOI map blank — defer init до non-zero clientSize

Предыдущий fix с ResizeObserver + RAF не помог: на пользовательском
скрине карта снова blank, нет даже zoom controls и attribution.
Гипотеза: Modal от @nstart/ui использует display:none → block для
animation. В момент useEffect div ещё имеет 0×0 (parent display:none
makes all children clientWidth/Height = 0 regardless of inline style).
Leaflet L.map(div) с 0×0 контейнером не вычисляет initial tile grid,
и invalidateSize позже не recompute'ит некоторые internal collections.

Решение: recursive RAF poll до тех пор пока div.clientWidth и
clientHeight > 0, тогда init'им L.map(). Cap на 100 кадров (~1.6s)
fallback init с тем что есть — на случай если modal никогда не
покажется по какой-то причине.

ResizeObserver всё равно ставится после init для window resize /
dev tools toggle / responsive — но не критичен для первого рендера.
This commit is contained in:
Zimin A.N.
2026-05-06 21:55:36 +03:00
parent d8686c0352
commit 5534b9d479
@@ -47,12 +47,32 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
const [shape, setShape] = useState<L.Layer | null>(null)
const [error, setError] = useState<string | null>(null)
// Init map после mount + isOpen=true. cleanup на unmount.
// 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
const div = mapDivRef.current
if (!div || mapRef.current) return
// Defer пока container не имеет реальный размер — RAF до 100 кадров
// (~1.6s @ 60fps), потом сдаёмся и init'им как есть (fallback).
if ((div.clientWidth === 0 || div.clientHeight === 0) && 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', {
@@ -63,7 +83,6 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
const drawn = new L.FeatureGroup().addTo(map)
drawnRef.current = drawn
// Initial geometry — отрисовать и зум.
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 })
@@ -72,8 +91,6 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
map.fitBounds(poly.getBounds(), { padding: [20, 20] })
}
// Draw controls: только rectangle + polygon. Marker / circle /
// circlemarker отключены — backend ждёт Polygon.
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#1d4ed8', weight: 2 } },
@@ -95,7 +112,6 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
})
map.addControl(drawControl)
// Created shape — заменяем предыдущую (max 1 AOI на picker).
map.on(L.Draw.Event.CREATED, (e) => {
const event = e as L.LeafletEvent & { layerType: string; layer: L.Layer }
drawn.clearLayers()
@@ -103,11 +119,7 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
setShape(event.layer)
setError(null)
})
map.on(L.Draw.Event.DELETED, () => {
setShape(null)
})
map.on(L.Draw.Event.DELETED, () => setShape(null))
map.on(L.Draw.Event.EDITED, () => {
const layers = drawn.getLayers()
setShape(layers[0] ?? null)
@@ -115,25 +127,17 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
mapRef.current = map
// Modal animation + flex layout = mapDiv может стартовать с 0×0 на
// первом кадре. Leaflet вычисляет tile grid от containerSize в момент
// create() — если 0, тайлы не запрашиваются и карта остаётся пустой.
//
// Решение: ResizeObserver на контейнер, invalidateSize при каждом
// resize. Это надёжно покрывает modal fade-in, dev tools toggle,
// window resize. Дополнительно invalidateSize на следующем
// animation frame — для случая когда RO не fire'ит (initial size
// уже окончательный).
const ro = new ResizeObserver(() => {
// Window resize / dev tools / responsive layout — invalidate.
ro = new ResizeObserver(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
ro.observe(div)
const raf = requestAnimationFrame(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
}
tryInit()
return () => {
ro.disconnect()
cancelAnimationFrame(raf)
cancelled = true
if (ro) ro.disconnect()
}
}, [isOpen, initial])