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:
@@ -47,93 +47,97 @@ 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
|
||||
const div = mapDivRef.current
|
||||
if (!div || mapRef.current) return
|
||||
let cancelled = false
|
||||
let ro: ResizeObserver | null = null
|
||||
let rafId = 0
|
||||
|
||||
// Россия по умолчанию: центр ~Урал, 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 tryInit = () => {
|
||||
if (cancelled) return
|
||||
const div = mapDivRef.current
|
||||
if (!div || mapRef.current) return
|
||||
|
||||
const drawn = new L.FeatureGroup().addTo(map)
|
||||
drawnRef.current = drawn
|
||||
// Defer пока container не имеет реальный размер — RAF до 100 кадров
|
||||
// (~1.6s @ 60fps), потом сдаёмся и init'им как есть (fallback).
|
||||
if ((div.clientWidth === 0 || div.clientHeight === 0) && rafId < 100) {
|
||||
rafId += 1
|
||||
requestAnimationFrame(tryInit)
|
||||
return
|
||||
}
|
||||
|
||||
// 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 })
|
||||
drawn.addLayer(poly)
|
||||
setShape(poly)
|
||||
map.fitBounds(poly.getBounds(), { padding: [20, 20] })
|
||||
// Россия по умолчанию: центр ~Урал, 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)
|
||||
}
|
||||
|
||||
// Draw controls: только rectangle + polygon. Marker / circle /
|
||||
// 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()
|
||||
})
|
||||
tryInit()
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
cancelAnimationFrame(raf)
|
||||
cancelled = true
|
||||
if (ro) ro.disconnect()
|
||||
}
|
||||
}, [isOpen, initial])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user