From 5534b9d479e820686a9c80620227b2a6392ce407 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 6 May 2026 21:55:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(geo):=20AOI=20map=20blank=20=E2=80=94=20def?= =?UTF-8?q?er=20init=20=D0=B4=D0=BE=20non-zero=20clientSize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Предыдущий 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 — но не критичен для первого рендера. --- .../src/components/geo/AoiPickerDialog.tsx | 162 +++++++++--------- 1 file changed, 83 insertions(+), 79 deletions(-) diff --git a/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx index d20fb2f..26bf607 100644 --- a/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx +++ b/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx @@ -47,93 +47,97 @@ export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => const [shape, setShape] = useState(null) const [error, setError] = useState(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])