Pelorus Nav — open-source web-based marine chartplotter (PWA). See PLAN.md for full architecture and roadmap.
- Language: TypeScript (strict mode, no
any) - Runtime: Bun (dev/build), browser (prod)
- Map: MapLibre GL JS
- Bundler: Vite
- Testing: Vitest (unit), Playwright (E2E)
- Lint/format: Biome
bun dev— start dev serverbun run build— production buildbun run test— unit tests (Vitest)bun run e2e— E2E tests (Playwright, Chromium only by default; ~45s). Worker count is capped in playwright.config.ts rather than left to Playwright's default: the specs are CPU-bound through software WebGL, so more workers inflates each test's wall-clock toward the timeout without finishing the suite any sooner.bun run e2e:chart-coverage— the S-57 render-coverage harness (tests/e2e/render-test-chart.spec.ts). Its own Playwright project, excluded from the browser projects: it steps a map through all ~266 test-chart variants and takes ~2 min, so it's run deliberately after symbology/style changes, not on everybun run e2e. Writestools/s57-test-chart/out/render-report.md.bun run lint— check lint + formatbun run lint:fix— auto-fix lint + formatbun run typecheck— TypeScript type checkingbun run check— run typecheck + lint + test (use before committing)bun run docs:dev/docs:build— the online user guide (VitePress,docs-site/), served in production at/doc/userguide(route insrc/worker.ts; built intodist/doc/userguidebybun run build, skipped whenCAPACITOR=1)bun run docs:shots [scene ...]— regenerate the guide's screenshots (tools/docs-shots.ts→docs-site/public/images/, committed to git). Deterministic: dev-only?simMode=linearpins the vessel; seeds mirrorsrc/data/db.ts. Needs the dev server running (DOCS_SHOTS_BASEoverrides the default :5173 if the docs dev server holds that port). Re-run after UI changes that alter documented screens, and update the guide when user-visible behavior it documents changes.bun tools/docs-icons.ts— enlarged chart-symbol PNGs for inline guide use, rendered from the day-theme sprite SVGs (needsbun run spritesfirst)
- Use
double quotesandsemicolons(enforced by Biome) - 2-space indent (enforced by Biome)
- No
anytypes (enforced by Biome + TS strict) - Prefer pure functions and interfaces over classes where practical
- Keep modules small and focused; one concept per file
- All utility functions must have unit tests
- Follow NOAA's charts, recommendations and specs (then IHO S-4/S-52) for how
chart data is shown; don't invent conventions. Converted depths and heights
always round down (NOAA Technical Memorandum NOS CS 60 §16–17): use
chartDepthValue/formatChartDepth/formatChartHeight(settings.ts) orchartValueText/chartHeightText(style-context.ts) — neverround. Heights show in feet when depths are in feet or fathoms.
- Floating panels/bars register with
src/ui/SurfaceManager.ts(slot, group, priority) and call the handle'sopened()when shown — never hand-pick z-indexes or hand-wire "panel A closes panel B". Opening a surface evicts other groups from its slot; same-group surfaces coexist (a manager panel and its detail panel form a click-through pair);prioritysurfaces (COB) are never evicted. Outside-tap dismissal is the default (touch devices have no Esc key); bars whose purpose is map interaction opt out withcloseOnOutsideClick: false. Full contract in the module header. - Dialogs that consume Escape call
e.preventDefault()— the global Escape fallback (nav cancel, in ContextMenu.ts) defers one tick and checksdefaultPrevented. - Detail-level visibility is documented in
docs/detail-levels.md— regenerate it withbun tools/detail-levels-report.tsafter changing layer minzooms,LAYER_CATEGORIES, or the detail-level minzoom maps.
src/— application sourcechart/— chart providers, S-52 colours, vector tile styles (styles/)navigation/— GPS providers (simulator, geolocation, NMEA, Signal K), active navvessel/— vessel display, chart mode state machine, course linemap/— map interactions (measurement, route editing, draggable points)ui/— UI controls (HUD, settings panel, route/waypoint managers, context menu)data/— IndexedDB storage, chart catalogutils/— pure utility functions (coordinates, magnetic declination, units)plugins/— Capacitor native plugins
tests/e2e/— Playwright E2E testspublic/— static assets (PMTiles, sprites, glyph fonts, coverage masks)tools/— offline tools (S-57 pipeline, sprite builder, tile upload/check scripts)branding/— brand assets + parametric logo generator (see its README)
In production the app lives at /app; / is the static marketing landing
page (public/landing.html, assets in public/landing/). The app shell is
served only under /app — the service worker's navigateFallbackAllowlist
(vite.config.ts) and the worker's extensionless-path 404 (worker.ts) both
encode this, so adding a future site section (/docs, /help, …) needs only
an explicit route in src/worker.ts; no service-worker or client changes. src/worker.ts
rewrites / to the landing page and handles POST /api/subscribe (newsletter
signups → SUBSCRIBERS KV; run_worker_first in wrangler.toml makes those routes
reach the worker at all). The Vite dev server still serves the app at /
(no worker); preview the landing page at /landing.html. To exercise the real
routing locally: bun run build && bunx wrangler dev --local. The PWA
start_url is /app; the service worker never intercepts /, /landing.html,
or /api/* (denylist in vite.config.ts), and landing assets stay out of the
app's precache. Capacitor builds are unaffected (they load the bundle directly).
- Unit tests: colocate as
*.test.tsnext to source files - E2E tests: in
tests/e2e/ - Playwright needs WebGL flags for headless Chromium (configured in playwright.config.ts)
- Use SimulatorProvider for GPS data in tests (no real hardware needed)
The app has a built-in GPS diagnostic logger (src/navigation/GPSDiagnosticLog.ts)
that records raw, Kalman-filtered, and course-smoothed data at each stage of the
GPS pipeline. It's wired into NavigationDataManager and main.ts, exposed on
window.gpsDiag for console access:
gpsDiag.start()/gpsDiag.stop()— toggle recordinggpsDiag.entryCount— number of entriesgpsDiag.download()— export CSV via share/file savegpsDiag.csv()— return CSV string
Useful for tuning filter constants across different GPS hardware. The logger is
off by default; enable it from the browser console or Chrome DevTools (via
chrome://inspect for Android WebView).
Python CLI for converting NOAA S-57 ENC data → PMTiles vector tiles.
Requires gdal, tippecanoe, and uv installed via brew.
Important: tools/s57-pipeline/data must be a symlink to tile-data/ at the
project root (created by build-tiles.sh). Never create it as a real directory. Always
use tools/build-tiles.sh to run tile builds — don't invoke the pipeline CLI directly
for downloads or builds, as it bypasses the symlink setup.
S-57 has list-typed attributes (COLOUR, CATSPM, STATUS, etc.) that ogr2ogr outputs
as JSON arrays (e.g. ["1","11"]). Since MVT only supports flat values, the pipeline's
enrich_geojson step flattens all list-valued properties to comma-separated strings
(e.g. "1,11"). Front-end code should never expect JSON arrays — always comma-separated
strings or plain integers. The MapLibre style expressions use comma-padding (",1,11,")
for reliable substring matching.
All tile workflows go through tools/build-tiles.sh (run --help for full usage). Always use that to rebuild tiles, not manual commands. Note that multiple regions can serve overlapping tiles. Client-side rendering is preferred over server-side tile pipeline changes unless explicitly told otherwise, since a full tile rebuild takes 2 hours for the US Eastern seabord.
bun run tiles— build boston-test region (quick dev iteration)bun run tiles:build— build all production regionsbun run tiles:build:fresh— download ENCs then build all regionsbun run tiles:check— check NOAA for ENC updates (report only). A cell counts as updated when NOAA's product catalog (ENCProdCat.xml) lists a new edition/update for it; zip dates are ignored (NOAA regenerates every zip nightly). A state DB from before this edition tracking needs a one-timecd tools/s57-pipeline && uv run python ../check-enc-updates.py --seed-from-catalog(only when the current tiles are up to date), or the next check flags every cell.bun run tiles:upload— upload built tiles to CDNbun run tiles:update— full unattended cycle (check → download → build → upload). It rebuilds a changed region's basemap only once that basemap is 30+ days old (--basemap-min-age DAYS), since each rebuild means a large re-download for users. The nightly runs it viatools/tiles-update-cron.sh, which pushes the run's--summary(cells changed, regions rebuilt and why, timings, failures) to ntfy.tools/build-tiles.sh --build --region <name>— build a single region- Regions (see
tools/regions.json, the shared source of truth):boston-test,northern-new-england,southern-new-england,new-york,mid-atlantic,south-atlantic,usvi,gulf-coast,great-lakes,ny-inland,washington,oregon,northern-california,central-california,southern-california,hawaii --forcerebuilds all cells;--downloaddownloads ENCs first;--composite-onlyre-composites only- Output goes to
public/nautical-<region>.pmtiles
- Regions (see
tools/build-tiles.sh --basemap --region <name>— build the offline street basemap (public/basemap-<region>.pmtiles) from the Protomaps daily build: z0–13 over a 10 nm coastal band + z14–15 around US5/US6 harbor cells (takes minutes, no ENC build). Needs the region's charts built first, pluspmtiles+tippecanoe(brew). New regions also need abasemapSizeEstimateinsrc/data/chart-catalog.tsbefore the basemap download appears in the app's Chart Regions panel.
A region's cells are the Active cells of NOAA's product catalog
(tile-data/ENCProdCat.xml, refetched when over an hour old; builds fall back to
the cached copy offline) whose coverage bbox intersects the region's bbox
(s57_pipeline/regions.py). Cancelled cells are deleted datasets and never shown:
they drop out of the region lists (and the region is recomposited without them),
and the build also skips any cell whose data carries a cancellation update
(DSID_EDTN 0).
cd tools/s57-pipeline && uv run python -m s57_pipeline download --region <region>— download ENC cellscd tools/s57-pipeline && uv run python -m s57_pipeline convert -i data/enc/US5MA22M/US5MA22M.000 -o data/tiles/— convert single cellcd tools/s57-pipeline && uv run python -m s57_pipeline pipeline --region <region> -o <output.pmtiles>— run full pipelinecd tools/s57-pipeline && uv run python -m s57_pipeline search-index --annotate <file.search.json ...>— reassign each search entry's state/country (places.py) in existing indices, without rebuilding. Places come from Natural Earth boundaries, fetched once intodata/naturalearth/.cd tools/s57-pipeline && uv run pytest— run pipeline tests
bun run cap:sync— build web + sync to Android projectbun run cap:run— build, sync, and run on connected device/emulatorbun run cap:build— build, sync, and assemble release APK (signed with local keystore)bun run cap:build:debug— build, sync, and assemble debug APK- PMTiles, coverage GeoJSON, and search indices are excluded from the Android bundle and Cloudflare
static-asset upload (served from R2 instead). Vite copies all of
public/intodist/during build (no way to exclude files), so thebuildscript itself runsrm -f dist/*.pmtiles dist/*.coverage.geojson dist/*.search.jsonaftervite build. Originals inpublic/are untouched — the dev server (bun run dev) serves frompublic/directly, so runningcap:buildordeploywhile the dev server is active is safe. - Capacitor builds ship no service worker (
disable: isCapacitorin vite.config.ts) — all assets are bundled, so installing a new APK just works with no stale-cache concerns. main.ts also unregisters any SW left behind by pre-0.10 installs at startup. The "Clear Cache & Reload" button in the About dialog remains as an escape hatch only. - Release signing: keystore at
android/pelorus-release.keystore, config inandroid/signing.properties(both gitignored). To set up on a new machine, generate a keystore withkeytool -genkeypairand createsigning.propertieswith storeFile, storePassword, keyAlias, keyPassword. tools/upload-apk.sh [--build]— upload APK to Dropbox (garyo-dropbox:software/pelorus-nav/)
Pushing a v* tag triggers two workflows, so one tag ships all platforms:
.github/workflows/release.yml— produces a signed Android release APK and publishes it as a GitHub Release (attached assetapp-release.apk)..github/workflows/release-ios.yml("iOS TestFlight") — builds a signed iOS app on a macOS runner and uploads it to TestFlight via altool. After it succeeds the build sits in Apple's processing/review queue (this is the "review can take days" step) — no separate manual iOS build is needed for a tagged release.
(Pushing to main separately auto-deploys the production web app via Cloudflare.
So one tag on an up-to-date main covers web + Android + iOS.)
User-facing changes accumulate under ## [Unreleased] in CHANGELOG.md as they
land. A versioned section is created only at tag time — the web deploys
continuously from main, but a changelog section implies a tagged Android/iOS
release (iOS review can take days, so tags are cut deliberately).
Releasing is always the maintainer's explicit decision. Never bump the
package.json version, create a versioned changelog section, or push a v* tag
without being asked to cut a release — the version bump alone makes deployed
web builds announce a new version to users. Note that pushing to main deploys
the production web app; get the maintainer's go-ahead before pushing. To cut a
new release:
- Add a
## [X.Y.Z] - YYYY-MM-DDsection toCHANGELOG.md— compact and user-focused (omit changes users won't notice). This is the single source of truth for the in-app "What's New" dialog (src/ui/WhatsNewDialog.ts), which shows this version's entry once after an update and links to the changelog on GitHub. - Bump
versioninpackage.jsontoX.Y.Z— it's the app's__APP_VERSION__and what "What's New" keys on. Commit both. - Tag and push:
git tag -a vX.Y.Z -m "Pelorus Nav vX.Y.Z"
git push origin vX.Y.Z
The workflow requirements, worth knowing if it ever breaks:
- Java 21 (not 17) —
capacitor.build.gradleusessourceCompatibility VERSION_21. - Node 22+ — the Capacitor CLI rejects the runner's preinstalled Node 20, so an explicit
setup-node@v4step is required in addition tosetup-bun. contents: writepermission on the job, orgh release createreturns HTTP 403.environment: release— signing secrets (ANDROID_KEYSTORE_BASE64,ANDROID_KEYSTORE_PASSWORD,ANDROID_KEY_ALIAS,ANDROID_KEY_PASSWORD) live in thereleaseGitHub Actions environment, restricted tov*tag refs so PR workflows can't access them. PKCS12 keystore → store password and key password are the same value.
Fully-offline tide and tidal-current predictions, computed client-side with
@neaps/tide-predictor from NOAA harmonic constituents bundled in
public/tides-stations.json. Overlay: src/plugins/tides/TidesOverlay.ts;
prediction core: src/tides/ (schema, bundle loader, tide/current predictors,
formatters). Toggled by the "Tides & Currents" layer group (default off).
bun run tides:build— regenerate the bundle by crawling NOAA MDAPI (~9k calls, ~25 min cold; responses cached intools/tides/.cache/so re-runs take seconds). The bundle is committed to git; constituents change rarely (regenerate roughly yearly, or when NOAA adds stations).bun tools/tides/make-fixture.ts— re-extract the unit-test mini-bundle (src/tides/__fixtures__/mini-bundle.json) after regenerating the bundle.- Model: reference stations carry harmonic constituents (tides: heights about
MLLW via per-station datum offset; currents: signed major-axis velocity,
- = flood toward
floodDir, − = ebb towardebbDir, zero crossing = slack). Subordinate stations apply NOAA time/height(/speed) offsets to their reference's events and get events-only predictions (no continuous curve).
- = flood toward
- The trend arrows (↑/↓) in tide labels need glyph range
8448-8703.pbf(bundled for Noto Sans Regular).
The style carries two named sprites: default — the IHO S-52 chart symbols
(the only symbology scheme) — and basemap — the Protomaps sheet the street
underlay's POI/shield/townspot icons resolve against (icon refs prefixed
basemap: in src/chart/basemap-underlay.ts).
S-52 sprites (tools/sprites/s52/source/) — SVGs go in tools/sprites/s52/source/, use CSS
color classes (fCHBLK, sCHMGD, fISDNG, etc.) that are replaced per-theme (day/dusk/night/eink).
Named with S-52 symbol codes (e.g., ISODGR01.svg). Built into per-theme sheets
(public/sprites/s52-{day,dusk,night,eink}.{json,png} + @2x).
Color class reference: tools/sprites/s52/daySvgStyle.css and tools/sprites/s52/colours.json.
Basemap sprites (public/sprites/basemap-{light,dark,black}.{json,png} + @2x) — downloaded
from Protomaps' published assets by bun tools/sprites/fetch-basemap-sprites.ts and committed;
re-fetch only to pick up upstream icon additions. The black (night) sheet has no POI icons upstream.
Rebuild S-52 sprites after any change: bun run sprites
The active display theme selects which S-52 sheet MapLibre loads. If a symbol is referenced in a
layer's icon-image but missing from the sprite sheet, MapLibre will log
"Image could not be loaded" and show nothing.
MapLibre label glyphs are bundled locally under public/fonts/{fontstack}/{range}.pbf, served
same-origin through the local-glyphs:// protocol (registered in main.ts; the style's glyphs URL is
set in ChartManager.buildStyle). This is required for offline use: the Android WebView has no service
worker, and the old demo-server CDN 404'd some stacks.
- Only the Noto Sans family is bundled —
Regular,Bold,Italic— ranges0-255and256-511(Latin; enough for US chart text) plus8448-8703(tide trend arrows). Everytext-fontin the styles must use one of these stacks. - Text needing a codepoint outside the bundled ranges (e.g. non-Latin place names in the basemap at
world zooms) renders via MapLibre's local system-font fallback, with one console warning per
codepoint ("glyph range not bundled"). The
local-glyphsprotocol exists to make missing ranges fail cleanly: SPA hosting and the Vite dev server answer missing.pbfpaths withindex.html+ HTTP 200, which MapLibre would otherwise parse as protobuf ("Unimplemented type" console spam). - A
text-fontreferencing an unbundled font entirely (e.g.Open Sans Regular) falls back the same way; when adding a label layer, reuse an existing Noto stack. - To add a stack/range, download it from
https://demotiles.maplibre.org/font/<stack>/<range>.pbfintopublic/fonts/. Thepbfextension is in the PWA precache glob (vite.config.ts).
- IHO S-52/S-101 symbol SVGs (primary reference): https://github.com/iho-ohi/S-101_Portrayal-Catalogue/tree/main/PortrayalCatalog/Symbols
- Raw SVG URL pattern:
https://raw.githubusercontent.com/iho-ohi/S-101_Portrayal-Catalogue/main/PortrayalCatalog/Symbols/{SYMBOL_NAME}.svg - Note: these SVGs sometimes simplify shapes (e.g. circles instead of ovals). Cross-reference with OpenCPN's chartsymbols.xml HPGL definitions for exact geometry when needed.
- Raw SVG URL pattern:
- S-52 compliant chart sprites: https://github.com/openwatersio/enc-tiles
- Conventional commits:
feat:,fix:,test:,chore:,docs: - Always run
bun run checkbefore committing — this runs typecheck + lint + test. Do not substitute individual commands (typecheck,test) — the fullcheckcatches formatting issues thattypecheckalone misses. - After every push to main, verify the CI workflow passes (
gh run watchorgh run list) — a push isn't done until CI is green. CI runs the E2E suite in an environment without the gitignored chart artifacts (public/*.pmtilesetc.), so it catches environment-dependent failures that local runs can't. Never tag a release on a main whose latest CI run is red.