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 [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])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user