The Open Waters nautical chart as a MapLibre GL style: a VersaTiles base map, Seascape bathymetry, and chart symbology (buoys, beacons, lights, topmarks, landmarks, restricted areas) with the sprite sheet that draws it.
style() assembles everything:
import { style } from "@openwaters/seamap";
const map = new maplibregl.Map({
container: "map",
style: await style({ spriteBase: new URL("sprites", document.baseURI).href }),
});style() is async: land hillshading comes from the VersaTiles elevation tiles, and the builder fetches their TileJSON. Glyphs come from https://tiles.openwaters.io/fonts (tile-fonts) — the chart needs a true italic and weights the VersaTiles glyphs don't serve. Options: tiles (seamark TileJSON URL), seascape, versatiles, language, spriteBase, hillshade (on by default; false to skip, or an object to tune the shading), and the seascape passthroughs — flavor (overrides merged over its day), unit, safety, shading, and the dem/vector/coverage source id overrides.
sources() + layers() hand over just the chart symbology for a style you assemble yourself, following the same split as seascape:
import { sources, layers, sprite } from "@openwaters/seamap";
const { areas, symbols } = layers();
const style = {
version: 8,
sources: { ...mySources, ...sources() },
sprite: [mySprite, sprite(new URL("sprites", document.baseURI).href)],
// areas go below land fills and labels; symbols on top of everything
layers: [...myBaseLayers, ...areas, ...myLandLayers, ...symbols],
};
const map = new maplibregl.Map({ style /* ... */ });sources({ url? })— theseamapvector source (defaults tohttps://tiles.openwaters.io/seamap/tiles.json).layers({ font? })— the chart layers, split intoareasandsymbolsto preserve draw order around your land layers.fontrenames the glyph fontstacks ("Noto Sans Regular","Noto Sans Italic", and"Noto Sans Medium Italic"for hazard depths) to match your glyph server.sprite(base)— thestyle.spriteentry pointing at wherever you serve the sheet.
When tags compose a colour combination the sheet doesn't carry, the layers fall back to the shape's generic icon in the style itself (a coalesce of image expressions), so unusual marks never render as nothing.
The built sheet ships in the package at sprites/dist/ (freenauticalchart.{json,png}, @2x variants, the glyph licence, PROVENANCE.md). MapLibre loads sprites from a URL prefix — it appends .json/.png/@2x itself — so the files must be served together under a stable path rather than imported through a bundler's asset pipeline. With Vite:
import { viteStaticCopy } from "vite-plugin-static-copy";
viteStaticCopy({
targets: [{ src: "node_modules/@openwaters/seamap/sprites/dist/*", dest: "sprites" }],
});Icon names are composed from tag values at render time, so the style layers and the sprite sheet must always move together — always serve the sheet from this same package version.
In this repo the sheet is generated, not committed: bin/sprites (here in style/) expands the vendored SVG sources in sprites/ into sprites/dist/. Needs spreet (pinned in the repo's mise.toml) and Python 3. npm publish runs it via prepublishOnly; consumers of the published package never need the toolchain.
The same run composes the poi-* badges via bin/poi-badges, pulling glyphs from the @iconify-json/* dev dependencies (which sets, and their licences, are in sprites/PROVENANCE.md) and wrapping each in a halo and disc. Those packages have to be installed first, so npm install comes before bin/sprites, not after. To add an amenity symbol, map a sprite name to a glyph inside one of the groups in sprites/poi-icons.json — there is nothing to draw. The group decides the disc colour: colour says which question the badge answers (berthing, consumables, haul-out, ashore) and the glyph says which facility, because at 16 units the glyph alone does not resolve until you are already looking at it. Both scripts write into sprites/icons/gen/, which is gitignored, so no generated artwork is ever committed.
The symbology lives in layers/, grouped by what it draws. Each module exports one function returning its layers in draw order; layers/index.ts concatenates them and makes the areas/symbols split.
| module | draws |
|---|---|
| areas.ts | rocks, wrecks and obstructions, seabed quality, restricted and allowed areas |
| routes.ts | traffic separation schemes, ferry routes, navigation lines and tracks, submarine cables and pipelines |
| structures.ts | piers and breakwaters, piles and dolphins, platforms, cranes, shore stations, harbours, small craft facilities |
| lights.ts | lit-mark flares, sector arcs and rays, major and minor lights, fog signals |
| marks.ts | buoys and beacons, topmarks, radar reflectors |
| labels.ts | landmarks and all name text, including the light characteristic |
The order of that concatenation is load-bearing twice. Paint order is the obvious half. The other is symbol collision: MapLibre places symbols in reverse draw order, so a layer listed later wins the anchor in a crowded harbour — which is why labels come last. index.test.ts asserts the full id order, so a reshuffle can't happen by accident.
Layer geometry comes from the seamark and light layers of the seamap tiles; the symbols come from the sprite sheet, and because icon names are composed from tag values the two must move together.
The symbology derives from styles/freenauticalchart.json in signalk-seamap-plugin (CC0), which in turn draws the sprite set from quantenschaum/mapping. Positioning conventions — tight-cropped icons anchored and offset in the style rather than padded in the sheet — follow that project's own vector/styles/s57.json.
Semver tracks the consumer-facing contract: renaming or reordering layer ids, changing the icon naming scheme, or removing icons is major; new layers or icons are minor; visual tweaks are patch.
The code is MIT. The sprite sheet, and the icons and scripts in sprites/ it is built from, are not: the chart symbols are adapted from quantenschaum/mapping with permission, and the POI badges are built from permissively licensed glyph sets — see sprites/PROVENANCE.md.