Импорт и экспорт GeoJSON без повреждения каталога
Оглавление серии «Карты для веба» · Урок 11 из 12.
Каталог «Рядом» пока получает данные из файла, который входит в исходники. Добавим явный импорт GeoJSON и экспорт найденных мест. При этом не будем смешивать частично прочитанный документ с текущим набором: новый каталог станет видимым только после успешного разбора и полной проверки. Если файл отклонён, пользователь продолжит работать с предыдущими данными.
Мы не загружаем выбранный файл на сервер. Браузер читает его локально, приложение проверяет профиль Point-коллекции и заменяет состояние в памяти. Перезагрузка вернёт исходные четыре учебные записи. Постоянное сохранение, совместная работа редакторов и публикация каталога требуют отдельного процесса, которого этот пример не изображает.
Разбор JSON не означает принятие каталога
Файл может содержать корректный JSON, но неправильные данные: другой тип геометрии, повторный идентификатор, координату-строку или неизвестную категорию. Поэтому JSON.parse отвечает только за синтаксис. После него вызываем уже знакомый validateCatalog, который задаёт профиль приложения. Так импорт использует тот же договор, что начальная загрузка, а не второй почти похожий валидатор.
Ограничиваем размер до одного мебибайта и число точек до 500. Первый предел проверяется до чтения и помогает избежать явно чрезмерного локального документа; второй проверяется после разбора и определяет размер набора. Это не универсальная защита от любой стоимости JSON, но разумная граница нашего простого учебного клиента. Если требуется принимать гораздо больше, архитектуру обработки нужно выбирать отдельно.
Расширение .geojson и атрибут accept делают выбор удобнее, однако не подтверждают содержимое. Человек может переименовать файл, а браузер — предложить обход фильтра. Проверяем структуру независимо от имени. Также не считаем file.type достаточным доказательством: значение зависит от системы и не описывает договор геометрии и свойств.
Создадим модуль обмена
Ниже полный src/exchange.js одиннадцатого шага:
import { validateCatalog } from './data.js';
export async function readCatalogFile(file) {
if (!file || file.size > 1048576) throw new Error('Файл должен быть не больше 1 МиБ.');
return validateCatalog(JSON.parse(await file.text()));
}
export function downloadCatalog(features) {
const body = JSON.stringify({ type: 'FeatureCollection', features }, null, 2);
const blob = new Blob([body], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'ryadom-visible.geojson';
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Функция чтения получает объект File, читает текст и возвращает нормализованную коллекцию. Метод Blob.text, доступный и у File, возвращает Promise с текстом в UTF-8. Глобальное состояние функция не меняет. Поэтому контроллер может решить, является ли результат ещё актуальным и нужно ли заменять текущие данные. Такое разделение полезно для любого импорта: декодирование и применение — разные операции.
Функция скачивания создаёт новый GeoJSON-документ из переданного массива. Используем тип application/geo+json и понятное имя файла. Object URL указывает на локальный Blob, а не внешний адрес. После запуска скачивания освобождаем URL с небольшой задержкой. В более сложном интерфейсе жизненным циклом таких ссылок должен управлять отдельный владелец, особенно если пользователь может создавать несколько файлов подряд.
Экспортируем принятые нормализованные объекты, поэтому в документ не попадут внутренние маркеры, обработчики событий и произвольные свойства исходного импорта. Координаты сохраняют порядок GeoJSON. Не переводим их обратно в [широта, долгота] только потому, что карта использовала такой порядок при отображении.
Применим новый набор одним решением
В контроллере читаем кандидат во временную переменную. Назначение catalog = candidate происходит после успешного завершения функции и проверки актуальности запроса. Только затем сбрасываем отсутствующий выбор, возвращаем первую порцию списка и вызываем общее отображение.
const candidate = await readCatalogFile(file);
if (epoch !== importEpoch) return;
catalog = candidate;
if (!catalog.features.some(feature => feature.id === state.selected)) {
state.selected = null;
}
listLimit = 50;
render();
Если валидатор выбросил ошибку, присваивание не выполнится. Пользователь увидит сообщение «Импорт отклонён», а предыдущий каталог останется источником списка и маркеров. Не очищаем текущую карту в начале чтения: это создало бы пустой экран даже для документа, который позднее оказался ошибочным. Сначала решение о данных, затем решение об отображении.
Фильтры сохраняются. Поэтому корректно импортированный набор может дать ноль результатов при прежнем запросе. Это не ошибка импорта. В настоящем продукте полезно дополнительно показать количество принятых объектов и напомнить об активных условиях. Наш статус считает найденные записи, а принятый набор можно изучить после очистки фильтров.
Откажемся от устаревшего чтения
Чтение файла асинхронно. Если пользователь быстро выбрал два документа, первый не должен заменить второй только потому, что завершился позже. Счётчик importEpoch фиксирует актуальное действие. Перед применением результата и выводом ошибки сравниваем сохранённый номер с текущим.
Это тот же приём, который использовали для геолокации, но источник другой. Мы не обещаем, что старое чтение файла остановлено; просто перестаём применять его к состоянию. Ограничение размера всё равно важно, потому что проигнорированный результат мог потребовать памяти и времени. Защита от гонки состояния не заменяет ограничение стоимости входа.
После завершения актуального импорта очищаем значение поля. Это позволяет выбрать тот же файл снова после исправления. Иначе некоторые браузерные сценарии не дадут нового change, поскольку имя выбранного файла формально осталось тем же. При очистке следим за номером операции, чтобы старое завершение не сбросило более свежий выбор.
Как заменяются существующие места
Если новый каталог содержит прежний id, это считается той же записью с обновлёнными координатами или свойствами. Модуль маркеров обновляет объект и карточку. Если id исчез, соответствующий маркер удаляется из группы и реестра. Поэтому импорт не должен создавать дубли поверх старой карты.
У прежнего маркера обновляем не только popup: название в title и alt иконки должно соответствовать новому набору. Если заменить лишь текст карточки, два способа чтения одного места получат разные подписи. При сохранённом идентификаторе обновление данных охватывает все представления объекта, включая доступные имена элементов.
Это семантика замены всего каталога, а не слияния. Мы не добавляем только неизвестные записи и не сохраняем старые при совпадении идентификаторов. Для слияния потребовался бы договор конфликтов: чей текст имеет приоритет, можно ли удалить запись, как хранить ревизии. Сейчас одну принятую коллекцию заменяет другая, что легче объяснить и воспроизвести.
Идентификаторы должны быть устойчивыми между редакционными версиями. Если каждый экспорт создаёт новые случайные id, карта потеряет возможность связать запись с прежним выбором и обновлением. Генерация идентификаторов относится к подготовке данных, а не к отрисовке. Наши учебные имена остаются одинаковыми во всех шагах.
Экспортируем именно результат поиска
Кнопка «Скачать найденные места» передаёт visible(), то есть результат текста, категории и расстояния. Ограничение области и предел нарисованных маркеров не входят в этот массив. Таким образом, экспорт может содержать больше объектов, чем видно значков. Подпись кнопки намеренно говорит о найденных местах, а не о скриншоте текущей карты.
Личная исходная точка не является Feature каталога и в файл напрямую не попадает. Однако набор ближайших результатов способен косвенно указывать на выбранную область. Экспорт — явное действие, и в продукте с чувствительными данными его последствия нужно объяснить. Не следует обещать анонимность только потому, что отдельного поля userLocation в JSON нет.
Подложка также не входит в архив экспорта. Мы скачиваем собственные данные каталога, а не тайлы OSM и не офлайн-карту. У изображений поставщика другие условия, и прежняя политика их использования сохраняется. GeoJSON описывает точки, которые другое приложение сможет отобразить своим способом.
Для самостоятельного разбора возьмите places.geojson, измените описание и сохраните файл. После импорта ожидается обновлённая карточка без дубля. Затем повторите один идентификатор: документ должен быть отклонён, а прежняя карта сохранена. Наконец, выберите одну категорию и экспортируйте найденные места. Полученный набор должен соответствовать фильтру, а не количеству нарисованных значков.
Полный снимок этого шага находится в папке lesson-11 архива серии. При подготовке материала пример не запускался; описанные эффекты ожидаются по разобранному коду и требуют самостоятельной проверки в браузере.