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,93 +47,97 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) =>
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)
// 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(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
const div = mapDivRef.current let cancelled = false
if (!div || mapRef.current) return let ro: ResizeObserver | null = null
let rafId = 0
// Россия по умолчанию: центр ~Урал, zoom видит всю страну. const tryInit = () => {
const map = L.map(div, { zoomControl: true }).setView([60, 90], 3) if (cancelled) return
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const div = mapDivRef.current
attribution: '© OpenStreetMap contributors', if (!div || mapRef.current) return
maxZoom: 18,
}).addTo(map)
const drawn = new L.FeatureGroup().addTo(map) // Defer пока container не имеет реальный размер — RAF до 100 кадров
drawnRef.current = drawn // (~1.6s @ 60fps), потом сдаёмся и init'им как есть (fallback).
if ((div.clientWidth === 0 || div.clientHeight === 0) && rafId < 100) {
rafId += 1
requestAnimationFrame(tryInit)
return
}
// Initial geometry — отрисовать и зум. // Россия по умолчанию: центр ~Урал, zoom видит всю страну.
if (initial && initial.coordinates?.[0]?.length >= 4) { const map = L.map(div, { zoomControl: true }).setView([60, 90], 3)
const ring = initial.coordinates[0].map(([lng, lat]) => L.latLng(lat, lng)) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
const poly = L.polygon(ring, { color: '#1d4ed8', weight: 2 }) attribution: '© OpenStreetMap contributors',
drawn.addLayer(poly) maxZoom: 18,
setShape(poly) }).addTo(map)
map.fitBounds(poly.getBounds(), { padding: [20, 20] })
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)
} }
// Draw controls: только rectangle + polygon. Marker / circle / tryInit()
// circlemarker отключены — backend ждёт Polygon.
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)
// 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()
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
// 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(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
ro.observe(div)
const raf = requestAnimationFrame(() => {
if (mapRef.current) mapRef.current.invalidateSize()
})
return () => { return () => {
ro.disconnect() cancelled = true
cancelAnimationFrame(raf) if (ro) ro.disconnect()
} }
}, [isOpen, initial]) }, [isOpen, initial])