«Найти ближайший пункт выдачи», «показать погоду там, где я сейчас», «подставить город в форму доставки» — за всеми такими кнопками стоит один и тот же браузерный интерфейс, Geolocation API. Он спрашивает у устройства, где оно находится, и возвращает широту, долготу и оценку точности. Никаких библиотек и ключей для этого не нужно, достаточно объекта navigator.geolocation.

Ниже разберём весь путь: откуда браузер вообще берёт координаты, как сделать первый запрос и обработать отказ, как узнать о разрешении заранее, как нарисовать точку на карте и что поменял новый HTML-элемент <geolocation>.

Откуда браузер знает, где пользователь

Координаты не берутся из одного источника. Браузер (точнее, операционная система под ним) собирает их из того, что есть под рукой:

  • GPS (спутниковая навигация) — самый точный вариант, до нескольких метров, но есть в основном на телефонах и плохо работает в помещении;
  • Wi-Fi — по списку видимых точек доступа и базе их расположения; в городе даёт десятки метров;
  • вышки сотовой связи — сотни метров или километры;
  • IP-адрес — самый грубый способ, иногда промахивается на целый город.

Отсюда главное практическое следствие: у каждого ответа есть поле accuracy — радиус погрешности в метрах. Ноутбук без GPS спокойно вернёт точку с погрешностью в 2–3 километра, и это не ошибка, а честная оценка. Код, который ставит маркер «вы здесь» и молча игнорирует погрешность, будет врать пользователю.

Второе ограничение — API работает только в безопасном контексте: на страницах по HTTPS (защищённому протоколу) и на localhost для разработки. На обычном HTTP запрос сразу заканчивается ошибкой. И третье — без согласия пользователя координат не будет никогда: браузер обязательно показывает запрос разрешения, а отказ — такой же штатный сценарий, как и успех.

Запрос браузера на доступ к местоположению: разрешить для сайта, разрешить один раз или запретить

Поддержка Geolocation API
chrome
Chrome
5
firefox
Firefox
3.5
edge
Edge
12
safari
Safari
5
opera
Opera
10.6

Первый запрос: getCurrentPosition

Основной метод — getCurrentPosition(). Он ничего не возвращает: результат приходит в колбэк успеха, проблема — в колбэк ошибки. Минимальная версия с кнопкой и блоком для вывода:

<button type="button" id="locate-btn">Где я?</button>
<p id="locate-status"></p>
const statusEl = document.querySelector('#locate-status');

function onSuccess(position) {
  const { latitude, longitude, accuracy } = position.coords;
  statusEl.textContent =
    `Широта ${latitude.toFixed(5)}, долгота ${longitude.toFixed(5)} (±${Math.round(accuracy)} м)`;
}

function onError(error) {
  statusEl.textContent = describeGeoError(error);
}

document.querySelector('#locate-btn').addEventListener('click', () => {
  if (!('geolocation' in navigator)) {
    statusEl.textContent = 'Браузер не умеет определять местоположение';
    return;
  }

  statusEl.textContent = 'Определяем местоположение…';
  navigator.geolocation.getCurrentPosition(onSuccess, onError);
});

Строка «Определяем местоположение» тут не для красоты: ответ приходит не мгновенно — сначала пользователь думает над запросом разрешения, потом устройство ищет спутники или Wi-Fi. Без промежуточного статуса кнопка выглядит сломанной.

Что лежит в ответе

В колбэк успеха приходит объект GeolocationPosition с двумя полями: timestamp (момент измерения) и coords. Внутри coords:

  • latitude и longitude — широта и долгота в градусах;
  • accuracy — погрешность в метрах, есть всегда;
  • altitude, altitudeAccuracy — высота и её погрешность;
  • heading — направление движения в градусах от севера;
  • speed — скорость в метрах в секунду.

Последние пять полей устройство заполняет, только если умеет их измерять. На ноутбуке они почти всегда равны null, так что код должен быть к этому готов.

Долгое время у этих объектов была неприятная особенность: JSON.stringify(position) возвращал пустой объект {}, потому что все поля живут на прототипе, а не на самом объекте. Приходилось перекладывать координаты в обычный объект вручную. Теперь у GeolocationPosition и GeolocationCoordinates есть метод toJSON(), и позицию можно сразу отправить на сервер:

navigator.geolocation.getCurrentPosition((position) => {
  fetch('/api/nearest-store', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(position),
    // {"timestamp":1767300000000,"coords":{"latitude":50.45,"longitude":30.52,"accuracy":35,...}}
  });
});

В старых браузерах такой код молча отправит {"timestamp":…,"coords":{}} или вовсе пустой объект, поэтому, если аудитория широкая, надёжнее собрать нужные поля вручную.

Поддержка GeolocationPosition.toJSON()
chrome
Chrome
126
firefox
Firefox
129
edge
Edge
126
safari
Safari
18
opera
Opera
112

Три вида ошибок

В колбэк ошибки приходит GeolocationPositionError с числовым полем code и техническим message. Кодов по спецификации ровно три, и у каждого есть именованная константа — сравнивать с ней читабельнее, чем с голыми единицей, двойкой и тройкой:

function describeGeoError(error) {
  switch (error.code) {
    case error.PERMISSION_DENIED:     // 1
      return 'Доступ к местоположению запрещён. Его можно включить в настройках сайта.';
    case error.POSITION_UNAVAILABLE:  // 2
      return 'Устройство не смогло определить координаты. Попробуйте позже или у окна.';
    case error.TIMEOUT:               // 3
      return 'Не дождались ответа от устройства.';
    default:
      return 'Что-то пошло не так: ' + error.message;
  }
}

Ветка default — на всякий случай: новых кодов в спецификации нет, но защититься от неожиданного ничего не стоит. Поле message пользователю лучше не показывать — там отладочный текст на английском.

Опции: точность, таймаут и кэш

Третий аргумент getCurrentPosition() — объект настроек. Про него часто забывают, а зря: значения по умолчанию подходят далеко не всем сценариям.

navigator.geolocation.getCurrentPosition(onSuccess, onError, {
  enableHighAccuracy: true, // по умолчанию false
  timeout: 10_000,          // по умолчанию Infinity
  maximumAge: 60_000,       // по умолчанию 0
});
  • enableHighAccuracy — просьба дать самые точные координаты. На телефоне это обычно значит включить GPS: ответ медленнее, батарея тратится быстрее. Для «в каком я городе» не нужно, для навигации — нужно.
  • timeout — сколько миллисекунд ждать ответа. По умолчанию Infinity, то есть ждать вечно: если устройство не может поймать сигнал, колбэк не вызовется никогда, и интерфейс зависнет на «Определяем…». Ставить таймаут стоит почти всегда. Важно, что отсчёт начинается после того, как пользователь дал разрешение, а не с момента вызова.
  • maximumAge — насколько старые закэшированные координаты согласны принять. При 0 браузер каждый раз меряет заново, при 60_000 отдаст позицию минутной давности мгновенно, если она есть.

С колбэками неудобно работать в async-коде, поэтому метод часто оборачивают в промис. Обёртка короткая и переиспользуется во всём проекте:

function getPosition(options = {}) {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject, {
      timeout: 10_000,
      ...options,
    });
  });
}

async function fillCityField() {
  try {
    const { coords } = await getPosition({ maximumAge: 300_000 });
    cityInput.value = await lookupCity(coords.latitude, coords.longitude);
  } catch (error) {
    // пользователь отказал или не дождались — оставляем поле пустым
  }
}

Сначала проверить, потом спрашивать

Самая частая ошибка с геолокацией — вызвать getCurrentPosition() прямо при загрузке страницы. Пользователь ещё не понял, что это за сайт, а у него уже просят местоположение; рефлекторный ответ — «Блокировать». И это решение надолго: после отказа сайт больше не может показать запрос, каждый следующий вызов сразу падает в PERMISSION_DENIED, а вернуть доступ пользователь может только сам, через настройки сайта.

Правильный порядок такой: сначала узнать текущее состояние разрешения, а запрос показывать по клику, объяснив, зачем нужны координаты. Состояние отдаёт Permissions API:

const permission = await navigator.permissions.query({ name: 'geolocation' });

function render(state) {
  if (state === 'granted') {
    // разрешение уже есть — можно определять сразу, без кнопки
    locateAndShow();
  } else if (state === 'prompt') {
    // ещё не спрашивали — показываем кнопку с пояснением
    showLocateButton('Покажем ближайшие пункты выдачи');
  } else {
    // 'denied' — запрос не появится, подсказываем, где включить
    showManualCityInput();
  }
}

render(permission.state);

// пользователь может поменять решение в настройках, не перезагружая страницу
permission.addEventListener('change', () => render(permission.state));

Три состояния — granted (разрешено), prompt (ещё не спрашивали) и denied (запрещено). Сам по себе query() никакого запроса пользователю не показывает, это просто справка. А на ветку denied всегда стоит иметь запасной путь — ручной выбор города, поле для адреса — иначе функция сайта для этого пользователя просто перестаёт существовать.

Поддержка Permissions API: query()
chrome
Chrome
43
firefox
Firefox
46
edge
Edge
79
safari
Safari
16
opera
Opera
30

Слежение за перемещением: watchPosition

Если координаты нужны не один раз, а постоянно — трекер пробежки, курьер на карте, «до остановки 300 метров», — вместо повторных вызовов getCurrentPosition() используют watchPosition(). Сигнатура та же, но колбэк успеха вызывается каждый раз, когда позиция меняется. Метод возвращает числовой идентификатор, по которому слежение потом выключают:

let watchId = null;

startBtn.addEventListener('click', () => {
  watchId = navigator.geolocation.watchPosition(
    ({ coords }) => updateRunnerMarker(coords.latitude, coords.longitude),
    onError,
    { enableHighAccuracy: true, maximumAge: 5_000 }
  );
});

stopBtn.addEventListener('click', () => {
  navigator.geolocation.clearWatch(watchId);
  watchId = null;
});

Слежение с enableHighAccuracy держит GPS включённым и заметно садит батарею, поэтому clearWatch() нужно вызывать, как только данные перестали быть нужны: пользователь ушёл с экрана карты, закончил тренировку, закрыл модалку.

Точка на карте: Leaflet и OpenStreetMap

Пара чисел с пятью знаками после запятой мало что говорит человеку — координаты хочется увидеть на карте. Самый лёгкий путь без ключей и регистрации — библиотека Leaflet плюс тайлы (квадратные фрагменты карты) из OpenStreetMap, открытой карты, которую наполняет сообщество.

Подключаем стили и скрипт стабильной версии и готовим контейнер:

<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<div id="user-map"></div>
#user-map {
  height: 360px; /* без явной высоты карта схлопнется в 0 */
}

Комментарий в CSS — не формальность. Leaflet растягивает карту на размер контейнера, а у пустого <div> высота нулевая. Это самая частая причина вопроса «почему карта не показывается». Родственная ловушка: если контейнер был скрыт через display: none в момент создания карты, после показа нужно вызвать map.invalidateSize(), иначе тайлы лягут только в угол.

Дальше — создаём карту в колбэке успеха, ставим маркер и рисуем круг погрешности:

function showOnMap({ coords }) {
  const point = [coords.latitude, coords.longitude];

  const map = L.map('user-map').setView(point, 15);

  L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
    maxZoom: 19,
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
  }).addTo(map);

  L.marker(point).addTo(map).bindPopup('Вы примерно здесь');

  const accuracyCircle = L.circle(point, {
    radius: coords.accuracy, // в метрах, прямо из ответа API
    weight: 1,
    fillOpacity: 0.15,
  }).addTo(map);

  // если погрешность большая, отдаляем карту, чтобы круг поместился целиком
  map.fitBounds(accuracyCircle.getBounds());
}

navigator.geolocation.getCurrentPosition(showOnMap, onError, { timeout: 10_000 });

Круг погрешности — то, чего не хватает большинству туториалов. На телефоне с GPS он почти совпадает с маркером, на ноутбуке может накрыть полгорода, и пользователь сразу видит, насколько точке можно верить. Именно так ведут себя карты в навигаторах.

Правила пользования тайлами

Тайловый сервер OpenStreetMap бесплатный, но не безусловный. Из правил пользования, которые чаще всего нарушают в учебных примерах:

  • Адрес — только https://tile.openstreetmap.org/ без поддоменов. Старый шаблон с {s} (поддомены a, b, c) ещё кочует по статьям, но эти адреса могут отключить без предупреждения.
  • Атрибуция — подпись «© OpenStreetMap» должна быть видна прямо на карте. Прятать её стилями или за переключателем нельзя.
  • Referer (заголовок, по которому сервер видит, с какого сайта пришёл запрос) — должен отправляться. Слишком строгая Referrer-Policy на сайте приводит к блокировке.
  • Нагрузка — массово скачивать тайлы, качать их заранее для офлайна и строить на этом сервере высоконагруженный продукт нельзя. Для продакшена с большим трафиком берут коммерческого поставщика тайлов или поднимают свой сервер.

Пара слов про версии. Примеры выше написаны для Leaflet 1.9.4 — это актуальная стабильная ветка. Параллельно идёт работа над Leaflet 2.0: он пока в статусе alpha, переходит на ES-модули и классы вместо фабричных функций (вместо L.map() и L.marker() — импортируемые LeafletMap и Marker). Переписывать код на него в продакшене стоит после стабильного релиза.

Элемент <geolocation>: запрос без JavaScript

Связка «кнопка → обработчик → getCurrentPosition() → два колбэка» давно стала шаблоном, и у неё есть общая проблема: браузер не может отличить осмысленный клик пользователя от скрипта, который дёрнул запрос сам. Отсюда и вал запросов при загрузке, и привычка пользователей не глядя нажимать «Блокировать».

Chrome 144 добавил декларативную альтернативу — HTML-элемент <geolocation>. Это кнопка, которую рисует сам браузер: текст и иконку задаёт он, а стили страницы могут поменять цвета и размеры, но не сделать кнопку невидимой, полупрозрачной или неконтрастной. Поэтому клик по ней — гарантированно осознанное действие, и браузер доверяет ему больше, чем обычной кнопке.

<geolocation id="geo" accuracymode="precise">
  <!-- фолбэк: его увидят браузеры, которые элемент не знают -->
  <button type="button" id="geo-fallback">Показать, где я</button>
</geolocation>
const geoEl = document.querySelector('#geo');

geoEl.addEventListener('location', () => {
  if (geoEl.position) {
    showOnMap(geoEl.position);          // тот же GeolocationPosition
  } else if (geoEl.error) {
    statusEl.textContent = describeGeoError(geoEl.error);
  }
});

// для браузеров без элемента внутренняя кнопка работает по-старому
document.querySelector('#geo-fallback').addEventListener('click', () => {
  navigator.geolocation.getCurrentPosition(showOnMap, onError, { timeout: 10_000 });
});

Что умеет элемент:

  • атрибут accuracymode — approximate (по умолчанию) или precise, аналог enableHighAccuracy;
  • атрибут autolocate — если разрешение уже выдано, координаты запрашиваются сразу при загрузке, без клика;
  • атрибут watch — превращает разовый запрос в слежение, как watchPosition();
  • событие location и свойства position / error — те же объекты, что и в классическом API, так что функции разбора ответа переиспользуются без изменений;
  • CSS-псевдокласс :granted — срабатывает, когда разрешение выдано, и позволяет перекрасить кнопку без JavaScript.
geolocation {
  background-color: #1d4ed8;
  color: #fff;
  border-radius: 8px;
}

geolocation:granted {
  background-color: #15803d;
}

Фолбэк работает за счёт старого правила HTML: незнакомый тег браузер считает обычным строчным элементом и просто показывает его содержимое. Firefox и Safari увидят вложенную кнопку и пойдут по классическому пути, Chrome и Edge нарисуют свою. Проверить поддержку из скрипта можно через 'HTMLGeolocationElement' in window.

Элемент вырос из экспериментального <permission>, который Chrome обкатывал в пробном режиме. От универсального варианта отказались в пользу отдельных элементов под каждую возможность — по тому же принципу сейчас тестируется элемент для камеры и микрофона. Стандартизация ещё идёт, поэтому <geolocation> — это прогрессивное улучшение поверх классического API, а не замена ему.

Поддержка элемента <geolocation>
chrome
Chrome
144
firefox
Firefox
 
edge
Edge
144
safari
Safari
 
opera
Opera
131

Частые ошибки

  • Запрос при загрузке страницы. Пользователь отказывает, не разобравшись, и второго шанса у сайта нет. Спрашиваем по клику и с объяснением, зачем.
  • Нет таймаута. По умолчанию браузер ждёт бесконечно, и интерфейс навсегда остаётся в состоянии загрузки.
  • Нет запасного пути. Отказ, отсутствие сигнала и старый браузер — нормальные сценарии. Ручной ввод города или адреса должен работать всегда.
  • Тестирование по HTTP. На стенде с обычным HTTP API не работает, и кажется, что сломан код. Для разработки подходит localhost, для стенда — только HTTPS.
  • Карта внутри iframe. Встроенная во фрейм страница по умолчанию не имеет доступа к геолокации, даже на HTTPS. Родительская страница должна явно его разрешить: <iframe src="…" allow="geolocation">.
  • Слепая вера в точку. Без учёта accuracy ноутбук с погрешностью в километры получит «ближайший магазин» в соседнем районе.

Ещё одно соображение, не техническое: координаты — персональные данные. Если для задачи хватает города, не стоит хранить на сервере точную точку, а если координаты всё же уходят на бэкенд, об этом стоит честно написать рядом с кнопкой.

Итог

Geolocation API — один из самых старых и простых браузерных интерфейсов, но вокруг него много граблей. Рабочий минимум выглядит так: проверить разрешение через navigator.permissions, спросить координаты по клику с понятным пояснением, задать timeout, обработать все три кода ошибки и всегда учитывать погрешность. Там, где поддерживается элемент <geolocation>, браузер берёт часть этой работы на себя, а вложенная кнопка-фолбэк сохраняет классический путь для остальных.

И карта — далеко не единственное применение. По координатам можно подставить город в форму, отсортировать список магазинов по расстоянию, выбрать язык и валюту по умолчанию или показать местную погоду.