Недавно я вспомнил стартап, над которым работал в 2024 году. Мы делали что‑то вроде Pokemon GO на свой лад: пользователь должен был ходить по городу и собирать сундучки, из которых можно было получить скидку, промокод или специальное предложение от компании‑партнёра.

В какой‑то момент проект свернулся, а я ушёл в вольное плавание. Но из него у меня остался интересный и достаточно уникальный опыт — в ходе разработки пришлось довольно глубоко разобраться в том, как вообще работают карты в браузере.

Например, как по широте и долготе определить, какую именно часть карты нужно показать пользователю? Откуда берутся отдельные изображения карты и как они соединяются между собой? Как определить точное положение пользователя уже внутри полученного изображения?

В этой статье я хочу разобрать базовую механику web‑карт: пройти путь от обычной пары координат до конкретного тайла карты и позиции пользователя внутри него. Без готовых картографических библиотек — только координаты, немного математики и тайлы OpenStreetMap.

Что человек вообще видит на web‑карте?

В целом всю планету можно представить как одно изображение определённой ширины и высоты. Для масштаба, на котором мы видим только океаны и материки, особых проблем не возникает — такое изображение может быть относительно небольшим. Изображение без проблем встраивается в браузер.

Но при необходимости более детального масштаба возникает проблема — невозможно детально показать улицы и дома, сохранив при этом итоговое изображение в рамках разумного размера. Схема с одним изображением перестаёт быть жизнеспособной: карта размером в несколько десятков или даже сотен гигабайт будет слишком долго загружаться.

Для решения этой проблемы карту со всеми улицами, домами и другими объектами разделяют на небольшие квадратные изображения размером 256 × 256 пикселей (обычно). Такие кусочки называются тайлами карты. Серверу достаточно отдать только те фрагменты карты, которые нужны пользователю в данный момент, а их объединением в цельное изображение занимается уже клиентское приложение. Каждый тайл можно получить по отдельному URL, пример такого URL:

https://tile.openstreetmap.org/13/5765/2593.png

Из каких частей состоит адрес тайла?

Возьмем URL тайла openstreetmap (бесплатный сервис предоставления тайлов карты планеты): https://tile.openstreetmap.org/13/5765/2593.png

Часть с доменом и разрешением изображение не особо интересны — гораздо интереснее 3 числа.

13 — это показатель масштаба. Он определяет, каким количеством тайлов представлен мир по каждой из осей. При этом ширина и высота представления мира в тайлах одинаковы. Для получения количества тайлов по оси нужно воспользоваться формулой:

N = 2^Z

где N — количество тайлов по оси, а Z — тот самый показатель масштаба.

зная, что по обеим осям количество тайлов совпадает, можно узнать общее число тайлов, на которые разбит мир — достаточно умножить N на себя же (вспоминаем формулу площади квадрата).

давайте посчитаем значение общего количества тайлов для нескольких масштабов. Начнем с масштаба, равного 0.

N = 2^0 = 1S = 1^2=1

Соответственно, при таком масштабе весь мир умещается в один тайл. Учитывая, что стандартное разрешение тайла 256 на 256 — единственное, что можно увидеть при такой детализации это континенты и океаны.

Перейдем к машстабу 13, получаем количество тайлов по каждой оси: 2 в степени 13 = 8192

S = 8192 × 8192 = 67.108.864

Если представлять мир в более чем 60 миллионах тайлах 256 на 256 пикселей — это достаточно, чтобы в тайлы поместились уже дома и улицы.

Перейдем в числу 5765 — это номер тайла по оси x. В то время как 2593 — это номер тайла по оси y.

При этом тайл с координатами (0, 0) — это левый вехний тайл условного гигантского изображения мира.

Связь X тайла и долготы

Вспомним, что долгота — это значение в пределах от −180° до 180°. Нулевая долгота соответствует Гринвичскому меридиану, отрицательные значения находятся к западу от него, а положительные — к востоку.

Возьмем масштаб тайловой системы, равный 1 — тогда количество тайлов по ширине равно 2^1 = 2

Как видно, тайл со значением x = 0 граничит с долготой −180°. А тайл со значением = кол‑во тайлов — 1, граничит с долготой 180°.

Для того, чтобы получить значение тайла по соответствующей долготе для начала долготу нужно перевести из диапазона [−180°, 180°] в более привычные значения [0,1].

Превратим начальную точку диапазона в 0. То есть для значения долготы −180° мы должны получить значением 0 в новом диапазоне. Несложно догадаться, что для этого достаточно прибавить 180.

- 180 + 180 = 0

Теперь самая правая граница смещается до 360.

180 + 180 = 360

Для того, чтобы сузить диапазон до [0,1] достаточно разделить значение диапазона [0,360] на 360. Итоговая формула получения значения долготы в диапазоне [0,1] выглядит так:

((longitude + 180) / 360)

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);

Отображение позиции внтутри тайла

Итак, код выше дает нам возможность определить внутри какого тайла находится искомая точка, но где именно она находится? Возможно на мосту, в речке, на одной из улиц и так далее

https://tile.openstreetmap.org/13/5765/2593.png

Для правильного отображения точки нам нужно не только знать тайл, но и позицию внутри него. Мы помним, что номер тайла мы получали через округление вниз:

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>