Плиточная карта России на React: прототип с drag & drop и zoom без библиотек
- вторник, 27 января 2026 г. в 00:00:05
Вновь здравствуйте, снова я — Дмитрий, React-разработчик. Все мы видели с вами вот такие карты, именуемые картойдами в данном случае — карта России, я взял первую попавшуюся из интернета для примера.

И вот возник вопрос, как такое сделать, и сделать без использования библиотек? А давайте попробуем!
В этой статье я покажу, как сделать интерактивную карту России на React с плитками, поддержкой drag & drop и масштабированием, без внешних библиотек.
Это будет базовый набросок React + JS для взаимодействий с мышью.
Набросаем требования:
Карта должна быть реализована на React.
Карта состоит из плиток, каждая из которых соответствует региону России.
Плитки располагаются по координатной сетке (x, y), заданной в массиве regions.
Размер и цвет плитки зависят от значения показателя региона (value).
Карта должна быть интерактивной: поддержка перетаскивания и масштабирования.
Каждая плитка:
Имеет фиксированный размер с возможностью масштабирования.
В плитке отображается код региона (code).
При наведении отображается title с названием и значением региона (name и value).
Размер плитки может масштабироваться пропорционально значению региона через функцию.
Пользователь может перемещать карту, удерживая левую кнопку мыши.
Пользователь может приближать и отдалять карту
Для визуализации карты России мы используем массив объектов regions, где каждая запись описывает отдельный регион. Каждый объект содержит следующие поля:
{
code: string; // Код региона, я использовал аббревиатуру, но можно использовать цифровой код
name: string; // Полное название региона
x: number; // Координата плитки по горизонтали (сеточная позиция)
y: number; // Координата плитки по вертикали (сеточная позиция)
value: number; // Числовой показатель для визуализации (например, население или продажи)
}Для отображения карты создается два основных контейнера:
1.Viewport — видимая область карты, в которой происходит масштабирование и перетаскивание.
2.Map — контейнер для всех плиток (tiles), который мы трансформируем с помощью CSS (translate и scale) в зависимости от состояния.
Вот как это выглядит в JSX:
<div ref={viewportRef} className="viewport">
<div className="interaction-layer" />
<div
ref={mapRef}
className="map"
style={{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})` }}
>
{regions.map((r) => {
const scale = Math.sqrt(r.value / maxValue);
const size = TILE * clamp(scale, 1, 1);
const mainColor = colorByValue(r.value / maxValue);
const lightColor = lightenColor(mainColor, 15);
return (
<div
key={r.code}
className="tile"
title={`${r.name}: ${r.value}`}
style={{
width: `${size}px`,
height: `${size}px`,
left: `${r.x * TILE}px`,
top: `${r.y * TILE}px`,
transform: "translate(-50%, -50%)",
background: `linear-gradient(135deg, ${mainColor}, ${lightColor})`,
}}
onClick={() => console.log('123')}
>
<div style={{ padding: 4, display: 'flex', flexDirection: 'row', justifyContent: 'space-between', flex: 1 }}>
<div style={{fontWeight: 'bold', fontSize: 11}}>{r.code}</div>
</div>
</div>
);
})}
</div>
<div className="legend"></div>
</div>Давайте коротко поясню, что здесь происходит по задумке.
Viewport - контейнер фиксированного размера, который ограничивает видимую область карты. На него вешаются события мыши для перетаскивания и масштабирования.
Так же нам потребуется interaction-layer - прозрачный слой поверх карты, который используется для перехвата событий, подсветки регионов и других интерактивных элементов.
Map - Содержит все плитки. Именно этот контейнер трансформируется при перетаскивании и масштабировании:
style={{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})` }}tile – это каждая плитка представляет отдельный регион, позиционируется по координатам x и y, умноженным на базовый размер плитки TILE.
Внутри плитки отображается код региона, а при наведении показывается title с полным названием и значением.
Legend - заготовка для легенды, пока пустой контейнер
Чтобы карта была не только информативной, но и визуально наглядной, используем цветовую дифференциацию наших плиточек по значению показателя региона.
Основной цвет каждой плитки вычисляется функцией colorByValue, которая переводит числовое значение региона в цветовую шкалу в формате HSL:
const colorByValue = (t: number) => {
const hue = 190 - t * 180;
return `hsl(${hue}, 80%, 60%)`;
}Эта функция принимает значение t – в коде будем передавать в нее нормализованное значение региона вида (r.value / maxValue), где maxValue — максимальное значение среди всех регионов. Цветовая шкала в данном случае идет от светло-голубого, что является наименьшем значением, до - бирюзового, который имеет наибольшее значение. HSL нам позволяет легко изменять яркость и насыщенность для создания градиентов.
Есть еще второй цвет градиента, я его сделал более светлым, в тех же тонах, для небольшого объема, вычисляется он вот так:
const lightenColor = (color: string, amount: number = 10) => {
const hsl = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!hsl) return color;
const h = Number(hsl[1]);
const s = Number(hsl[2]);
let l = Number(hsl[3]);
l = clamp(l + amount, 0, 100);
return `hsl(${h}, ${s}%, ${l}%)`;
}Функция извлекает Hue, Saturation, Lightness из строки HSL и просто увеличивает Lightness на amount процентов, чтобы получить более светлый оттенок, при этом clamp гарантирует, что значение света остается в диапазоне 0–100%.
Далее в коде вычисляются первый цвет и второй, в этих строчках:
const mainColor = colorByValue(r.value / maxValue);
const lightColor = lightenColor(mainColor, 15);Этот подход с использованием HSL очень гибкий и визуально наглядный.
Помимо цвета, каждую плитку можно масштабировать в зависимости от значения региона. Это позволяет представлять данные не только цветом, но и размером.
Базовый размер плитки я взял 50px. Дальнейшие вычисления будут строятся относительно этого значения.
const TILE = 50;Далее простая математика, чтобы корректно сравнивать регионы между собой, значения нормализуются относительно максимального
const maxValue = Math.max(...regions.map((r) => r.value));и для каждой плитки вычисляется коэффициент масштаба
const scale = Math.sqrt(r.value / maxValue);Использование квадратного корня делает различия менее разнообразными, менее агрессивными, чем линейная зависимость, и помогает избежать слишком больших визуальных перекосов.
В текущей версии карты размер плиток зафиксирован, мне просто так больше нравится, как выглядит.
const size = TILE * clamp(scale, 1, 1);
Но при надобности карта может выглядеть и вот так:

А еще каждый регион можно сделать прямоугольным, а не квадратным, чтобы вместить дополнительную информацию. Но мне нравится первый вариант, поэтому масштабирование плиток пока просто есть, но не используется.
В коде за это отвечает вот эта строчка:
const size = TILE * clamp(scale, 0.7, 1.6);Но я, как уже сказал, использую пока без масштабирования, т.е:
const size = TILE * clamp(scale, 1, 1);Для удобной навигации по карте нужно реализовать перетаскивание (drag & drop) с помощью мыши. Пользователь может зажать левую кнопку мыши и свободно перемещать карту в пределах вьюпорта. Делается это с помощью с помощью CSS transform: translate(...).
Положение карты хранится в стейте, в котором мы храним объект с текущими координатами и масштаб.
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });Для отслеживания процесса перетаскивания используются useRef, чтобы не вызывать лишние перерендеры. dragging — флаг, указывающий, что кнопка мыши зажата, а lastMouse — последняя зафиксированная позиция курсора.
const dragging = useRef(false);
const lastMouse = useRef({ x: 0, y: 0 });При нажатии кнопки мыши на вьюпорте мы фиксируем стартовую точку и ожидаем, что карту будут перемещать.
const handleMouseDown = (e: MouseEvent) => {
dragging.current = true;
lastMouse.current = { x: e.clientX, y: e.clientY };
};Во время движения мыши вычисляется разница между текущей и предыдущей позицией курсора
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const dx = e.clientX - lastMouse.current.x;
const dy = e.clientY - lastMouse.current.y;
setPos((p) => ({ ...p, x: p.x + dx, y: p.y + dy }));
lastMouse.current = { x: e.clientX, y: e.clientY };
};Таким образом получаем все данные о перемещении карты мышкой. И в завершении этого действия, когда кнопка мыши отпускается, процесс перетаскивания останавливается, за это отвечает событие, которое отслеживается на уровне window, чтобы перетаскивание корректно завершалось даже если курсор вышел за пределы карты
const handleMouseUp = () => {
dragging.current = false;
};Осталось навесить все события внутри useEffect и не забыть корректно уделить их при размонтировании компонента.
viewport.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
viewport.addEventListener("wheel", handleWheel, { passive: false });Также для удобной навигации по карте прикрутил масштабирование с помощью колесика мыши. Масштабирование происходит относительно позиции курсора, а не центра экрана.
Общий принцип строится на том, что масштабирование изменяет не только коэффициент scale, но и смещение карты по x и y таким образом, чтобы точка под курсором оставалась на том же месте во вьюпорте. Для этого используем тот же стейт:
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });Событие масштабирования навешивается на контейнер viewport:
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const viewport = viewportRef.current;
const map = mapRef.current;
if (!viewport || !map) return;
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
setPos((p) => {
const newScale = clamp(p.scale * (e.deltaY > 0 ? 0.9 : 1.1), 1, 4);
const offsetX = (mouseX - p.x) / p.scale;
const offsetY = (mouseY - p.y) / p.scale;
const newX = mouseX - offsetX * newScale;
const newY = mouseY - offsetY * newScale;
return { x: newX, y: newY, scale: newScale };
});
}; И не забываем отключить стандартный скролл страницы, используя preventDefault().
Сначала определяем положение курсора относительно вьюпорта, это позволяет точно понять, в какой точке пользователь инициировал масштабирование.
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;Масштаб ограничивается допустимыми значениями, диапазон масштабирования выбран интуитивно мной и установлен на значения от 1 до 4. Происходит это в следующей строчке:
const newScale = clamp(p.scale * (e.deltaY > 0 ? 0.9 : 1.1), 1, 4);Подумать для TODO: можно вынести в пропс, также как и в некоторые другие параметры.
Далее нам нужно пересчитать смещение карты.
const offsetX = (mouseX - p.x) / p.scale;
const offsetY = (mouseY - p.y) / p.scale;
const newX = mouseX - offsetX * newScale;
const newY = mouseY - offsetY * newScale;Сначала определяем координаты курсора в системе координат карты. После изменения масштаба смещаем карту так, чтобы эта точка осталась под курсором. Если этого не сделать, будет ощущение, что смещается карта непонятно куда.
Все вычисления выполняются внутри setPos, чтобы использовать актуальное состояние, а дальше трансформация применяется через CSS строкой:
И все работает, так как это мини-проект в обучающих целях, а не Production Ready-компонент, то на этом остановимся. Но, в целом, можно добавить:
плавную анимацию масштабирования (transition или requestAnimationFrame).
Реализовать zoom по двойному клику.
Добавить кнопки + / − для управления масштабом.
Поддержать pinch-zoom на тач-устройствах.
Финальный код у меня получился вот такой:
import React, { useRef, useEffect, useState } from "react";
import "./App.css";
import { regions } from "./regions";
const TILE = 50;
const clamp = (v: number, min: number, max: number) => {
return Math.min(max, Math.max(min, v));
}
const colorByValue = (t: number) => {
const hue = 190 - t * 180;
return `hsl(${hue}, 80%, 60%)`;
}
const lightenColor = (color: string, amount: number = 10) => {
const hsl = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
if (!hsl) return color;
const h = Number(hsl[1]);
const s = Number(hsl[2]);
let l = Number(hsl[3]);
l = clamp(l + amount, 0, 100);
return `hsl(${h}, ${s}%, ${l}%)`;
}
const App = () => {
const mapRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });
const dragging = useRef(false);
const lastMouse = useRef({ x: 0, y: 0 });
const maxValue = Math.max(...regions.map((r) => r.value));
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const handleMouseDown = (e: MouseEvent) => {
dragging.current = true;
lastMouse.current = { x: e.clientX, y: e.clientY };
};
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current) return;
const dx = e.clientX - lastMouse.current.x;
const dy = e.clientY - lastMouse.current.y;
setPos((p) => ({ ...p, x: p.x + dx, y: p.y + dy }));
lastMouse.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
dragging.current = false;
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const viewport = viewportRef.current;
const map = mapRef.current;
if (!viewport || !map) return;
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
setPos((p) => {
const newScale = clamp(p.scale * (e.deltaY > 0 ? 0.9 : 1.1), 1, 4);
const offsetX = (mouseX - p.x) / p.scale;
const offsetY = (mouseY - p.y) / p.scale;
const newX = mouseX - offsetX * newScale;
const newY = mouseY - offsetY * newScale;
return { x: newX, y: newY, scale: newScale };
});
};
viewport.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => {
viewport.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
viewport.removeEventListener("wheel", handleWheel);
};
}, []);
return (
<div ref={viewportRef} className="viewport">
<div className="interaction-layer" />
<div
ref={mapRef}
className="map"
style={{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})` }}
>
{regions.map((r) => {
const scale = Math.sqrt(r.value / maxValue);
// const size = TILE * clamp(scale, 0.7, 1.6);
const size = TILE * clamp(scale, 1, 1);
const mainColor = colorByValue(r.value / maxValue);
const lightColor = lightenColor(mainColor, 15);
return (
<div
key={r.code}
className="tile"
title={`${r.name}: ${r.value}`}
style={{
width: `${size}px`,
height: `${size}px`,
left: `${r.x * TILE}px`,
top: `${r.y * TILE}px`,
transform: "translate(-50%, -50%)",
background: `linear-gradient(135deg, ${mainColor}, ${lightColor})`,
}}
onClick={() => console.log('123')}
>
<div style={{ padding: 4, display: 'flex', flexDirection: 'row', justifyContent: 'space-between', flex: 1 }}>
<div style={{fontWeight: 'bold', fontSize: 11}}>{r.code}</div>
</div>
</div>
);
})}
</div>
<div className="legend"></div>
</div>
);
}
export default App;В этой статье я решал одну из своих задач и идей в голове. Мне было интересно понять, как это сделать — и у меня получилось! Я доволен результатом и хочу поделиться им с вами.
Обращу внимание, что позиции регионов полностью настраиваемые. Каждая плитка соответствует объекту в массиве regions, и при необходимости вы можете менять расположение регионов или переставлять их местами, не затрагивая остальную логику карты.
Пример для нескольких областей:В итоге я сделал заготовку интерактивной карты России на React в стиле «картойд», которая дополнительно имеет возможности, масштабирования, перетаскивания, визуализацию через градиенты HSL и гибкую структуру плиток в плане позиций и отображения данных.
Для тех, кто любит, говорить: "А вот можно еще то и вот то сделать..."
Отвечу: "Да, я знаю, что приходит на ум в плане доработок и улучшений. Но оставлю это вам на домашнее задание. Целью статьи была заготовка и понимание «как». Считаю, что цель достигнута!"
Что реализовано в компоненте:
Все важные переменные вынесены в пропсы и сделан отдельный компонент
Отображение числовых данных внутри плиток, например: население, продажи, рейтинг и т.п.
Tooltip с дополнительной информацией: при наведении отображаются более детальные данные региона.
Предусмотрена анимация при масштабировании (hover-эффекты): плавное увеличение плитки при наведении или плавный переход при zoom.
Легенда с градиентом и пояснением цветов для наглядного понимания карты пользователями.
Потыкать на https://codepen.io/Nun4aku/pen/LEZxMBY