Njord is a Marine Electronic Navigational Chart (ENC) server. It ingests S-57 hydrographic chart files and serves them as MVT (Mapbox Vector Tiles) for display in a browser via MapLibre GL. It does not strictly follow IHO S-52 display specifications; styling is custom per S-57 object class.
Live demo: https://openenc.com
| Module | Target | Purpose |
|---|---|---|
server |
Kotlin/Native | HTTP server: ingestion, tile serving, API endpoints |
web |
Kotlin/JS | Compose frontend with MapLibre GL map |
shared |
Multiplatform (JVM/JS/Native) | Shared data models and serialization |
shared_fe |
Kotlin/JS | Frontend UI components, ViewModels, MapLibre bindings |
libgdal |
Kotlin/Native | C interop bindings to GDAL 3.6.2 |
libpq |
Kotlin/Native | C interop bindings to PostgreSQL client (libpq) |
libzip |
Kotlin/Native | C interop bindings for ZIP extraction |
libsqlite |
Kotlin/Native | C interop bindings to SQLite (region export) |
geojson |
Multiplatform | GeoJSON RFC 7946 implementation |
┌───────────────────────────────────────────────────────────┐
│ Browser Client │
│ (MapLibre GL + Kotlin/JS frontend compiled to JS) │
└────────────────────────┬──────────────────────────────────┘
│ HTTP / WebSocket
▼
┌───────────────────────────────────────────────────────────┐
│ Ktor CIO HTTP Server :9000 │
├───────────────────────────────────────────────────────────┤
│ /v1/tile/{z}/{x}/{y} → MVT protobuf │
│ /v1/style/{depth}/{theme} → MapLibre GL style JSON │
│ /v1/enc_save → Upload/delete S-57 ZIPs │
│ /v1/chart_ws → WebSocket ingest progress │
│ /v1/chart* → Chart CRUD │
│ /v1/content/* → Fonts, sprites │
│ /v1/admin → HMAC signature endpoint │
│ /v1/about/* → S-57 object metadata │
│ /v1/regions → Region manifest JSON │
│ /v1/regions/{archive} → Download region SQLite │
└──────┬───────────────────────────────┬─────────────────────┘
│ Tile reads │ Writes on ingest
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ PostgreSQL 13 + PostGIS │
├────────────────────────────────────────────────────────────┤
│ charts — chart metadata + coverage polygon (GIST) │
│ features — S-57 features in WGS84 (WKB + JSONB) │
│ base_features — Natural Earth base map features │
└────────────────────────────────────────────────────────────┘
The server polls for uploaded chart ZIPs and processes them in the background.
-
Upload: The user uploads a ZIP of S-57
.000files via the web UI.EncSaveHandlerwrites it to the uploads directory (config.chartTempData/save/). -
Claim:
ChartIngestWorkerpolls thesave/directory every 5 seconds. When it finds a ZIP, it atomically renames it toingest/{uuid}/to claim it and prevent duplicate processing. -
Unzip & read: Files are extracted and opened via GDAL (
OgrS57Datasetfromlibgdal). Each.000file represents one chart. -
Parallel processing: Up to
config.chartIngestWorkers(default: 5) charts are processed concurrently using coroutines. -
Per-chart processing:
- Extract chart metadata from the
DSIDlayer (dataset ID, scale, dates). - Extract the coverage polygon from
M_COVR— this is used for spatial indexing. - Calculate optimal display zoom from chart scale.
- Convert all geometries to WGS84 (EPSG:4326).
- Read all remaining layers and convert to GeoJSON
FeatureCollectionper layer.
- Extract chart metadata from the
-
Persist: In a single transaction:
- Insert chart record into
chartstable (ChartDao). - Insert all features into
featurestable (FeatureDao), each with its layer name, WKB geometry, and JSONB attribute properties.
- Insert chart record into
-
Progress:
IngestStatusbroadcasts progress events to connected WebSocket clients (ChartWebSocketHandler). -
Cache invalidation: The tile cache is cleared on ingestion completion.
server/src/nativeMain/kotlin/ingest/ChartIngest.kt— Worker and processing logicserver/src/nativeMain/kotlin/db/ChartDao.kt— Chart table queriesserver/src/nativeMain/kotlin/db/FeatureDao.kt— Feature table queries
When a tile request arrives at /v1/tile/{z}/{x}/{y}, TileEncoder assembles the response.
-
Envelope: Compute the WGS84 bounding box for tile
(z, x, y), expanded by 15 pixels to avoid clipping artifacts at tile edges. -
Find charts:
ChartDao.findInfoAsync()— spatial query (ST_Intersects) againstcharts.covrto find all charts whose coverage overlaps the tile, ordered by scale (most detailed first). -
Find features: For each chart,
ChartDao.findChartFeaturesAsync4326()runsST_Intersectionagainstfeatures.geomfiltered byz_range(the chart's SCAMIN/SCAMAX zoom range). This clips features to the tile envelope. -
Pre-encode styling:
LayerFactory.preTileEncode()dispatches each feature to its layer class (e.g.,Depare,Boyspp). The layer mutates the feature'spropsJSONB to add computed display properties:SY— Symbol/sprite nameAC— Area fill colorLP— Line patternLC— Line color
-
Coordinate transform: Feature geometries are converted from WGS84 degrees to MVT tile pixel coordinates (0–4096).
-
Encode: Features are added to a
VectorTileEncoder(protobuf) grouped by layer name. -
Base map: For tiles with no chart coverage,
BaseFeatureDaoqueries Natural Earth data (base_features) at the appropriate scale as a fallback. -
Cache: The encoded tile bytes are stored in an LRU in-memory
TileCache(disabled ifconfig.debugTile = true).
server/src/nativeMain/kotlin/geo/TileEncoder.ktserver/src/nativeMain/kotlin/db/TileDao.ktserver/src/nativeMain/kotlin/db/TileCache.kt
StyleHandler returns a MapLibre GL style JSON at /v1/style/{depth}/{theme}. The style references:
- The tile source at
/v1/tile_json - Sprites from
/v1/content/sprites/... - Fonts (glyphs) from
/v1/content/fonts/... - One
Layerobject per S-57 object class per geometry type
Depth and theme variants are pre-generated by LayerFactory on first request and cached. Supported combinations:
- Depth:
meters(meters/feet/fathoms),feet(feet/nautical miles),feet_fm - Theme:
day(light),dark, plus any custom themes fromColorLibrary
Each S-57 object class has a corresponding Kotlin class in server/src/nativeMain/kotlin/layers/ that extends Layerable. A layer class has two responsibilities:
-
layers(options)— Returns the MapLibre GLLayerspec(s) for this object class (fill, line, symbol, etc.). These form part of the style JSON. -
preTileEncode(feature)— Called during tile encoding. Mutates feature properties to add display hints that the style JSON reads via["get", "SY"],["get", "AC"], etc.
Examples:
-
Depare(depth areas):preTileEncode()inspectsDRVAL1(depth range minimum) and setsACto one ofDEPIT / DEPVS / DEPMS / DEPMD / DEPDWbased on depth thresholds from config. -
Boyspp(buoys):preTileEncode()inspectsBOYSHP(buoy shape) andCOLOURto select the correct sprite name, storing it asSY. -
Lndare(land areas): NopreTileEncode()needed — uses a static fill + line style. -
Soundg(soundings): Multi-point geometry; each sounding depth is encoded as a separate symbol with the depth value formatted as the label.
Sprites are loaded from PNG sprite sheets embedded in resources. IconHandler serves individual PNG icons per theme at /v1/icon/{name}.png using libgd to crop from the sprite sheet.
Managed by DbMigrations.kt (sequential SQL migrations applied at startup).
id BIGSERIAL PRIMARY KEY
name VARCHAR UNIQUE -- DSID dataset name (e.g., US5WA46M)
scale INTEGER -- chart scale denominator
file_name VARCHAR -- source .000 filename
updated VARCHAR -- DSID_UADT update date
issued VARCHAR -- DSID_ISDT issue date
zoom INTEGER -- optimal MapLibre zoom level
covr GEOMETRY -- M_COVR coverage polygon (GIST indexed)
dsid_props JSONB -- full DSID attributes
chart_txt JSONB -- chart text file contentsid BIGSERIAL PRIMARY KEY
layer VARCHAR -- S-57 object class (DEPARE, BOYSPP, …)
geom GEOMETRY -- WKB in EPSG:4326 (GIST indexed)
props JSONB -- S-57 attributes + computed display props
chart_id BIGINT REFERENCES charts
lnam_refs VARCHAR[] -- LNAM cross-references (GIN indexed)
z_range INT4RANGE -- [SCAMIN, SCAMAX] zoom range (GIST indexed)Natural Earth data used as a base map for areas without chart coverage.
id BIGSERIAL PRIMARY KEY
geom GEOMETRY -- EPSG:4326 (GIST indexed)
props JSONB
name VARCHAR -- source shapefile name
scale INTEGER -- NE scale: 10M / 50M / 110M
layer VARCHAR -- mapped S-57 layer (e.g., LNDARE)ChartsConfig is loaded from the CHART_SERVER_OPTS environment variable (JSON string) or a config file. Key fields:
pgConnectionInfo — PostgreSQL connection string
host / port — Ktor bind address (default 0.0.0.0:9000)
externalScheme/Host/Port — Public URL for TileJSON and sprite URLs
chartTempData — Temp dir for uploads and caching (/mnt/njord/charts)
webStaticContent — Absolute path to frontend build output
shallowDepth — Depth threshold for "very shallow" coloring (default 3.0m)
safetyDepth — Depth threshold for "moderate" coloring (default 6.0m)
deepDepth — Depth threshold for "deep" coloring (default 9.0m)
adminKey — HMAC-SHA256 key for admin signature generation
adminUser / adminPass — Basic auth credentials for /v1/admin
adminExpirationSeconds — Signature TTL (default 604800 = 7 days)
chartIngestWorkers — Concurrent S-57 processing workers (default 5)
enableIngestion — Enable/disable the chart ingest worker (default true)
useTileCache — Enable/disable in-memory tile LRU cache (default true)
debugTile — Include debug envelope layer; disable tile caching
regionExports — List of RegionExportConfig objects (default empty)
Each RegionExportConfig has:
name — Region identifier, used as the SQLite filename prefix (e.g., REGION_15)
description — Human-readable description
coverage — WKT polygon defining the geographic boundary of the region
Coverage polygons can be generated with data/enc_boundary_wkt.py.
webStaticContent must be an absolute path because the server resolves static files relative to CWD, not the resources directory.
Region exports produce SQLite archive files that mobile clients can download for offline chart rendering. Each archive contains all charts and features whose M_COVR polygon falls within the configured region boundary.
RegionExportWorker hooks into the ingestion lifecycle. Each time the ingest lock is released it schedules a coroutine with a 15-second debounce delay. If another export is already pending it is cancelled and rescheduled. When the delay expires the worker checks that no ZIPs are queued and no ingestion is running, then calls RegionExporter.exportAll().
- Query
RegionDaoto check whether any chart with anM_COVRinside the region boundary has been added since the last export. Skip if not. - Open a new SQLite file named
{name}_{ISO8601-timestamp}.sqlitein<chartTempData>/regions/. - Write
chart,feature,lnam_refs, andfeature_bboxtables for all matching charts (no indexes — the mobile client builds those on import). - Update
manifest.jsonin the regions directory. - Prune archives so at most 2 copies per region are retained (oldest deleted first).
chart — mirrors the PostGIS `charts` table (covr stored as WKB BLOB)
feature — layer, WKB geometry, JSON props, chart_id FK
lnam_refs — (fid, lnam_ref) cross-reference rows
feature_bbox — pre-computed bounding boxes (min/max z, x, y) used by mobile to
build a virtual rtree index without re-parsing geometry WKBGET /v1/regions returns manifest.json — a JSON array of objects with name, description, coverage (WKT), and archive (filename). GET /v1/regions/{archive} streams the SQLite file for download.
The mobile app downloads an archive, imports its rows into a local SQLite database, builds an rtree feature_geo_index from feature_bbox, then discards the download. Tile rendering on device follows the same spatial query algorithm as the server.
server/src/nativeMain/kotlin/ingest/RegionExporter.kt— export logicserver/src/nativeMain/kotlin/ingest/RegionExportWorker.kt— post-ingest schedulingserver/src/nativeMain/kotlin/db/RegionDao.kt— PostGIS queries for region dataserver/src/nativeMain/kotlin/endpoints/RegionHandler.kt— HTTP handlerslibsqlite/— C interop bindings for SQLite writesdata/enc_boundary_wkt.py— helper script to generate coverage WKT from ENC files
The admin API uses HMAC-SHA256 signatures rather than session cookies to authorize mutations.
- Client calls
GET /v1/adminwith HTTP Basic auth (adminUser/adminPass). - Server returns a time-limited HMAC signature (expires after
adminExpirationSeconds). - Client passes the signature as a query parameter
?signature=...on mutating calls (POST /v1/enc_save,DELETE /v1/enc_save, etc.).
The server runs as a Deployment in the njord namespace with a pgbouncer sidecar.
Pods: 2–5 replicas (HPA scaling on 50% CPU)
Containers per pod:
njord-chart-svc— the server binary (server.kexe), limits: 1 CPU / 3 GiB RAMpgbouncer— connection pooler sidecar; reachable atlocalhost:5432
Storage: NFS PVC (/mnt/njord) shared across replicas for chart upload/temp data
Ingress: HAProxy with cert-manager TLS termination for openenc.com
Secrets:
admin-secret-json—CHART_SERVER_OPTSJSON with credentialsnjord-pgbouncer-ini/njord-pgbouncer-userlist-txt— pgbouncer config