Год назад мне понадобился JavaScript-инструмент для работы с морскими навигационными картами формата S-57. Я нашёл GDAL — серверная библиотека на C, не для браузера. Нашёл коммерческие SDK — от $10K до $200K в год. Нашёл несколько заброшенных репозиториев 2014 года.

Написал сам. Получился парсер, который работает прямо в браузере: никакого сервера, никаких зависимостей, 127 тестов, открытый код.

В этой статье расскажу, что такое S-57, почему с ним сложно, что я сделал и зачем вообще это нужно.


Что такое S-57 и зачем это кому-то нужно

S-57 (IHO Special Publication No. 57) — международный стандарт для электронных навигационных карт. Используется на каждом коммерческом судне в мире с 1996 года. Гидрографические службы США (NOAA), Великобритании, Норвегии, Японии и других стран публикуют данные именно в этом формате.

Если вы видите электронную карту в системе навигации судна — с большой вероятностью под капотом S-57.

Карты NOAA для US вод доступны бесплатно. Всего их около 1200 штук, они обновляются еженедельно.

Проблема: открытой JavaScript-библиотеки для работы с S-57 фактически не существовало. Все решения — либо серверные (GDAL, OpenCPN), либо платные SDK для enterprise.

Масштаб рынка: maritime navigation software — около $500M, растёт из-за перехода на S-101 (обязателен с 2029 года для всего флота). В JS-экосистеме для этого нет ничего.


Формат данных: почему это сложно

ISO 8211 — бинарный формат 1994 года

S-57 основан на ISO 8211, международном стандарте для обмена данными в полевых условиях. Разработан в эпоху перфокарт, но до сих пор живёт.

Файл состоит из записей (records), каждая запись — из полей, каждое поле — из данных с метаданными. Всё бинарное, с позиционными индексами в начале каждой записи.

Первый сюрприз: два формата бинарной нотации, которые внешне неотличимы. В заголовке записи указан тип, но декодировать его нужно с учётом глобального DDR (Data Descriptive Record) в начале файла. Если перепутать — данные читаются без ошибок, но неверно. Потратил несколько часов на hex-дампы, прежде чем понял.

// DDR tells you how to read each field
// Without it, two identical byte sequences mean different things
const fieldType = ddr.getFieldFormat(fieldTag);
const value = fieldType === 'B' ? readBinary(bytes) : readString(bytes);

Топология: полигоны не хранятся как полигоны

Второй сюрприз — и главная техническая сложность S-57 — это топологическая модель данных.

В обычных форматах (GeoJSON, Shapefile) полигон — это массив координат. В S-57 полигоны хранятся как ориентированные рёбра графа, которые нужно собирать в рантайме.

Структура:

  • Node — точка на карте (может быть связана с несколькими рёбрами)

  • Edge — ребро, соединяющее два узла (может быть частью нескольких объектов)

  • Feature — объект (мель, буй, огонь, береговая линия), ссылается на рёбра через Edge References с направлением (+1 или -1)

Алгоритм сборки полигона:

  1. Найти все Edge References для Feature

  2. Для каждого ребра — взять точки в правильном направлении

  3. Соединить рёбра по shared nodes

  4. Замкнуть контур

function assemblePoly(feature: Feature, edges: Map<string, Edge>): Coordinate[] {
  const rings: Coordinate[][] = [];
  const edgeRefs = feature.attr['FSPT'] as EdgeRef[];
  
  // Sort edges into a closed ring
  let currentRing: Coordinate[] = [];
  let currentEdge = edgeRefs[0];
  
  while (edgeRefs.length > 0) {
    const edge = edges.get(currentEdge.rcid);
    const coords = currentEdge.ornt === 1 
      ? edge.coords 
      : [...edge.coords].reverse();
    
    currentRing.push(...coords.slice(0, -1)); // avoid duplicate node
    
    const nextStart = coords[coords.length - 1];
    currentEdge = edgeRefs.find(e => 
      edgeStartsAt(edges.get(e.rcid), e.ornt, nextStart)
    );
    
    if (!currentEdge || currentRing.length === 0) {
      rings.push([...currentRing, currentRing[0]]); // close
      currentRing = [];
      currentEdge = edgeRefs[0];
    }
  }
  
  return rings.flat();
}

Это медленнее прямой десериализации, но зато: каждое ребро хранится один раз, данные консистентны.


Рендеринг: стандарт IHO S-52

Распарсить данные — полдела. Отобразить их правильно — стандарт IHO S-52 на 400+ страниц с правилами символики, цветов, приоритетов слоёв.

Три цветовые схемы: день (DDAY), сумерки (DUSK), ночь (DNIGHT). Каждая — свой набор цветов для глубин, опасностей, береговой линии.

Что реализовано в парсере:

  • Глубинная зональность с градиентом (безопасные воды — синие, мелководье — зелёное, суша — серая)

  • Секторные навигационные огни (дуги с правильными углами и цветами)

  • Контуры опасностей (пунктирные линии со звёздочками по стандарту)

  • Точечные символы: буи, маяки, вехи

  • Текстовые метки с правилами приоритета

Canvas 2D API, никакого WebGL.


Что получилось в цифрах

  • 5 200 строк TypeScript

  • 127 тестов (парсинг, топология, рендеринг)

  • 0 runtime зависимостей

  • Работает в браузере и Node.js

  • Парсит реальные файлы NOAA (протестировано на 40+ картах)

Демо: можно перетащить .000-файл прямо в браузер и увидеть карту. NOAA публикует их бесплатно на nauticalcharts.noaa.gov.


Почему open source

Я мог бы продавать SDK. Коммерческие аналоги стоят от $10K в год.

Но:

  1. Гидрографические данные NOAA бесплатны по закону. Логично, что инструменты тоже должны быть бесплатными.

  2. Я не морской инженер. Вряд ли смогу строить бизнес на поддержке этой библиотеки правильно.

  3. Следующий стандарт S-101 обязателен к 2029 году. Открытая инфраструктура нужна отрасли.

Написал статью на английском на Reddit (r/sailing, r/OpenSource), получил звёзды от Норвегии, Германии, Канады. Несколько разработчиков уже открыли issues с запросами под конкретные суда.


Что дальше

S-101 — новый стандарт, S-63 (шифрование коммерческих карт), Web Workers для тяжёлых файлов, WebGL для производительности.

Если вы работаете в maritime tech или просто интересно поковыряться — репозиторий на GitHub: github.com/devladpopov/s57-parser

Демо: devladpopov.github.io/s57-parser/

Буду рад вопросам и фидбеку в комментариях.