Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
940191b
fix(bake): best-available for cells without M_COVR; scale-boundary wi…
beetlebugorg Jun 26, 2026
ab9b63a
feat(portrayal): honor SYMINS for NEWOBJ; SWPARE fallback; true symbo…
beetlebugorg Jun 26, 2026
9e7185d
test(preslib): ECDIS Chart 1 render harness + per-class audit breakdown
beetlebugorg Jun 26, 2026
f6f4ff4
feat(web): spec mode (chrome-free) + render Chart-1 pages at compilat…
beetlebugorg Jun 26, 2026
a402a5d
fix(lights): sector legs honor the AugmentedRay length CRS (no more s…
beetlebugorg Jun 26, 2026
724b0b8
fix(web): keep S-52 text labels single-line (text-max-width)
beetlebugorg Jun 26, 2026
98acc05
feat(portrayal): SY(INFORM01) additional-information callout (S-52 §1…
beetlebugorg Jun 26, 2026
d1c6754
test(preslib): set explicit reference mariner settings in the Chart-1…
beetlebugorg Jun 26, 2026
56e095d
feat(web): label installed-chart coverage boxes (find charts on a bla…
beetlebugorg Jun 26, 2026
4a9d24c
feat(search): find installed cells by name — index + /api/cells?activ…
beetlebugorg Jun 26, 2026
2ba0cd7
feat(search): browse active charts when the box is empty (no need to …
beetlebugorg Jun 26, 2026
d450a8a
fix(server): keep the cell index fresh on add / update / remove
beetlebugorg Jun 26, 2026
a0b6456
test(s64): render harness for the S-64 ENC test pages (sibling of pre…
beetlebugorg Jun 26, 2026
eb4bd5e
docs(chart1): live S-52 "ECDIS Chart 1" symbol-compliance page
beetlebugorg Jun 26, 2026
5be94d5
Merge branch 'main' of https://github.com/beetlebugorg/chartplotter-g…
beetlebugorg Jun 26, 2026
6943754
fix(web): don't add the catalog overlay before the style finishes loa…
beetlebugorg Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ jobs:
path: ${{ runner.temp }}/demo-cells
key: demo-cells-${{ hashFiles('scripts/fetch-demo-cells.sh', 'Makefile') }}

# Cache the S-52 PresLib "ECDIS Chart 1" source cells (the IHO draft zip is
# fetched once and extracted), keyed on the fetch script so a change re-pulls.
- name: Cache PresLib Chart 1 cells
uses: actions/cache@v4
with:
path: ${{ runner.temp }}/preslib-cells
key: preslib-cells-${{ hashFiles('scripts/fetch-preslib-cells.sh') }}

# Build the read-only widget demo bundle into docs/static/demo so Docusaurus
# copies it into the published site at /chartplotter/demo/. Needs the S-101
# catalogue (IHO material kept out of the repo): clone it and let `make build`
Expand All @@ -61,6 +69,18 @@ jobs:
DEMO_CACHE="$RUNNER_TEMP/demo-cells" \
DEMO_OUT="docs/static/demo"

# Bake the S-52 "ECDIS Chart 1" reference sheet to tiles into docs/static/chart1
# (published at /chartplotter/chart1/). The symbol-compliance docs page embeds
# it live, reusing the demo bundle's frontend assets. Reuses the S-101 catalogue
# cloned above; the source cells come from the IHO PresLib draft (fetched once).
- name: Build live Chart 1 tiles (docs/static/chart1)
run: |
make demo-chart1 \
S101_PC="$RUNNER_TEMP/s101-pc/PortrayalCatalog" \
S101_FC="$RUNNER_TEMP/s101-fc/S-101FC/FeatureCatalogue.xml" \
PRESLIB_CACHE="$RUNNER_TEMP/preslib-cells" \
DEMO_CHART1_OUT="docs/static/chart1"

- uses: actions/setup-node@v6
with:
node-version: "20"
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ coverage.*
/testdata/full/
/All_ENCs.zip

# S-52 PresLib "ECDIS Chart 1" harness: extracted cells + rendered panels
/testdata/preslib_chart1/
/testdata/preslib-chart1-out/

# Generated tile archives / runtime chart state (never committed)
*.pmtiles
web/noaa.pmtiles
Expand Down Expand Up @@ -39,3 +43,4 @@ web/patterns.json
web/patterns.png
web/sprite.json
web/sprite.png
/testdata/s64-pages-out/
36 changes: 35 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ S101_PC ?= $(HOME)/Projects/s101-portrayal-catalogue/PortrayalCatalog
S101_FC ?= $(HOME)/Projects/s101-feature-catalogue/S-101FC/FeatureCatalogue.xml
S101_CACHE ?= $(CACHE)/s101

.PHONY: build xbuild test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo serve-demo
.PHONY: build xbuild test vet fmt fmt-check tidy clean clear-cache serve docs docs-shots bake-ienc bake-noaa serve-widget demo demo-chart1 serve-demo preslib-chart1 s64-pages

# Prebaked prod test set (US Inland ENC bundle + the NOAA world archive).
# NB: keep these as bare values with NO inline `#` comments — Make folds any
Expand Down Expand Up @@ -192,6 +192,24 @@ demo: build ## Assemble the read-only Annapolis widget demo bundle into $(DEMO_O
@cp -R web/src web/vendor web/glyphs web/basemap "$(DEMO_OUT)/"
@echo " demo bundle ready: $(DEMO_OUT)/ — host it on any static server / CDN"

# ---- live "ECDIS Chart 1" tiles for the docs symbol-compliance page ----
# The S-52 PresLib "ECDIS Chart 1" reference sheet, baked to tiles so the docs
# Chart-1 page embeds it LIVE: one <chart-plotter> widget that reuses the demo
# bundle's frontend assets ($(DEMO_OUT)) and points its tile manifest here via
# catalog="…". So this target emits ONLY the tiles + manifest (~1 MB) — no second
# frontend copy. The whole sheet is one contiguous synthetic ENC, so a click in the
# page's test list just setView()s the widget to that panel at its compilation scale.
# Source cells come from the IHO PresLib draft (fetched + cached; see the script).
PRESLIB_CACHE ?= $(CACHE)/preslib
DEMO_CHART1_OUT ?= dist/chart1
CHART1_MAXZOOM ?= 16

demo-chart1: build ## Bake the S-52 ECDIS Chart 1 sheet to tiles for the docs (into $(DEMO_CHART1_OUT))
PRESLIB_CACHE="$(PRESLIB_CACHE)" scripts/fetch-preslib-cells.sh
@mkdir -p "$(DEMO_CHART1_OUT)"
$(BIN) bake "$(PRESLIB_CACHE)/cells" -o "$(DEMO_CHART1_OUT)/chart1.pmtiles" --bands --max-zoom $(CHART1_MAXZOOM) --manifest "$(DEMO_CHART1_OUT)/charts-index.json"
@echo " chart1 tiles ready: $(DEMO_CHART1_OUT)/ — served beside the demo bundle as /chart1/"

# LOCAL PREVIEW ONLY. The bundle is pure static files — deploy it to ANY
# range-capable static host (GitHub Pages, S3/CloudFront, nginx, `npx serve`); it
# needs no backend. PMTiles are read with HTTP Range, which python's http.server
Expand All @@ -205,6 +223,22 @@ serve-demo: demo ## Preview the static demo bundle locally (range-capable static
docs: ## Run the documentation site dev server (Docusaurus; DOCS_HOST/DOCS_PORT overridable)
cd docs && { [ -d node_modules ] || npm install; } && npm start -- --host $(DOCS_HOST) --port $(DOCS_PORT)

# Render the S-52 PresLib "ECDIS Chart 1" panels (one PNG per reference-plot page)
# with our implementation, for visual diffing against the spec's reference plots
# (PresLib e4.0.0 Part I §16). Self-contained: extracts the cells, bakes+serves via
# the import path, screenshots each panel, tears down. Needs the PresLib zip in
# testdata/ + a headless Chromium. Output → testdata/preslib-chart1-out/ (gitignored).
preslib-chart1: ## Render PresLib "ECDIS Chart 1" panels for spec comparison (one PNG per reference page)
scripts/preslib-chart1.sh

# Render the IHO S-64 ENC test dataset's rendering pages (one PNG per test section)
# for diffing against the S-64 reference plots. Same self-contained flow as
# preslib-chart1, but the S-64 tests vary the mariner settings per page (§3.1 renders
# Base/Standard/Other). Needs the S-64 zip in testdata/ + a headless Chromium.
# Output → testdata/s64-pages-out/ (gitignored).
s64-pages: ## Render S-64 ENC test pages for spec comparison (one PNG per test section)
scripts/s64-pages.sh

# Regenerate the documentation UI screenshots (docs/static/img/ui/*.png) from the
# live app, so they stay in sync when the UI changes. Needs baked charts in the
# S-101 cache (e.g. after `make serve` has imported a region); Chromium +
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ static binary** for any platform with `GOOS`/`GOARCH` and nothing else to instal
- **Live position and AIS (early).** Point a **NMEA 0183** feed at the server (over
TCP) and it shows your **own ship** and **basic AIS targets** on the chart. A
built-in `simulate` command generates traffic for testing.
- **Draws the whole symbol set.** It renders the complete S-52 Presentation Library
**ECDIS "Chart 1"** reference sheet — every symbol, line style, area fill, and
colour — drawn by the same pipeline that bakes real NOAA charts and diffed against
the spec's own plots. [See the rendered sheet →](https://beetlebugorg.github.io/chartplotter/chart1)

<p align="center">
<a href="https://beetlebugorg.github.io/chartplotter/chart1" title="How chartplotter renders the S-52 ECDIS Chart 1 symbol sheet">
<img src="docs/static/img/chart1/page-238-overview.png" alt="chartplotter's render of the full S-52 ECDIS Chart 1 symbol sheet" width="640">
</a>
<br><sub>The S-52 PresLib <b>ECDIS Chart 1</b> symbol sheet, rendered by chartplotter — <code>make preslib-chart1</code>.</sub>
</p>

## 🧩 Beyond the chart

Expand Down
5 changes: 5 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
# (the read-only widget app + baked Annapolis .pmtiles), never committed.
/static/demo/

# Live "ECDIS Chart 1" tiles — generated by
# `make demo-chart1 DEMO_CHART1_OUT=docs/static/chart1` in CI (the baked S-52
# PresLib reference sheet the symbol-compliance page embeds), never committed.
/static/chart1/

# Misc
.DS_Store
.env.local
Expand Down
56 changes: 56 additions & 0 deletions docs/docs/chart1.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
id: chart1
title: Chart 1 (symbol compliance)
sidebar_position: 5
---

import Chart1Tests from '@site/src/components/Chart1Tests';

# ECDIS Chart 1

The S-52 Presentation Library's reference symbol sheet — drawn live by the same
S-101 pipeline that bakes real NOAA charts. **Click a panel to frame it** and diff
our render against the spec's reference plots.

<Chart1Tests />

## What it is

The IHO S-52 Presentation Library ships a reference dataset known as
**ECDIS "Chart 1"** — the digital equivalent of the paper *Chart No. 1* legend
sheets. It is a single synthetic ENC that exercises **every symbol, line style,
area fill, and text instruction** the portrayal catalogue can produce, laid out in
labelled panels by feature group. Rendering the whole sheet correctly is the
standard way to check that a chart engine portrays the catalogue faithfully; each
panel above maps to a reference plot in the Presentation Library (Part I §16, doc
pages 238–253).

:::tip
The colour-test panels come in **Day** and **Dusk** — the same baked tiles, restyled
instantly, because colours are stored as S-101 colour *names*, not fixed RGB. You can
switch Day / Dusk / Night yourself from the widget's display settings at any zoom.
:::

## Reproducing it

The whole sheet is a repeatable test you can run headlessly. With the S-52 PresLib
digital-files zip in `testdata/`, one command extracts the cells, bakes them through
the normal server-side import path, and renders each panel chrome-free at its
compilation scale:

```bash
make preslib-chart1
```

It writes one PNG per reference page to `testdata/preslib-chart1-out/` (gitignored)
for diffing against the Presentation Library's own plots. A sibling harness,
`make s64-pages`, does the same for the IHO **S-64** ENC test dataset.

The live chart on this page is built by `make demo-chart1`, which bakes the same
cells to a small tile bundle the docs site serves beside the
[home-page demo](./intro.mdx).

:::note
ECDIS Chart 1 demonstrates **symbol portrayal coverage**, not navigational fitness.
See [Known limitations](./limitations.md) for what the engine does not yet do.
:::
4 changes: 4 additions & 0 deletions docs/docs/intro.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ static binary for any platform with just `GOOS`/`GOARCH`.
- **Shows live position and AIS (early).** Point a NMEA 0183 feed at the server
over TCP and it draws your own ship and basic AIS targets on the chart; a
`simulate` command generates traffic for testing.
- **Draws the whole symbol set.** It renders the complete S-52 Presentation
Library **[ECDIS Chart 1](./chart1.mdx)** reference sheet — every symbol, line
style, area fill, and colour — drawn by the same pipeline that bakes real NOAA
charts and diffed against the spec's own plots.

## Beyond the chart

Expand Down
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const sidebars = {
'intro',
'installation',
'getting-started',
'chart1',
'widget',
'cli',
'architecture',
Expand Down
186 changes: 186 additions & 0 deletions docs/src/components/Chart1Tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import React, {useEffect, useRef, useState} from 'react';
import BrowserOnly from '@docusaurus/BrowserOnly';
import useBaseUrl from '@docusaurus/useBaseUrl';

// Chart1Tests embeds the S-52 PresLib "ECDIS Chart 1" reference sheet LIVE — one
// read-only <chart-plotter> widget — and turns the docs page into a symbol-compliance
// checker: every panel of the sheet is a row in the list; click one and the widget
// frames that panel. The whole sheet is one contiguous synthetic ENC, so navigation
// is just map.fitBounds(panel) — which fits the panel to the actual map size with
// padding that keeps the widget's own chrome (HUD, controls, scalebar) off the data.
// Tiles load from the /chart1/ bundle (`make demo-chart1`); the frontend assets are
// shared with the /demo/ bundle.

// Web-Mercator scale↔zoom (512-tile metres/px at z0, 1/96-inch CSS px) — only used
// for the pre-fit first paint and the no-map fallback; the real framing is fitBounds.
const M_PER_PX_Z0 = 78271.516964020485;
const PX_PITCH_M = 0.00026458;
const zoomForScale = (scale, lat) =>
Math.log2((M_PER_PX_Z0 * Math.cos((lat * Math.PI) / 180)) / (PX_PITCH_M * scale));

// Inset the fit so the sheet clears the widget's overlaid chrome on every edge.
const PAD = {top: 48, bottom: 56, left: 48, right: 48};

// One row per PresLib reference-plot page (Part I §16, doc pages 238–253). Bounds
// are the cells' data extents [W, S, E, N]; the harbor pages are 1:14 000, the
// overview 1:60 000. Kept in step with the PANELS table in scripts/preslib-chart1.mjs.
const HARBOR = 14000;
const RAW = [
{page: 238, label: 'Whole sheet (overview)', b: [-5.135803, 15.00018, -4.997983, 15.133311], scale: 60000},
{page: 239, label: 'Information about (A, B)', b: [-5.1307, 15.0993, -5.1002, 15.1288]},
{page: 240, label: 'Information about (cont.)', b: [-5.0982, 15.0993, -5.0677, 15.1288]},
{page: 241, label: 'Natural & man-made (C, D, E)', b: [-5.0656, 15.0992, -5.0351, 15.1288]},
{page: 242, label: 'Port features (F)', b: [-5.0331, 15.0993, -5.0026, 15.1288]},
{page: 243, label: 'Depths & currents (H, I)', b: [-5.1307, 15.0677, -5.1002, 15.0973]},
{page: 244, label: 'Seabed & obstructions (J, K, L)', b: [-5.0982, 15.0677, -5.0677, 15.0973]},
{page: 245, label: 'Traffic routes (M)', b: [-5.0656, 15.0677, -5.0351, 15.0973]},
{page: 246, label: 'Special areas (N)', b: [-5.0331, 15.0677, -5.0026, 15.0973]},
{page: 247, label: 'Lights, buoys & beacons (P–S)', b: [-5.1307, 15.0362, -5.1002, 15.0657]},
{page: 248, label: 'Buoys & beacons (Q)', b: [-5.0982, 15.0362, -5.0676, 15.0657]},
{page: 250, label: 'Topmarks (Q)', b: [-5.0656, 15.0362, -5.0350, 15.0657]},
{page: 251, label: 'Approved new objects / V-AIS', b: [-5.1307, 15.0046, -5.1002, 15.0342]},
{page: 252, label: 'Colour-test diagram (Day)', b: [-5.0331, 15.0362, -5.0026, 15.0657], scheme: 'day'},
{page: 253, label: 'Colour-test diagram (Dusk)', b: [-5.0331, 15.0362, -5.0026, 15.0657], scheme: 'dusk'},
];
const PANELS = RAW.map((p) => {
const [w, s, e, n] = p.b;
return {...p, scale: p.scale || HARBOR, lng: (w + e) / 2, lat: (s + n) / 2};
});
const SHEET = PANELS[0]; // page 238 = the whole sheet
const INITIAL_SCALE = 105000; // generous pre-fit paint; fitBounds refines on ready
// These features' SCAMIN is 1:139 000 — zoom out past it and they vanish. Floor the
// map so neither the whole-sheet fit (on a small map) nor a scroll can cross it.
const SCAMIN_MIN_ZOOM = zoomForScale(139000, SHEET.lat);

// Fit the map to a panel's bounds with chrome padding. Returns false if the map
// isn't up yet (caller falls back to setView).
function fitPanel(el, p, animate) {
const m = el && el.map;
if (!m || typeof m.fitBounds !== 'function') return false;
const [w, s, e, n] = p.b;
m.fitBounds([[w, s], [e, n]], {padding: PAD, duration: animate ? 900 : 0});
return true;
}

function Chart() {
// /demo/ holds the widget frontend (baked by `make demo`); /chart1/ holds just
// the Chart 1 tiles + manifest (baked by `make demo-chart1`). The widget reuses
// the former for assets and points its tile manifest at the latter via catalog=.
const demo = useBaseUrl('/demo/');
const manifest = useBaseUrl('/chart1/charts-index.json');
const overviewImg = useBaseUrl('/img/chart1/page-238-overview.png');
const ref = useRef(null);
const [active, setActive] = useState(238);
const [status, setStatus] = useState('checking'); // checking | ready | missing

// Only boot the live widget if the tile bundle is actually published. Locally
// (no `make demo-chart1`) fall back to the static overview image.
useEffect(() => {
let cancelled = false;
fetch(manifest)
.then((r) => {
if (cancelled) return;
if (!r.ok) { setStatus('missing'); return; }
setStatus('ready');
const id = 'chartplotter-widget-module';
if (!document.getElementById(id)) {
const sc = document.createElement('script');
sc.type = 'module';
sc.id = id;
sc.src = `${demo}src/chartplotter.mjs`;
document.head.appendChild(sc);
}
})
.catch(() => { if (!cancelled) setStatus('missing'); });
return () => { cancelled = true; };
}, [demo, manifest]);

// Once the widget's map is ready, frame the whole sheet (fitBounds, not a guessed
// scale, so the entire box + labels show with margin for the chrome).
useEffect(() => {
if (status !== 'ready') return undefined;
let tries = 0;
const iv = setInterval(() => {
const m = ref.current && ref.current.map;
if (m) {
try { m.setMinZoom(SCAMIN_MIN_ZOOM); } catch (e) { /* older map */ }
fitPanel(ref.current, SHEET, false);
clearInterval(iv);
} else if (++tries > 60) {
clearInterval(iv);
}
}, 200);
return () => clearInterval(iv);
}, [status]);

const go = (p) => {
setActive(p.page);
const el = ref.current;
if (!el) return;
if (p.scheme && typeof el.applyScheme === 'function') {
try { el.applyScheme(p.scheme); } catch (e) { /* widget-mode best-effort */ }
}
if (!fitPanel(el, p, true) && typeof el.setView === 'function') {
el.setView({lng: p.lng, lat: p.lat, scale: p.scale, animate: true, duration: 900});
}
};

if (status === 'missing') {
return (
<div className="chart1 chart1--poster">
<img className="chart1__poster" src={overviewImg} alt="The S-52 ECDIS Chart 1 symbol sheet rendered by chartplotter" />
<p className="chart1__hint">
The live, clickable version needs the baked tiles. Build them locally with{' '}
<code>make demo DEMO_OUT=docs/static/demo</code> and{' '}
<code>make demo-chart1 DEMO_CHART1_OUT=docs/static/chart1</code>, then{' '}
<code>make docs</code>.
</p>
</div>
);
}

const zoom = zoomForScale(INITIAL_SCALE, SHEET.lat);
return (
<div className="chart1">
<div className="chart1__panel">
<div className="chart1__title">
Reference panels <span className="chart1__sub">PresLib §16, pp. 238–253</span>
</div>
<ol className="chart1__list">
{PANELS.map((p) => (
<li key={p.page}>
<button
type="button"
className={'chart1__test' + (active === p.page ? ' chart1__test--active' : '')}
onClick={() => go(p)}
>
<span className="chart1__page">p.&nbsp;{p.page}</span>
<span className="chart1__label">{p.label}</span>
</button>
</li>
))}
</ol>
</div>
<div className="liveChart chart1__map">
{/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles */}
<chart-plotter
ref={ref}
widget=""
assets={demo}
catalog={manifest}
basemap="none"
center={`${SHEET.lng},${SHEET.lat}`}
zoom={zoom.toFixed(3)}
/>
</div>
</div>
);
}

export default function Chart1Tests() {
return (
<BrowserOnly fallback={<div className="liveChart liveChart--loading">Loading the chart…</div>}>
{() => <Chart />}
</BrowserOnly>
);
}
Loading