Недавно я вспомнил стартап, над которым работал в 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>

