Места рядом: расстояние и фильтр по радиусу
Оглавление серии «Карты для веба» · Урок 9 из 12.
Теперь у каталога может быть исходная точка, полученная от устройства. Добавим понятный смысл слова «рядом»: пользователь задаёт радиус в метрах, а приложение оставляет места, расстояние до которых не больше этого значения. Одновременно разрешим выбрать центр текущей карты вручную. Это позволит изучать пример без доступа к геолокации и искать в другой области.
Такой фильтр не строит маршрут. Две точки могут оказаться рядом по поверхности Земли, но быть разделены рекой, закрытой территорией или неудобной транспортной связью. Поэтому подпись результата говорит о приблизительном расстоянии по прямой. Для времени поездки нужен отдельный сервис маршрутизации с собственными данными, ограничениями и договором.
Выберем одну единицу измерения
В состоянии сохраняем radius в метрах. Значение поля формы тоже выражено в метрах, а результат LatLng.distanceTo Leaflet 1.9.4 возвращает метры. Это помогает не смешивать километры, пиксели и градусы в одном сравнении. Для текстовой подписи преобразуем расстояние в километры только в момент вывода.
Нельзя вычитать широты и долготы как обычные координаты в метрах. Градус долготы соответствует разной длине в зависимости от широты, а на глобальных данных добавляется переход через 180-ю долготу. На выбранной карте библиотека использует модель расстояния по поверхности Земли. Для нашего городского учебного каталога этого достаточно; это не геодезическое измерение для правовых или инженерных решений.
У исходной точки есть собственная точность, если она получена от устройства. Радиус поиска и оценка точности не складываются автоматически в «точный охват». Если устройство сообщает погрешность в несколько километров, список внутри радиуса 300 метров будет выглядеть математически аккуратным, но исходная предпосылка останется слабой. Сохраните видимое сообщение о точности и возможность выбрать точку вручную.
Добавим расстояние в общий предикат
Девятый шаг расширяет src/filters.js. Ниже полный файл:
export function filteredFeatures(features, state) {
const query = state.query.trim().toLocaleLowerCase('ru');
return features.filter(feature => {
const p = feature.properties;
if (state.category !== 'all' && p.category !== state.category) return false;
if (query && !`${p.name} ${p.description}`.toLocaleLowerCase('ru').includes(query)) {
return false;
}
if (state.origin) {
const [longitude, latitude] = feature.geometry.coordinates;
const distance = L.latLng(state.origin.latitude, state.origin.longitude).distanceTo(
L.latLng(latitude, longitude)
);
if (distance > state.radius) return false;
}
return true;
});
}
После категории и текста проверяем наличие state.origin. Если исходная точка не выбрана, ограничение по расстоянию не применяется. Это важно для начального состояния: пользователь должен увидеть каталог, а не пустую выдачу, которую можно открыть только разрешением геолокации. Радиус становится условием после явного выбора источника.
Координаты места снова извлекаются по именам. Обе точки передаются в L.latLng как широта и долгота. Метод distanceTo вызывается у точки отсчёта, а не у DOM-элемента маркера. Геометрическая операция не зависит от того, находится ли маркер сейчас на экране и подключена ли подложка. Значит, отсутствие тайлов не должно запрещать вычисление по принятому набору.
Сравнение использует distance > state.radius для исключения. Значение ровно на границе остаётся в результатах. В числах с плавающей точкой и приблизительной модели не стоит обещать пользователю точность включения до миллиметра. Если реальный продукт требует пограничной зоны или допуска, его правило нужно задать отдельно и отразить в тексте.
Ручной центр не является измерением устройства
Кнопка «Искать от центра карты» читает текущую камеру и назначает исходную точку:
const center = map.getCenter().wrap();
applyPosition({
latitude: center.lat,
longitude: center.lng,
accuracy: 0,
source: 'manual'
});
Внутреннее accuracy: 0 здесь технически удобно для окружности, но не означает доказанную нулевую погрешность. Поле source заставляет контроллер показать «Точка выбрана вручную» вместо сообщения об измеренной точности. Пользователь выбирает область визуально; он не обязан находиться в этом месте. Именно поэтому такой способ полезен для планирования просмотра другого района.
wrap приводит долготу к обычному диапазону, если камера перемещалась через повторяющиеся миры. При этом профиль широты остаётся ограниченным картой и данными. В более сложном приложении следует отдельно проверять ручные координаты на границе ввода, особенно если они пришли из текстовых полей или URL. Здесь источником является текущий экземпляр Leaflet с заданными пределами.
При ручном выборе увеличиваем positionEpoch, чтобы старый ответ Geolocation API не заменил новую точку. Этот случай легко пропустить: пользователь нажал геолокацию, передумал и выбрал другую область до ответа устройства. Новое действие должно иметь приоритет, даже если предыдущая асинхронная операция закончилась успешно.
Проверим радиус из формы
Атрибуты min, max и step помогают взаимодействовать с числовым полем, но контроллер всё равно проверяет полученное значение. Нужны конечное число и диапазон от 100 до 100000 метров. Если значение отклонено, поле возвращается к последнему принятому радиусу, а статус объясняет ограничение.
Почему не оставить любое положительное значение? У нашего городского примера небольшой набор и конкретный сценарий. Произвольный радиус в миллиарды метров не даёт полезного результата, а дробь в микрометрах создаёт ложное ощущение точности. Диапазон — решение продукта, которое можно изменить вместе с интерфейсом и примерами, а не универсальный предел алгоритма расстояния.
В коде принимается и дробное значение внутри диапазона: step относится к удобству формы, а не заменяет договор JavaScript. Если вам нужны только целые сотни метров, добавьте соответствующую проверку и назовите её в сообщении. В этой серии существенным ограничением остаётся диапазон, поэтому не создаём скрытое дополнительное правило.
Покажем расстояние рядом с названием
src/ui.js теперь вычисляет расстояние для текстовой подписи, когда есть исходная точка. Делим метры на 1000 и округляем до одного десятичного знака. Это формат чтения, а не изменение фильтра: сравнение по радиусу использует исходное значение в метрах. Иначе округление могло бы ошибочно включить или исключить точку около границы.
Порядок мест пока остаётся порядком коллекции. Показать расстояние и отсортировать по нему — разные решения. Если добавите сортировку, определите второй ключ для равных значений, например устойчивый идентификатор, чтобы порядок не менялся случайно. Также не перемещайте фокус пользователя при неожиданной пересортировке во время редактирования радиуса.
Список, маркеры и количество результатов получают тот же массив после всех условий. Подложка при этом остаётся независимым слоем. Кнопка «Показать все результаты» ограничивает камеру уже найденными местами, а не всеми записями исходного каталога. Это сохраняет смысл операции при одновременном использовании текста, категории и расстояния.
Где заканчивается локальность данных
Положение остаётся в памяти, и приватная камера не записывается в hash. Но сам результат поиска может косвенно показывать выбранную область: если позже вы экспортируете две ближайшие точки, получатель сможет сделать предположение о центре. В следующем уроке об обмене подчеркнём, что явный экспорт — действие пользователя, а не автоматическое сохранение личной позиции.
Не вызываем маршрутизацию, геокодирование или сбор адресов для этого фильтра. Исходные координаты уже известны. Если продукту понадобится «в двадцати минутах пешком», нельзя просто заменить подпись километров на минуты: потребуется модель маршрута, источник дорожных данных, запрос, обработка отсутствия пути и условия сервиса. Наше математическое условие имеет более узкий и честный смысл.
В своей копии поставьте центр на первую учебную мастерскую, задайте радиус 100 метров, затем 3000 метров. Ожидается расширение набора при неизменной категории и запросе. Уберите исходную точку: ограничение расстоянием должно исчезнуть. Проверьте, что подпись ручного центра не выдаёт его за измерение устройства. Следующим шагом отделим найденные записи от точек, которые действительно нужно рисовать в текущей области.
Полный снимок этого шага находится в папке lesson-09 архива серии. При подготовке материала пример не запускался; описанные эффекты ожидаются по разобранному коду и требуют самостоятельной проверки в браузере.