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

Фильтры мест: категории, поиск и слои

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

Список и карта «Рядом» теперь выбирают одно место согласованно. Добавим фильтры по категории и тексту. Главное изменение касается не поля ввода как такового: нам нужен единый набор результатов, который будет показан и в списке, и на карте. Если каждое представление фильтрует данные самостоятельно, пользователь легко увидит название без маркера или точку, которая уже не входит в выдачу.

Работаем с той же моделью GeoJSON. Категории workshop и library заданы машинными значениями, а поиск проверяет название и описание. Сервис геокодирования не используется: ввод не отправляется наружу и не ищет неизвестный адрес. Это локальный поиск по принятому учебному набору, что важно объяснить человеку рядом с интерфейсом.

Полный каталог и результат фильтрации

catalog.features содержит все принятые места. Состояние интерфейса хранит строку запроса и категорию. Функция фильтрации получает оба значения и возвращает новый массив ссылок на подходящие записи. Она не удаляет объекты из каталога и не переписывает исходный GeoJSON. Поэтому очистка поля снова покажет полный набор без повторной загрузки данных.

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

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

Сформулируем правило текстового поиска

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

Например, запрос «библиотека» найдёт две записи по названию. Запрос «синтетическая» может найти записи по описанию, даже если в названии его нет. Это нужно учитывать в интерфейсе: человек должен понимать, почему место попало в результаты. Для сложной библиотеки можно подсветить совпадение или показывать фрагмент описания, но не добавлять HTML-подсветку небезопасной подстановкой строк.

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

Создадим функцию фильтрации

Ниже полный файл 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;
    }
    return true;
  });
}

Функция не знает о DOM, слоях Leaflet и адресе страницы. Она работает с массивом объектов и состоянием. Это помогает рассуждать о результате отдельно от обновления карты. На этом шаге origin ещё не используется; фильтр по расстоянию появится в девятом уроке после явного выбора точки отсчёта.

toLocaleLowerCase('ru') применяется одинаково к запросу и объединённому тексту. Если нормализовать только одну сторону, сравнение может стать неожиданным. В настоящем многоязычном каталоге локаль следует выбирать по договору данных и интерфейса. Здесь язык зафиксирован, а категорию не переводим: это устойчивый ключ, который не зависит от видимой подписи.

Соединим поля с общим обновлением

В index.html добавлены подписанное поле input#query и select#category. Контроллер обновляет состояние, затем вызывает render:

document.querySelector('#query').addEventListener('input', event => {
  state.query = event.target.value;
  render();
});

document.querySelector('#category').addEventListener('change', event => {
  state.category = event.target.value;
  render();
});

Обработчики не обращаются к маркерам напрямую. Общая функция visible вызывает filteredFeatures, а render передаёт один полученный массив обоим отображениям. Так число результатов имеет единственный источник. Если категория убрала выбранное место, выбор сбрасывается согласно правилу прошлого урока; мы не оставляем видимую отметку несуществующего результата.

input реагирует на редактирование текста, а change удобен для завершённого выбора категории. Для маленького локального набора задержка поиска не требуется. Debounce не стоит добавлять только потому, что он часто встречается в примерах: пользователь должен сразу видеть результат, а четыре записи не создают заметной вычислительной работы. При сетевом поиске появятся другая стоимость, отмена запросов и защита от устаревших ответов.

Почему не используем фильтр GeoJSON один раз

Leaflet может создать GeoJSON-слой с собственной функцией filter. Однако настройка при добавлении данных не становится универсальным реактивным состоянием всего приложения. Если поменять значение поля, уже существующие слои не обязаны автоматически пересчитаться так, как ожидает разработчик. Кроме того, список всё равно потребует тот же результат.

В нашем примере сначала вычисляем массив средствами JavaScript, затем передаём его функции маркеров. Для четырёх точек эта функция очищает группу и создаёт новый набор. Подложка и камера сохраняются. В уроке о производительности будем обновлять маркеры по идентификаторам, но правило фильтрации менять не придётся: оптимизация отображения находится после получения результата.

Фильтр не должен перемещать камеру при каждом символе. Иначе человек, который изучал одну область, получит неожиданный скачок после исправления запроса. Отдельную кнопку «Показать все результаты» добавим в следующем уроке. Такое разделение даёт пользователю контроль: изменить состав каталога и изменить область просмотра — разные действия.

Пустая выдача остаётся нормальным состоянием

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

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

Категории «Все», «Мастерские», «Библиотеки» остаются доступны независимо от текущего текста. Мы пока не вычисляем счётчики для каждой категории и не запрещаем варианты с нулевой выдачей. Это сознательное простое устройство. Фасетные счётчики требуют дополнительного договора: считать ли их до применения категории или после всех условий, и как показать такую семантику человеку.

Попробуйте в собственной копии сначала выбрать библиотеки, затем набрать «Строка», а после очистить поле. Ожидается один результат, затем два, при неизменной категории. Переключение на «Все» возвращает четыре места. В следующем уроке сохраним публичные фильтры и камеру в адресе страницы, чтобы такую выдачу можно было открыть заново.

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