React Grid Layout в деле: эволюция гео-аналитики в 2ГИС Про
- пятница, 6 февраля 2026 г. в 00:00:05
Как дать пользователю ответ на конкретный вопрос с помощью гео-аналитики? Идеальный ответ — это заходишь в сервис, нажимаешь одну большую кнопку и сразу видишь ответ на свой вопрос. Но, как и в любой сложной системе, в реальности всё устроено сложнее.
Мы начинали 2GIS Pro как типичный стартап: быстрый, модный, молодёжный. В начале у нас не было ни глубокого опыта в аналитических продуктах, ни чёткого понимания, каким должен стать продукт.

Мы взяли базовые наработки из 2gis.ru, приспособили их для «прошки» и начали писать собственную логику поверх уже существующего решения. В итоге получился очень насыщенный и визуально сложный интерфейс, сосредоточенный преимущественно на работе с картами и рассчитанный на опытных аналитиков.
Первые задачи были просты и понятны — дать пользователям возможность взглянуть на данные с географической точки зрения: посмотреть плотность объектов, трафик, точки интереса. Это неплохо работало. Но со временем стало ясно, что людям хочется не просто видеть информацию, а настраивать под свои нужды. Сценариев использования становилось всё больше, а интерфейс тем временем устаревал и вызывал всё больше неудобств.

Мы начали изучать различные инструменты (Miro, Figma и Excalidraw) и поняли, что ключевое преимущество этих платформ — предоставление пользователям свободного пространства для размещения элементов интерфейса по своему усмотрению.
Это вдохновило нас пересмотреть подход к нашей карте. Мы поняли, что карта должна стать частью большего рабочего пространства, где пользователь сам определяет расположение графиков, таблиц и фильтров, формируя уникальный интерфейс под конкретные задачи. Такой подход позволил существенно расширить функциональность и адаптивность нашего продукта.
За такой красивой идеей скрывалась пачка проблем и вопросов — и технических, и UX, и продуктовых. Как всё будет работать в разных разрешениях и на разных экранах? Как сделать так, чтобы при полной свободе расположения элементов сохранялся контроль над их слоями? Что делать с адаптивностью, производительностью и миграцией уже имеющихся пользовательских данных?
Мы поняли одну важную вещь: теперь мы не просто создаём средство визуализации, а строим полноценную аналитическую платформу. И вот как мы с командой двигались вперед — шаг за шагом.
Мы изначально решили сделать так, чтобы пользователь решал задачи в одном пространстве, без какого-либо переключения между вкладками и экранами. Объяснение простое. Аналитика — это всегда вопрос комплексного восприятия. Цифры, контексты, визуализации и фильтры должны быть рядом, чтобы быстрее ориентироваться и видеть взаимосвязи. Поэтому мы сделали дашборды, внутри которых есть сцены, а на сценах выстроены виджеты. Это как матрёшка, но только про навигацию.
Наша главная цель заключалась в том, чтобы предложить пользователю максимальную гибкость в управлении размещением элементов, сохраняя при этом структурированность и упорядоченность интерфейса. Именно поэтому мы выбрали сеточную структуру, позволяющую каждому элементу занимать определённое число ячеек.
Пользователь получает полную свободу действий: изменение размеров, перемещение, скрытие или вывод виджетов на передний план становится простым и интуитивным процессом. Подобный подход отражает природные закономерности — ведь сама Земля представляет собой глобус, покрытый координатной сеткой, обеспечивающей чёткую организацию пространства.

Отчасти такие ограничения накладывают ответственность на пользователя, зато взамен мы дали ему полную свободу и понятное предсказуемое поведение элементов.
Основная идея была в том, чтобы оставаться в рамках одного экрана и видеть данные, карту и визуализации тех самых данных. Было разработано несколько прототипов, и практически сразу стало понятно, что это то, чего мы хотели.
Сама карта стала тоже виджетом, и это открывает возможности для сложных сценариев: например, можно в отдельном виджете отрисовать другую карту или сторонний сервис — и всё это без серьёзного вмешательства в кодовую базу.
Каждый виджет функционирует как самостоятельное мини-приложение: самостоятельно получает и обрабатывает данные. При большом количестве виджетов мы внедрили архитектурные подходы для распределения нагрузки и организации очередей задач. Это напоминает принцип микросервисов, только реализованный внутри пользовательского интерфейса.
Для стабильной и быстрой работы мы применили React Query, он дал нам следующие ответы, чтобы:
заново не запрашивать уже полученную информацию;
автоматически восстанавливаться при сбоях.
Такие решения принесли ощущение «живого» интерфейса — десятки виджетов работают параллельно, не мешают друг другу и при этом не нагружают систему.
Наш продукт уже был живым и растущим, а пользователи активно работали с текущей версией. Мы не могли допустить резкого отказа в привычных сценариях и потерю ранее настроенных рабочих слоев и групп.
Чтобы избежать этого, мы построили параллельную инфраструктуру:
включили фиче-флаги, позволяющие плавно включать или отключать новые возможности;
разнесли всё по модулям и сервисам, чтобы минимизировать риски;
организовали маршрутизацию между старым и новым интерфейсами.
Так переход прошёл гладко — старые представления автоматически превратились в виджеты, пользовательские настройки сохранились. И что особенно радует, не было ни одного большого сбоя и практически никаких обращений в поддержку. Многие просто не заметили, что уже работают в обновлённой системе.
Один экран вовсе не ограничение, если правильно построена архитектура — можно уместить всё, что нужно.
Готовые решения вроде React Grid Layout и React Query — отличная отправная точка, но они всегда требуют доработок под конкретные задачи.
Чёткое разделение бизнес-логики и пользовательского интерфейса даёт возможность легко масштабировать продукт и уменьшает взаимозависимости.
Плавный переход — это всегда сложная задача, но если заложить поддержку изоляции нового функционала (через фичe-флаги, модули и маршруты) и тщательно протестировать, сложности можно свести к минимуму.
В нашей галактике, путешествуя во времени, мы столкнулись со многими вызовами, но давайте взглянем на некоторые из них.
Ключевые продуктовые и технические вызовы:
адаптивность интерфейса,
мобильность и поддержка разных устройств,
контроль слоёв и организация элементов,
производительность при большом количестве данных,
миграция пользовательских данных и сценариев.
Главным испытанием при создании нового дашборда стало то, как расположить массу разноплановых виджетов на одном экране, при этом не потеряв удобство и функциональность. Это заставило нас по-другому взглянуть на саму структуру интерфейса. Все кружилось вокруг одного единственного ограничения — у нас есть всего один экран.
Нужно было собрать воедино карту, графики, таблицы, фильтры и текстовые блоки, сохранив при этом все нужные качества: читаемость, масштабируемость и предсказуемость поведения.

Для этого мы параллельно работали над визуальными решениями и архитектурой.
С самого начала мы решили забыть о жёстко заданных в пикселях размерах. Вместо этого использовали относительные единицы, привязанные к сетке — высота и ширина каждого виджета измерялись в условных ячейках, строках и колонках. Такой подход позволял интерфейсу нормально адаптироваться к разным экранам — от огромных 4K-мониторов до компактных ноутбуков и даже телефонов.
К тому же мы внедрили container queries — современные CSS-фишки, которые дают виджету возможность «чувствовать» своё место и подстраивать содержимое в зависимости от размера. Это оказалось критичным для хорошего UX и, конечно, для плавной работы на самых разных дисплеях.
Для работы с настройкой расположения элементов мы выбрали библиотеку React Grid Layout. Она позволяла пользователю перетаскивать виджеты, менять их размер и упрощала создание индивидуального интерфейса.
Отличное решение! Но как всегда и бывает, не идеальное. Пришлось решать ряд проблем:
нельзя допускать бесконечную доску с возможностью уйти вправо без конца;
важна строгая организация, чтобы блоки не наслаивались друг на друга;
поведение при изменении размера и масштабировании должно быть точным и предсказуемым.
Для решения этих вопросов мы построили свою систему ограничений и настроек. Появилась очередность виджетов и их одновременный показ на сцене. Все решения нетривиальные, но очень хорошо описаны и документированы — в будущем это упростит масштабирование и поддержку, а будущие поколения скажут нам: «Спасибо».
Одна из самых неожиданных проблем возникла с виджетами, содержащими iframe — например, встроенные карты или внешние визуализации.
При перетаскивании или изменении размера таких виджетов события мыши «проваливались» в iframe, и взаимодействие с сеткой прерывалось.
Решение
Мы добавили динамическое наложение прозрачного блока поверх iframe во время drag/resize операций. Этот подход позволил сохранить все функции drag & drop, не нарушая работу содержимого iframe в обычном режиме.
Мы решили, что будем работать в рамках одного пользовательского экрана, поэтому ограничили размер сетки её краями. И первое, на что наткнулись — это некорректная работа перетаскивания виджетов при включенном параметре isBounded, который управляет поведением элементов внутри сетки и не даёт им выходить за границы сетки.
React Grid Layout имеет известную проблему с вычислением позиций при наличии padding у контейнера. Виджеты смещались на величину padding, что приводило к некорректному позиционированию.
Решение
Проблема была связана с изменениями в логике вычисления top и left в используемой библиотеке. Ключевая проблема была локализована и решена с помощью опции containerPadding, которую мы явно выставили в [0,0].
<ReactGridLayout containerPadding={[0, 0]}> {widgets} </ResponsiveGridLayout>
Стандартная responsive логика RGL основана на breakpoints, что не подходило для нашего случая — нам нужна была плавная адаптация под любой размер экрана.
Решение
Мы отключили встроенную обработку изменения лейаута по причине того, что он вызывался и на инициализации сетки. Так же, мы отключили и responsive-логику и реализовали собственную. Благо RGL в этом плане достаточно гибок.
Так как мы имеем стабильную сетку, то и менять нам её не надо, а значит мы просто пересчитаем размер ячейки и, по классике, отложим перерасчет с помощью debounce, чтобы не делать это на каждый пиксель при возникновении события resize.
const debouncedOnWindowResizeHandler = useDebouncedCallback(() => { onLayoutChange(layout); }, delay); useEffect(() => { if (!isResponsive) { return; } window.addEventListener('resize', debouncedOnWindowResizeHandler); return () => { window.removeEventListener('resize', debouncedOnWindowResizeHandler); }; }, [isResponsive, debouncedOnWindowResizeHandler]);
Количество колонок теперь вычисляется динамически на основе ширины контейнера и минимального размера виджета.
Есть и такие виджеты, которые все же лучше оставлять в том размере, в котором они были нарисованы. Если контейнеры будут адаптивными, то они будут либо расползаться или сильно сжиматься, что будет ломать восприятие данных пользователями.
Решение
В этом случае мы выделили их как отдельную сущность и обрабатываем по отдельному правилу: делаем ячейку по ширине размером в 1px и тогда количество колонок будет равно количеству пикселей по ширине.
const onWindowResizeHandler = useCallback(() => { // Контейнер с сеткой const girdContainer = girdContainerRef.current; if (!girdContainer) { return; } setColsCount(girdContainer.clientWidth); }, [girdContainerRef]);
Пользователь может скрывать и доставать из общего списка свои виджеты. Для плавности интерфейса мы используем react-transition-group. В первоначальном виде этот инструмент принес некоторые особенности.
При наведении на объект карты реакции не возникало, из-за чего пользователь не может увидеть важных статистических данных.А причиной для такого стало то, что после исчезновения виджета оставались пустые div, которые блокировали взаимодействие со слоями ниже.
Хорошо. Тогда может просто всю сетку прятать... нет, тут тоже проблема. Если ее спрятать, то при появлении она будет чуть дергаться, пока не установятся финальные размеры. Это очень сильно заметно невооруженным взглядом.
Решение
Перенесли использование CSSTransition в сам Grid. Этот компонент нужен для анимации скрытия и передаёт все дополнительные пропсы во внутренний div-элемент, что необходимо для корректной работы с react-grid-layout. Попутно мы решаем кастомизацию стилей путем передачи параметра component={null}, чтобы избежать обертки виде div.
<TransitionGroup component={null}> <ReactGridLayout {...}> {widgets.map((widget) => ( <CSSTransition key={widget.id} component={null} unmountOnExit > <div data-widget data-grid={{ i: widget.i, w: widget.w, h: widget.h, x: widget.x, y: widget.y, minW: widget.minW, minH: widget.minH, resizeHandles: widget.resizeHandles, isResizable: widget.isResizable, }} /> </CSSTransition> ))} </ReactGridLayout> </TransitionGroup>
И мы полностью удаляем его из DOM с помощью опции unmountOnExit.
Чтобы стилизовать placeholder — элемент, который появляется при перетаскивании элемента — в React-Grid-Layout существует не так много предложений. Основной подход — стилизация через класс, а всё остальное — излишнее усложнение.
Решение
Положили рядом с компонентом сетки компонент, который кладёт тег style с нужными нам стилями для класса .react-grid-item.react-grid-placeholder.
const DraggableWidgetPlaceholderStyles = () => { const backgroundColor = useStyleTokenValue('colors.effects.shadows.elevation1'); return ( <style dangerouslySetInnerHTML={{ __html: ` .react-grid-item.react-grid-placeholder { background: #fff; border-radius: 6px; transition-duration: 150ms; } `, }} /> ); }; const Grid = () => ( <TransitionGroup component={null}> <DraggableWidgetPlaceholderStyles /> <ReactGridLayout {...} /> </TransitionGroup> );
Теперь сетка возьмет нашу реализацию стилей для подложки.
Если нужно стилизовать и эту часть карточки, то можно написать свою реализацию «ручки». В нашем случае требовалось не только стилизовать их, но ещё и положить логику отображения их в разных режимах: с доступом к редактированию, или без.
Решение
Компонент оборачиваем в forwardRef для прямого доступа библиотеки к нашему узлу, распределение пропертей и полный контроль над стилями:
interface ResizeHandlerProps { isShown: boolean; handleAxis?: 'sw' | 'nw' | 'se' | 'ne' | 's' | 'e' | 'w' | 'n'; } export const ResizeHandler = forwardRef<HTMLDivElement, ResizeHandlerProps>( ({ isShown, handleAxis, ...restProps }, ref) => { const { classes, cx } = useStyles(); return ( <div dir="ltr" ref={ref} data-dir={handleAxis} className={cx(classes.resizeHandler, !isShown && classes._visually_hidden)} {...restProps} /> ); } );
Наш параметр isShown будет сообщать компоненту, когда ему стоит исчезнуть из вида, а когда и показаться. А handleAxis мы указываем в типизации, так как это необходимо предоставить нашему div для корректной обработки изменений размеров, остальное же просто отдаем как есть.
Так же есть особенность с атрибутом dir. Наше приложение работает работает с разным направлением текста, но тут мы намеренно явно задали одно направление для того, чтобы исключить перемешивание их по горизонтали в режиме "rtl"
Переходим в работу с поддержкой направления текста.
Для корректной обработки координат мы определили свою логику расположения карточки в сетке, учитывая направления текста. Для самой сетки мы указали статичный dir — таким образом исключаем лишние эффекты, после чего в нужный нам момент посчитали коллизию. Но для отображения контента виджета мы уже вернём обратно тот параметр, в котором работает приложение.
// Производим свои расчёты и формируем данные const WidgetsGrid = () => { const { widgets } = calculateUserGridLayoutParams(...); return ( <Grid widgets={widgets} {...}> {(gridItem) => <UserWidget {...} />} </Grid> ); }; // А тут сама логика отрисовки сетки const Grid = ({ widgets, children }) => { return ( <div className={cx(classes.gridWrapper)} dir="ltr"> <ReactGridLayout {...}> {widgets.map((widget) => ( <CSSTransition key={widget.id} component={null} unmountOnExit > <div data-widget data-grid={{ i: widget.i, w: widget.w, h: widget.h, x: widget.x, y: widget.y, minW: widget.minW, minH: widget.minH, resizeHandles: widget.resizeHandles, isResizable: widget.isResizable, }} > {children(widget)} </div> </CSSTransition> ))} </ReactGridLayout> </div> ); };
При работе с множественными слоями виджетов возникали проблемы с z-index — активный виджет мог оказываться под другими элементами.
Решение
Реализовали динамическое управление z-index через глобальный счётчик:
// Глобальный счётчик для z-index let globalZIndex = 1000; const bringToFront = (widgetId) => { globalZIndex += 1; setWidgetZIndex(widgetId, globalZIndex); }; // При начале drag-операции поднимаем виджет наверх onDragStart={(layout, oldItem, newItem) => { bringToFront(newItem.i); }}
Стандартные resize handles RGL не всегда подходили под наш дизайн, особенно для небольших виджетов.
Решение
Создали собственные resize handles с кастомным позиционированием:
.react-resizable-handle { /* Убираем стандартные стили */ background: none; /* Добавляем собственные */ &::after { content: ''; position: absolute; right: 3px; bottom: 3px; width: 12px; height: 12px; background: url('resize-icon.svg') no-repeat; cursor: se-resize; } }
Здесь я кратко предлагаю ознакомиться с некоторыми подходами к производительности, виртуализации, обработке большого количества виджетов и другие решения для масштабируемых интерфейсов.
Следить за размерами контейнеров и своевременно реагировать на изменения размеров мы озадачили интерфейс ResizeObserver, он позволил нам не слушать все события подряд.
export const useResizeObserver = ( elRef: MutableRefObject<HTMLElement | null>, callback: ResizeObserverCallback, ) => { useEffect(() => { if (isUndefined(elRef.current) || isUndefined(ResizeObserver)) { return; } const resizeObserver = new ResizeObserver(callback); resizeObserver.observe(elRef.current); return () => { resizeObserver.disconnect(); }; }, [callback, elRef]); };
При 30+ виджетах начинались проблемы с производительностью, особенно при изменении layout.
Решение
Внедрили виртуализацию и ленивую загрузку:
// Виртуализация виджетов вне видимой области const VisualizedWidget = ({ widget, isVisible }) => { if (!isVisible) { return <div style={{ ...widget.style, opacity: 0 }} />; } return <LazyWidget widget={widget} />; }; // Ленивая загрузка компонентов виджетов const LazyWidget = lazy(() => import(`./widgets/${widget.type}`));
Также при большом количестве виджетов стандартный алгоритм поиска свободного места в RGL начинал тормозить. Особенно это было заметно при автоматическом размещении новых виджетов.
Решение
Внедрили пространственный индекс R-Bush для эффективного поиска коллизий:
// Создаём пространственный индекс для быстрого поиска коллизий const spatialIndex = new RBush(); widgets.forEach((widget) => { spatialIndex.insert({ minX: widget.x, minY: widget.y, maxX: widget.x + widget.w, maxY: widget.y + widget.h, widget, }); }); // Быстрый поиск пересечений для нового виджета const conflicts = spatialIndex.search({ minX: newWidget.x, minY: newWidget.y, maxX: newWidget.x + newWidget.w, maxY: newWidget.y + newWidget.h, });
Это ускорило размещение виджетов в 10+ раз при большом количестве элементов.
В каждом параметре для обработки пользовательских действий дергается колбек onLayoutChange. Даже в том случае, когда пользователь начал, но не стал менять позицию виджета все равно происходит «чих».
Решение
Нет смысла дергать обновление layout'а, если виджет никуда не перенесли
const Grid = () => { return ( <ReactGridLayout {...} onDragStop={(layout, oldItem, newItem, placeholder, event, mutableEl) => { if (oldItem.x !== newItem.x || oldItem.y !== newItem.y) { onLayoutChange(layout); } }} > {...} </ReactGridLayout> ); };
1. Почти все виджеты и списки обернуты в memo и используют useMemo для тяжелых вычислений и рендеров. Это снижает количество лишних перерисовок при изменении props или состояния. Лучше все возможные места завернуть в hooks и HOC, чтобы потом не искать места, где не закешировано изменение с простого типа на ссылочный да и еще и неоднократно вычисляемый.
2. Debounce/Throttle для событий. Используется для оптимизации частоты обновлений при изменении фильтров, ресайзе окна, скролле и других событий. Например, при ресайзе окна сетка пересчитывается с задержкой, чтобы не перегружать браузер.
3. Предотвращение коллизий и компактность. В grid-пропсах используется preventCollision и compactType={null} — это отключает автоматическое уплотнение и пересортировку, что ускоряет работу с большим количеством элементов.
4. Батч-обновления и иммутабельность. Все обновления фильтрации и layout'а делаются через иммутабельные операции и батч-сеты, чтобы не было лишних рендеров.
9. Контроль layout через props. Везде layout и его изменения передаются через props, а не через глобальное состояние — это позволяет оптимизировать рендер только нужных частей.
10. Использование RGL (react-grid-layout) с WidthProvider, который оборачивает grid и оптимизирует пересчет ширины контейнера.
1. React Grid Layout — мощная, но не идеальная библиотека. Готовьтесь к необходимости обходить её ограничения и баги.
2. Iframe и drag & drop несовместимы без дополнительных мер. Всегда предусматривайте overlay для блокировки событий.
3. Производительность критична при работе с большим количестом элементов. Пространственные индексы и виртуализация — must have.
4. Документируйте все хаки и обходные пути. Это сэкономит время при будущих обновлениях и рефакторинге.
5. Тестируйте на реальных объёмах данных. Поведение с 5 виджетами и 50 виджетами кардинально отличается.
Эти решения позволили нам создать стабильную и производительную систему управления виджетами, которая комфортно работает даже при большом количестве элементов на экране.
Это не была революция, где всё сломали и начали заново. Скорее, это была эволюция — постепенная, продуманная, с вниманием к мнению пользователей и пониманием собственных внутренних ограничений.
Мы прошли путь от простой карты с наложенными слоями до гибкого рабочего пространства с настраиваемыми и адаптивными виджетами.
Сегодня 2ГИС Про — это не просто инструмент для гео-аналитики. Это платформа, на которой любой может собрать интерфейс под свои конкретные задачи, комбинируя дашборды, виджеты, сцены и визуализации. А с помощью фильтрации данных каждый пользователь гарантировано получит заветные геопространственные ответы.
И если завтра понадобится добавить ещё одну карту, создать группу виджетов просто закинув один на другой, показать сразу несколько слоёв или поменять график на разговор с LLM-ботом — это уже не проблема, а просто следующий естественный шаг вперёд.