Год назад мне понадобился 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)
Алгоритм сборки полигона:
Найти все Edge References для Feature
Для каждого ребра — взять точки в правильном направлении
Соединить рёбра по shared nodes
Замкнуть контур
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 в год.
Но:
Гидрографические данные NOAA бесплатны по закону. Логично, что инструменты тоже должны быть бесплатными.
Я не морской инженер. Вряд ли смогу строить бизнес на поддержке этой библиотеки правильно.
Следующий стандарт 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/
Буду рад вопросам и фидбеку в комментариях.

