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

GeoJSON: модель мест и порядок координат

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

В каталоге «Рядом» появились поверхность карты и отдельная подложка. Теперь подготовим собственные данные: четыре вымышленных места возле условного центра учебной области. Названия «Точка», «Строка», «Линия» и «Сетка» нужны для упражнений; они не обозначают действующие организации и не являются рекомендациями посетить эти координаты.

Будем хранить данные в GeoJSON. Это позволит обмениваться набором с другими инструментами, а позднее добавить импорт и экспорт. Однако стандарт формата не определяет все правила нашего каталога: уникальность идентификаторов, длину описания и допустимые категории мы зададим отдельно. Результат урока — подготовленный набор и функция, которая либо принимает его целиком, либо сообщает о нарушении договора.

Геометрия и свойства решают разные задачи

GeoJSON описывает географические объекты. Для одного места нам нужен объект Feature: у него есть геометрия, свойства и идентификатор. Геометрия отвечает на вопрос «где находится точка», свойства — «что нужно показать человеку». Коллекция таких объектов получает тип FeatureCollection и массив features.

Не включайте название в идентификатор, если название может измениться. Учебная запись workshop-1 сохранит свою связь с выбранной карточкой после переименования «Точки». Аналогично порядковый номер в массиве не подходит для связи списка и карты: при фильтрации и сортировке место окажется на другой позиции. Устойчивый строковый id используем в интерфейсе, импорте и экспорте.

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

Порядок координат на границе форматов

В предыдущем уроке setView получил [широта, долгота]. У GeoJSON согласно RFC 7946 позиция начинается с долготы, затем широты. Поэтому центр нашей первой записи хранится как [27.5667, 53.9]. Массив с обратным порядком тоже состоит из двух чисел и часто остаётся формально допустимым, но указывает на другую область.

Одна проверка диапазона не обнаружит такую перестановку: 27 и 53 подходят и широте, и долготе. Защита состоит из явно названных переменных при преобразовании, визуальной проверки учебной области и, для настоящих данных, отдельного правила ожидаемого региона. Универсальный стандарт не знает, в каком городе должен находиться ваш каталог.

GeoJSON: долгота перед широтой; Leaflet: широта перед долготой
Учебная схема порядка координат. Это иллюстрация договора API, а не снимок запущенного проекта.

GeoJSON использует географические координаты WGS 84 в градусах. Не подставляйте вместо них пиксели изображения, метры локальной проекции или числа из другой системы без преобразования. Leaflet отображает карту через проекцию, но это не меняет договор файла. В этой серии принимаем только двумерные точки, без высоты, линий и многоугольников; это профиль приложения, а не весь GeoJSON.

Создадим учебный набор

В архиве третьего шага есть places.geojson для обмена и places.js для прямого модульного импорта. Первый файл содержит полный набор:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "id": "workshop-1",
      "geometry": {
        "type": "Point",
        "coordinates": [
          27.5667,
          53.9
        ]
      },
      "properties": {
        "name": "Учебная мастерская «Точка»",
        "category": "workshop",
        "description": "Вымышленное место для разбора каталога; реального адреса нет."
      }
    },
    {
      "type": "Feature",
      "id": "library-1",
      "geometry": {
        "type": "Point",
        "coordinates": [
          27.552,
          53.912
        ]
      },
      "properties": {
        "name": "Учебная библиотека «Строка»",
        "category": "library",
        "description": "Демонстрационная карточка с книгами о веб-разработке."
      }
    },
    {
      "type": "Feature",
      "id": "workshop-2",
      "geometry": {
        "type": "Point",
        "coordinates": [
          27.584,
          53.894
        ]
      },
      "properties": {
        "name": "Учебная мастерская «Линия»",
        "category": "workshop",
        "description": "Синтетическая запись для фильтра по категории."
      }
    },
    {
      "type": "Feature",
      "id": "library-2",
      "geometry": {
        "type": "Point",
        "coordinates": [
          27.596,
          53.908
        ]
      },
      "properties": {
        "name": "Учебная библиотека «Сетка»",
        "category": "library",
        "description": "Синтетическая запись для выбора и измерения расстояния."
      }
    }
  ]
}

JavaScript-файл экспортирует тот же объект как значение по умолчанию. Так мы не добавляем в урок ещё один асинхронный запрос только ради четырёх учебных записей. При работе над настоящим сайтом JSON можно получить через fetch, но тогда понадобятся состояния ожидания, отказа, отмены и проверки ответа. Проверяющая функция останется такой же независимо от способа доставки данных.

Категории храним как машинные значения workshop и library; русские подписи интерфейса задаются отдельно. Перевод слова «Библиотеки» не должен менять фильтры, сохранённые ссылки и соглашение с источником данных. Описания — обычный текст. Здесь нет разрешённых HTML-тегов, готовых popup-фрагментов или команд поведения карты.

Проверим данные до изменения интерфейса

Полный файл src/data.js определяет профиль каталога и принимает исходный набор:

import raw from '../places.js';
export const categories = new Set(['workshop', 'library']);
export function validateCatalog(value) {
  if (!value || value.type !== 'FeatureCollection' || !Array.isArray(value.features)
    || value.features.length > 500) throw new Error('Нужен каталог до 500 точек.');
  const ids = new Set();
  const features = value.features.map(feature => {
    if (!feature || feature.type !== 'Feature' || typeof feature.id !== 'string'
      || !/^[a-z][a-z0-9-]{0,39}$/.test(feature.id) || ids.has(feature.id)) {
      throw new Error('Проверьте тип и уникальный строковый id каждой записи.');
    }
    ids.add(feature.id);
    const coordinates = feature.geometry?.coordinates;
    if (feature.geometry?.type !== 'Point' || !Array.isArray(coordinates)
      || coordinates.length !== 2 || !coordinates.every(Number.isFinite)
      || Math.abs(coordinates[0]) > 180 || Math.abs(coordinates[1]) > 85) {
      throw new Error('Нужны две координаты: долгота и широта от −85 до 85.');
    }
    const p = feature.properties;
    if (!p || typeof p.name !== 'string' || !p.name.trim() || p.name.length > 100
      || typeof p.description !== 'string' || p.description.length > 600
      || !categories.has(p.category)) throw new Error('Неверные свойства места.');
    return {
      type: 'Feature', id: feature.id,
      geometry: { type: 'Point', coordinates: [...coordinates] },
      properties: { name: p.name.trim(), category: p.category, description: p.description }
    };
  });
  return { type: 'FeatureCollection', features };
}
export const initialCatalog = validateCatalog(raw);

Функция сначала проверяет оболочку коллекции и верхний предел размера. Затем проходит по объектам, собирая множество идентификаторов. Повторный id отклоняется, даже если координаты у записей разные: иначе интерфейс не сможет однозначно определить выбранное место. Пустая коллекция допустима, потому что отсутствие результатов — нормальное состояние каталога.

Number.isFinite не превращает строку в число. Это полезно на границе импорта: координата "53.9" не считается корректной автоматически. Мы не исправляем чужой файл неявным приведением; читатель увидит ошибку и сможет поправить источник. Такой подход отделяет нормализацию заведомо ожидаемой строки формы от приёма внешнего структурированного документа.

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

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

Что означает отклонить набор целиком

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

Ограничение в 500 точек — учебное решение, которое удерживает объём обработки и интерфейса понятным. Оно не доказывает, что браузер способен показать ровно столько объектов на любом устройстве, и не описывает предел Leaflet. Для больших наборов позже отдельно ограничим видимые маркеры и список, а для настоящего крупного справочника понадобится серверная выдача и измерения.

Строковые поля имеют свои пределы: название до 100 символов и описание до 600. Это помогает контролировать размер карточки и импортируемой структуры, но не превращает текст в доверенный HTML. Безопасное отображение всё равно будем делать через DOM. Проверка данных и выбор API вывода дополняют друг друга, а не заменяют.

Разберём ожидаемые изменения

В третьем состоянии карты ещё нет маркеров. Контроллер сообщает, что приняты четыре вымышленных места. Если поменять geometry.type одного объекта на Polygon, функция должна отклонить файл: мы ещё не реализовали отображение такого типа. Если заменить library-2 на library-1, нарушится уникальность. Если переставить две допустимые координаты, формальная проверка может пройти — именно поэтому выше мы разобрали пределы валидатора.

Добавьте в своей копии пятую вымышленную точку с новым id, одной из двух категорий и корректным описанием. Ожидается изменение количества принятых записей. В следующем уроке преобразуем координаты на границе Leaflet и создадим маркеры. При этом GeoJSON останется источником сведений, а библиотечные объекты — представлением, которое можно пересоздать.

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