Web‑карты: от координат до изображения

—

от автора

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

ссылка на оригинал статьи https://habr.com/ru/articles/1070122/