Недавно я вспомнил стартап, над которым работал в 2024 году. Мы делали что‑то вроде Pokemon GO на свой лад: пользователь должен был ходить по городу и собирать сундучки, из которых можно было получить скидку, промокод или специальное предложение от компании‑партнёра.
В какой‑то момент проект свернулся, а я ушёл в вольное плавание. Но из него у меня остался интересный и достаточно уникальный опыт — в ходе разработки пришлось довольно глубоко разобраться в том, как вообще работают карты в браузере.
Например, как по широте и долготе определить, какую именно часть карты нужно показать пользователю? Откуда берутся отдельные изображения карты и как они соединяются между собой? Как определить точное положение пользователя уже внутри полученного изображения?
В этой статье я хочу разобрать базовую механику web‑карт: пройти путь от обычной пары координат до конкретного тайла карты и позиции пользователя внутри него. Без готовых картографических библиотек — только координаты, немного математики и тайлы OpenStreetMap.
Что человек вообще видит на web‑карте?
В целом всю планету можно представить как одно изображение определённой ширины и высоты. Для масштаба, на котором мы видим только океаны и материки, особых проблем не возникает — такое изображение может быть относительно небольшим. Изображение без проблем встраивается в браузер.

Но при необходимости более детального масштаба возникает проблема — невозможно детально показать улицы и дома, сохранив при этом итоговое изображение в рамках разумного размера. Схема с одним изображением перестаёт быть жизнеспособной: карта размером в несколько десятков или даже сотен гигабайт будет слишком долго загружаться.
Для решения этой проблемы карту со всеми улицами, домами и другими объектами разделяют на небольшие квадратные изображения размером 256 × 256 пикселей (обычно). Такие кусочки называются тайлами карты. Серверу достаточно отдать только те фрагменты карты, которые нужны пользователю в данный момент, а их объединением в цельное изображение занимается уже клиентское приложение. Каждый тайл можно получить по отдельному URL, пример такого URL:
Из каких частей состоит адрес тайла?
Возьмем URL тайла openstreetmap (бесплатный сервис предоставления тайлов карты планеты): https://tile.openstreetmap.org/13/5765/2593.png
Часть с доменом и разрешением изображение не особо интересны — гораздо интереснее 3 числа.
13 — это показатель масштаба. Он определяет, каким количеством тайлов представлен мир по каждой из осей. При этом ширина и высота представления мира в тайлах одинаковы. Для получения количества тайлов по оси нужно воспользоваться формулой:
где N — количество тайлов по оси, а Z — тот самый показатель масштаба.
зная, что по обеим осям количество тайлов совпадает, можно узнать общее число тайлов, на которые разбит мир — достаточно умножить N на себя же (вспоминаем формулу площади квадрата).
давайте посчитаем значение общего количества тайлов для нескольких масштабов. Начнем с масштаба, равного 0.
Соответственно, при таком масштабе весь мир умещается в один тайл. Учитывая, что стандартное разрешение тайла 256 на 256 — единственное, что можно увидеть при такой детализации это континенты и океаны.
Перейдем к машстабу 13, получаем количество тайлов по каждой оси: 2 в степени 13 = 8192
Если представлять мир в более чем 60 миллионах тайлах 256 на 256 пикселей — это достаточно, чтобы в тайлы поместились уже дома и улицы.
Перейдем в числу 5765 — это номер тайла по оси x. В то время как 2593 — это номер тайла по оси y.
При этом тайл с координатами (0, 0) — это левый вехний тайл условного гигантского изображения мира.
Связь X тайла и долготы
Вспомним, что долгота — это значение в пределах от −180° до 180°. Нулевая долгота соответствует Гринвичскому меридиану, отрицательные значения находятся к западу от него, а положительные — к востоку.
Возьмем масштаб тайловой системы, равный 1 — тогда количество тайлов по ширине равно

Как видно, тайл со значением x = 0 граничит с долготой −180°. А тайл со значением = кол‑во тайлов — 1, граничит с долготой 180°.
Для того, чтобы получить значение тайла по соответствующей долготе для начала долготу нужно перевести из диапазона [−180°, 180°] в более привычные значения [0,1].
Превратим начальную точку диапазона в 0. То есть для значения долготы −180° мы должны получить значением 0 в новом диапазоне. Несложно догадаться, что для этого достаточно прибавить 180.
Теперь самая правая граница смещается до 360.
Для того, чтобы сузить диапазон до [0,1] достаточно разделить значение диапазона [0,360] на 360. Итоговая формула получения значения долготы в диапазоне [0,1] выглядит так:
longitude — изначальная долгота искомой точки.
Теперь нам нужно «растянуть» полученное значение в диапазоне [0,1] до диапазона [0, количество тайлов по x], для этого достаточно просто умножить полученное значение на количество тайлов. В javascript это выглядит так:
const xFloat = ((longitude + 180) / 360) * 2 ** zoom;
Так как долгота может быть представлена числом с десятичной частью, для получения номера тайла (учитывая, что номер тайла — число целое), необходимо отбросить дробную часть, иными словами округлить в меньшую сторону через Math.floor. Итоговый код для получения номера тайла через долготу выглядит так:
const tileCount = 2 ** zoom
const xFloat = ((longitude + 180) / 360) * tileCount;
const tileX = Math.floor(xFloat);
Связь Y тайла и широты
Казалось бы, для координаты Y можно проследовать тем же путём, что и для X. Однако здесь всё немного сложнее: карта представляет собой проекцию поверхности Земли на плоскость, поэтому связь между широтой и координатой Y уже не является линейной.
Для отображения тайлов OpenStreetMap (тайлы которого мы и берем) используется проекция Web Mercator. Именно она определяет, каким образом значение широты преобразуется в координату Y на карте.
Web Mercator — достаточно объёмная тема, поэтому подробно рассматривать её в рамках этой статьи мы не будем. Если вам интересно разобраться в ней глубже, советую прочитать статью другого автора на Хабре — в ней достаточно подробно разобраны устройство Web Mercator, используемые формулы и связанные с этой системой координат особенности.
Нам достаточно знать, что позиция по оси Y точки на карте по ее широте можно получить таким образом:
const n = 2 ** zoom;
const yFloat = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
Для получения Y тайла необходимо округлить позицию по оси Y в меньшую сторону (помним, что тайлы нумеруются целыми числами, а значит дробную часть необходимо отбросить).
const tileY = Math.floor(yFloat);
Отображение позиции внтутри тайла
Итак, код выше дает нам возможность определить внутри какого тайла находится искомая точка, но где именно она находится? Возможно на мосту, в речке, на одной из улиц и так далее
Для правильного отображения точки нам нужно не только знать тайл, но и позицию внутри него. Мы помним, что номер тайла мы получали через округление вниз:
const tileCount = 2 ** zoom
const xFloat = ((longitude + 180) / 360) * tileCount;
const tileX = Math.floor(xFloat);
Какую смысловую нагрузку несет дробная часть координаты точки в тайловой системе?
Она показывает позицию внутри тайла, что как раз нам и нужно:

Для определения позиции внутри тайла нам достаточно получить это значение после точки:
xFloat - tileX
Полученное значение находится в диапазоне от 0 до 1. Однако для отображения точки на изображении нам нужна её позиция в пикселях. Например, если точка находится ровно по центру тайла размером 256 × 256 пикселей, её координата по соответствующей оси должна быть равна 128 — половине размера тайла. Если она в начале тайла — ее позиция равна 0, а в конце — 256.
Для этого достаточно умножить полученное значение на размер тайла:
(xFloat - tileX) * size
size — размер тайла. Для стандартных тайлов OpenStreetMap он составляет 256 × 256 пикселей. Важно помнить: если при отображении вы изменили размер тайла, например растянули его в два раза, то при расчёте позиции точки необходимо использовать уже фактический размер отображаемого изображения.
Например, если тайл размером 256 × 256 пикселей был растянут до 512 × 512, то и положение точки внутри него необходимо рассчитывать относительно 512, а не 256.
(рассчет позиции Y идентичен, просто берем tileY)
Итоговая функция преобразования широты и долготы в номер тайла и позиции точки внутри него
/**
* @param latitude - широта точки
* @param longitude - долгота точки
* @param zoom - масштаб тайлов
* @param size - размер тайла
* @returns координаты тайла и точки внутри тайла
**/
export const getTitleCoordinates = (latitude: number, longitude: number, zoom: number, size: number) => {
const n = 2 ** zoom;
const tileCount = 2 ** zoom;
const xFloat = ((longitude + 180) / 360) * tileCount;
const tileX = Math.floor(xFloat);
const latRad = (latitude * Math.PI) / 180;
const yFloat = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n;
const tileY = Math.floor(yFloat);
return {
tileX,
tileY,
pixelX: (xFloat - tileX) * size,
pixelY: (yFloat - tileY) * size,
};
};
Зная номера тайла по обеим осям и положение точки внутри него, мы уже можем без проблем отобразить полученные данные в вёрстке. Ниже демонстрационный html‑файл:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<title>OSM Tile Demo</title>
</head>
<body>
<div>
tileX: <span id="tileX"></span>
</div>
<div>
tileY: <span id="tileY"></span>
</div>
<div>
pixelX: <span id="pixelX"></span>
</div>
<div>
pixelY: <span id="pixelY"></span>
</div>
<br />
<div
id="tile"
style="
position: relative;
width: 256px;
height: 256px;
"
>
<img
id="tileImage"
width="256"
height="256"
/>
<div
id="point"
style="
position: absolute;
width: 10px;
height: 10px;
background: red;
border-radius: 50%;
transform: translate(-50%, -50%);
"
></div>
</div>
<script>
const LATITUDE = 56.4846;
const LONGITUDE = 84.9482;
const ZOOM = 13;
const SIZE = 256;
const getTileCoordinates = (
latitude,
longitude,
zoom,
size
) => {
const tileCount = 2 ** zoom;
const xFloat =
((longitude + 180) / 360) * tileCount;
const tileX = Math.floor(xFloat);
const latRad =
(latitude * Math.PI) / 180;
const yFloat =
(
(
1 -
Math.log(
Math.tan(latRad) +
1 / Math.cos(latRad)
) / Math.PI
) / 2
) * tileCount;
const tileY = Math.floor(yFloat);
return {
tileX,
tileY,
pixelX:
(xFloat - tileX) * size,
pixelY:
(yFloat - tileY) * size,
};
};
const {
tileX,
tileY,
pixelX,
pixelY
} = getTileCoordinates(
LATITUDE,
LONGITUDE,
ZOOM,
SIZE
);
document.getElementById("tileX").textContent =
tileX;
document.getElementById("tileY").textContent =
tileY;
document.getElementById("pixelX").textContent =
pixelX.toFixed(2);
document.getElementById("pixelY").textContent =
pixelY.toFixed(2);
document.getElementById("tileImage").src =
`https://tile.openstreetmap.org/${ZOOM}/${tileX}/${tileY}.png`;
const point =
document.getElementById("point");
point.style.left = `${pixelX}px`;
point.style.top = `${pixelY}px`;
</script>
</body>
</html>
Автор: GreenFox_2003
