Перейти к содержанию

Камера карты: границы, масштаб и адрес страницы

Оглавление серии «Карты для веба» · Урок 7 из 12.

Каталог умеет фильтровать места, но после обновления страницы возвращается к исходному виду. Добавим чтение публичного состояния из фрагмента URL и запись текущей камеры. Одновременно создадим кнопку, которая показывает все результаты. Это разные операции: адрес описывает выбранный вид, а кнопка меняет камеру по явному действию пользователя.

В «Рядом» сохраняем центр, масштаб, категорию и локальный текстовый запрос. Не сохраняем личную позицию, точность устройства и произвольное содержимое карточек. Геолокация появится позже с отдельным правилом приватного состояния. Уже сейчас полезно определить, какую ссылку человек может безопасно скопировать и что именно она восстановит.

Центр и масштаб не равны результатам поиска

Набор результатов определяется данными и фильтрами. Камера определяет, какую часть пространства видно. После ввода запроса подходящая точка может остаться за границей экрана — это не означает, что она пропала из каталога. Если при каждом вводе автоматически выполнять fitBounds, карта будет менять положение даже тогда, когда человек хотел лишь уточнить список.

Поэтому добавляем отдельную кнопку #fit. Она собирает координаты текущих результатов и просит Leaflet подобрать видимую область. При нулевом результате камера остаётся прежней. При одной точке ограничиваем максимальное приближение, чтобы библиотека не пыталась показать единственное место с чрезмерным масштабом. Отступы оставляют место между точками и краями контейнера.

Наш каталог ограничен одной учебной областью и не пересекает линию смены даты. Для глобальных данных расчёт границ потребует дополнительного внимания: две близкие точки по разные стороны 180-й долготы могут выглядеть как очень большой прямоугольник при наивном представлении. Не переносите удобный алгоритм городского каталога на любую географию без проверки такого случая.

Подберём вид по точкам

В контроллер добавлена следующая операция:

document.querySelector('#fit').addEventListener('click', () => {
  const features = visible();
  if (!map || !features.length) return;
  const coordinates = features.map(feature => {
    const [lng, lat] = feature.geometry.coordinates;
    return [lat, lng];
  });
  map.fitBounds(coordinates, {
    padding: [24, 24], maxZoom: 15, animate: false
  });
});

Снова преобразуем порядок координат на границе Leaflet. padding задаётся в пикселях контейнера, а не в метрах на местности. maxZoom ограничивает результат конкретного подбора; общий предел карты остаётся 19. Отключение анимации делает переход предсказуемым для учебного разбора, но не решает автоматически все вопросы предпочтения уменьшенного движения в сложном приложении.

Когда секция карты меняет размер, результат подбора может потребовать повторного расчёта после invalidateSize. Наш интерфейс имеет простой адаптивный контейнер без скрытых панелей. Если добавите выдвижной список, сначала сообщите карте новый размер, а потом решайте, нужно ли заново подбирать камеру. Старые пиксельные отступы не обязаны соответствовать новой области.

Зададим формат адреса

Используем fragment после #, например #lat=53.90000&lng=27.56670&z=13&cat=library. Браузер не включает этот фрагмент в обычный HTTP-запрос страницы, но это не делает его секретным хранилищем. Ссылку могут скопировать, сохранить в истории, прочитать скриптом или передать аналитике. Поэтому сюда входят только публичные фильтры и публичная камера.

URLSearchParams кодирует значения без ручной сборки отдельных символов. Для запроса на русском языке это особенно удобно. Числа камеры округляем для компактной ссылки: мы сохраняем положение вида, а не геодезическое измерение. Уменьшение количества знаков не исправляет проблему приватности, если центр связан с домом пользователя; эту границу задаём поведением приложения, а не одним округлением.

Полный файл src/camera.js:

export function readPublicView() {
  const params = new URLSearchParams(location.hash.slice(1));
  const lat = Number(params.get('lat'));
  const lng = Number(params.get('lng'));
  const zoom = Number(params.get('z'));
  const hasCamera = ['lat', 'lng', 'z'].every(key => params.has(key)
    && params.get(key).trim() !== '');
  const valid = hasCamera && [lat, lng, zoom].every(Number.isFinite)
    && Math.abs(lat) <= 85 && Math.abs(lng) <= 180
    && Number.isInteger(zoom) && zoom >= 3 && zoom <= 19;
  return {
    camera: valid ? { center: [lat, lng], zoom } : null,
    query: (params.get('q') || '').slice(0, 100),
    category: ['workshop', 'library'].includes(params.get('cat'))
      ? params.get('cat') : 'all'
  };
}
export function writePublicView(map, state) {
  const center = map.getCenter().wrap();
  const params = new URLSearchParams({
    lat: center.lat.toFixed(5), lng: center.lng.toFixed(5), z: String(map.getZoom())
  });
  if (state.query) params.set('q', state.query);
  if (state.category !== 'all') params.set('cat', state.category);
  history.replaceState(null, '', `${location.pathname}${location.search}#${params}`);
}

При чтении проверяем, что все три параметра камеры присутствуют и не пусты. Number('') дал бы ноль, поэтому одной проверки конечности недостаточно. Также ограничиваем широту, долготу и целочисленный масштаб. Если камера некорректна, используем начальное положение; категория и ограниченный по длине запрос могут быть приняты независимо.

Категория читается через список разрешённых значений. Не передаём любое слово из адреса напрямую в будущий SQL, HTML или настройки библиотеки. Здесь незнакомое значение просто превращается в all. Это соответствует простому договору публичной ссылки: старый или ошибочный вариант не ломает весь интерфейс.

Восстановим и запишем состояние

При старте контроллер читает readPublicView, назначает значения состояния и полей, а затем, если камера принята, вызывает setView. После этого подключаем запись на moveend. Важен порядок: не нужно сначала записать исходную камеру поверх адреса и только затем попытаться прочитать старую.

Событие окончания перемещения подходит лучше непрерывной записи на каждом кадре. Мы сохраняем законченное состояние вида, а не строим поток истории всех пикселей движения. history.replaceState заменяет текущую запись адреса, поэтому очередное движение не создаёт сотни шагов кнопки «Назад». Если продукт требует историю поисковых состояний, правила pushState и обработки возврата нужно определить отдельно.

В этом учебном состоянии фрагмент читается при загрузке документа. Ручное изменение hash в уже открытой странице не запускает новое восстановление: обработчик hashchange пока не реализован. Скопируйте ссылку в новую вкладку или обновите страницу. Это честная граница примера, которая отделяет сохраняемую ссылку от полноценного клиентского маршрутизатора.

Изменение категории или запроса тоже вызывает синхронизацию адреса после обновления результатов. Выбранный идентификатор в ссылку пока не включаем: при изменении данных он может исчезнуть, и потребуется отдельная стратегия неизвестного id. Наша ссылка восстанавливает область и условия поиска, а не состояние всех возможных панелей приложения.

Подготовим границу приватной камеры

В контроллере уже есть privateView. Пока он остаётся ложным, syncAddress записывает публичное состояние. Следующий урок установит его при использовании позиции устройства и запомнит прежнюю публичную камеру. С этого момента новые перемещения не будут автоматически сохраняться в URL. Возврат к публичному виду произойдёт только после удаления личной точки.

Это простая, консервативная модель. Она также означает, что изменения фильтров во время приватного просмотра пока не записываются в адрес. Мы предпочли понятное правило всей ссылки, а не незаметную смесь старых координат и новых личных условий. Более развитый интерфейс может отдельно сохранять публичный запрос, но должен объяснять пользователю, что будет скопировано.

После самостоятельного запуска выберите библиотеки, введите «Строка», нажмите «Показать все результаты» и скопируйте адрес в новую вкладку. Ожидается восстановление фильтра и камеры. Затем попробуйте z=999 или пустое lat=: неподдерживаемая камера должна быть проигнорирована. Следующим шагом добавим положение устройства, которое не будет частью такой публичной ссылки.

Полный снимок этого шага находится в папке lesson-07 архива серии. При подготовке материала пример не запускался; описанные эффекты ожидаются по разобранному коду и требуют самостоятельной проверки в браузере.