diff --git a/Makefile b/Makefile index 04dd0c3..4365161 100644 --- a/Makefile +++ b/Makefile @@ -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 demo-chart1 serve-demo preslib-chart1 s64-pages +.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 docs-harness 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 @@ -210,6 +210,31 @@ demo-chart1: build ## Bake the S-52 ECDIS Chart 1 sheet to tiles for the docs (i $(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/" +# ---- assets for the docs "Test harness" page (Chart 1 + S-64 review viewer) ---- +# Bakes BOTH suites into one merged manifest the page's points +# catalog= at, emits the shared widget frontend into /demo/, and crops the IHO +# reference plots the page shows beside our render. All gitignored (large + derived +# from copyrighted PDFs/zips); the page degrades to a build-it hint when absent. +HARNESS_OUT ?= docs/static/harness +HARNESS_ASSETS ?= docs/static/demo +S64_ZIP ?= testdata/S-64_ENC_Unencrypted_TDS.zip + +docs-harness: build ## Bake Chart 1 + S-64 tiles + widget assets + reference crops for the docs Test-harness page + # Widget frontend assets (shared with /demo/; additive — won't disturb a full demo bundle). + @mkdir -p "$(HARNESS_ASSETS)" + $(BIN) emit-assets "$(HARNESS_ASSETS)" $(if $(wildcard $(S101_PC)),--s101 "$(S101_PC)") + @cp -R web/src web/vendor web/glyphs web/basemap "$(HARNESS_ASSETS)/" + @cp web/catalog.json "$(HARNESS_ASSETS)/" 2>/dev/null || true + # Chart 1 + S-64 tiles → one merged manifest. + PRESLIB_CACHE="$(PRESLIB_CACHE)" scripts/fetch-preslib-cells.sh + @mkdir -p "$(HARNESS_OUT)" + $(BIN) bake "$(PRESLIB_CACHE)/cells" -o "$(HARNESS_OUT)/chart1.pmtiles" --bands --max-zoom $(CHART1_MAXZOOM) --manifest "$(HARNESS_OUT)/chart1.index.json" + $(BIN) bake "$(S64_ZIP)" -o "$(HARNESS_OUT)/s64.pmtiles" --bands --manifest "$(HARNESS_OUT)/s64.index.json" + node docs/scripts/merge-manifests.mjs "$(HARNESS_OUT)/charts-index.json" "$(HARNESS_OUT)/chart1.index.json" "$(HARNESS_OUT)/s64.index.json" + # Reference-plot crops (needs pdftoppm + the source PDFs; a missing PDF is a warning). + node docs/scripts/extract-refs.mjs + @echo " docs harness ready — run: make docs" + # 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 diff --git a/docs/.gitignore b/docs/.gitignore index 1e50fad..d11add6 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -12,10 +12,10 @@ # (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/ +# Test-harness assets — generated by `make docs-harness` (Chart 1 + S-64 baked +# tiles + merged manifest + IHO reference-plot crops the Test-harness page shows). +# Large + derived from copyrighted PDFs/zips, never committed. +/static/harness/ # Misc .DS_Store diff --git a/docs/docs/chart1.mdx b/docs/docs/chart1.mdx deleted file mode 100644 index c70fdff..0000000 --- a/docs/docs/chart1.mdx +++ /dev/null @@ -1,56 +0,0 @@ ---- -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. - - - -## 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. -::: diff --git a/docs/docs/intro.mdx b/docs/docs/intro.mdx index 4a7fe21..358c3f4 100644 --- a/docs/docs/intro.mdx +++ b/docs/docs/intro.mdx @@ -59,9 +59,9 @@ static binary for any platform with just `GOOS`/`GOARCH`. 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. + Library **[ECDIS Chart 1](./test-harness.mdx)** reference sheet (plus the S-64 ENC + display tests) — every symbol, line style, area fill, and colour — diffed against the + spec's own plots. ## Beyond the chart diff --git a/docs/docs/test-harness.mdx b/docs/docs/test-harness.mdx new file mode 100644 index 0000000..3ce073e --- /dev/null +++ b/docs/docs/test-harness.mdx @@ -0,0 +1,55 @@ +--- +id: test-harness +title: Test harness (symbol compliance) +sidebar_position: 5 +--- + +import TestHarness from '@site/src/components/TestHarness'; + +# ENC symbology review harness + +Pick a scenario and see our **live render** — drawn by our S-101 portrayal engine, +scaled to the cell's compilation scale — **beside the official IHO reference plot**. The +per-scenario mariner display settings are shown above our render, so it's an +apples-to-apples diff against the spec's own figures. + + + +:::tip +**⤢ Fullscreen** gives you the full three-pane view for serious side-by-side +comparison. **◀ / ▶** (or **↑ / ↓**, or click a row) step through scenarios — both our +render and the reference plot advance together. +::: + +## What's in it + +Two suites, both rendered chrome-free in the read-only `spec` widget: + +- **Chart 1** — the IHO S-52 Presentation Library's reference symbol sheet (ECDIS + "Chart 1"), 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. Each panel maps to a reference plot in the Presentation Library + (Part I §16, doc pages 238–253). The colour-test panels come in **Day** and **Dusk** — + the same tiles, restyled instantly, because colours are stored as S-101 colour + *names*, not fixed RGB. +- **S-64** — the IHO ENC test dataset's cells that have a comparable reference screen + plot: the §3.1 Base / Standard / Other display-category sheets (every object class) + and the §3.2 invalid-object display. The dataset's behavioural/procedural tests + (power-up, overlap, scale-minimum, settings, non-official data) have nothing to diff a + render against, and the §5/6/7 *detection* cases are out of scope — so they're omitted. + +## Reproducing it + +The harness is the interactive face of a repeatable, headless test. The same scenario +manifest drives the spec-diff PNGs (`make preslib-chart1`, `make s64-pages`). To build +the tiles + reference crops this page needs — with the S-52 PresLib digital-files zip +and the S-64 ENC test-dataset zip in `testdata/` — run: + +```bash +make docs-harness # builds Chart 1 + S-64 tiles + widget assets + ref-plot crops into docs/static/ +make docs # build / serve the Docusaurus site +``` + +Until those assets exist, the page shows a short build-it hint in place of the live +widget. The generated tiles and PDF-derived reference crops are git-ignored (large, and +derived from copyrighted source material). diff --git a/docs/scripts/extract-refs.mjs b/docs/scripts/extract-refs.mjs new file mode 100644 index 0000000..a69424d --- /dev/null +++ b/docs/scripts/extract-refs.mjs @@ -0,0 +1,58 @@ +// Extract the official reference-plot pages from the IHO PDFs into per-page JPEGs the +// docs test-harness page (TestHarness.js) shows beside the live render. +// +// node docs/scripts/extract-refs.mjs (run from the repo root) +// +// For every scenario we render a WINDOW of pages around its (best-guess) refPage, so +// the page's ◀/▶ nudge can slide to the exact plot without re-running this. Output → +// docs/static/harness/refs//p.jpg (gitignored, cached: existing pages +// skipped). Needs pdftoppm (poppler). A missing source PDF is a warning, not a failure +// — the chart1 PDF lives in the sibling ../chartplotter-specs repo. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { SCENARIOS, REF_PDFS } from "../src/components/testharness/scenarios.js"; + +const OUT = "docs/static/harness/refs"; +const WINDOW = 12; // pages on each side of refPage (covers printed-vs-PDF page offsets) +const DPI = 110; + +function havePdftoppm() { + try { execFileSync("pdftoppm", ["-v"], { stdio: "ignore" }); return true; } + catch { return false; } +} +if (!havePdftoppm()) { + console.error("extract-refs: pdftoppm (poppler) not found — install it to generate reference crops."); + process.exit(1); +} + +const pagesByPdf = new Map(); +for (const s of SCENARIOS) { + if (!s.refPage) continue; + const set = pagesByPdf.get(s.pdf) || new Set(); + for (let p = Math.max(1, s.refPage - WINDOW); p <= s.refPage + WINDOW; p++) set.add(p); + pagesByPdf.set(s.pdf, set); +} + +let wrote = 0, skipped = 0, missing = 0; +for (const [pdf, pages] of pagesByPdf) { + const src = REF_PDFS[pdf]; + if (!src || !existsSync(src)) { + console.warn(`extract-refs: ${pdf} PDF not found (${src}) — skipping ${pages.size} page(s).`); + missing += pages.size; + continue; + } + const dir = `${OUT}/${pdf}`; + mkdirSync(dir, { recursive: true }); + for (const p of [...pages].sort((a, b) => a - b)) { + const stem = `${dir}/p${p}`; + if (existsSync(`${stem}.jpg`)) { skipped++; continue; } + try { + execFileSync("pdftoppm", ["-jpeg", "-r", String(DPI), "-f", String(p), "-l", String(p), "-singlefile", src, stem], { stdio: "ignore" }); + wrote++; + } catch (e) { + console.warn(`extract-refs: failed page ${p} of ${pdf}: ${e.message}`); + } + } + console.log(`extract-refs: ${pdf} → ${dir} (${pages.size} page(s) in window)`); +} +console.log(`extract-refs: wrote ${wrote}, cached ${skipped}, missing-pdf ${missing}.`); diff --git a/docs/scripts/merge-manifests.mjs b/docs/scripts/merge-manifests.mjs new file mode 100644 index 0000000..27d0951 --- /dev/null +++ b/docs/scripts/merge-manifests.mjs @@ -0,0 +1,22 @@ +// Merge several `bake --manifest` charts-index files into one, by concatenating +// their `districts` arrays, so a single on the docs test-harness page +// holds both the Chart 1 and S-64 pre-baked band archives at once. +// +// node docs/scripts/merge-manifests.mjs … +import { readFileSync, writeFileSync } from "node:fs"; + +const [out, ...ins] = process.argv.slice(2); +if (!out || !ins.length) { + console.error("usage: merge-manifests.mjs "); + process.exit(1); +} +const districts = []; +let aux = ""; +for (const f of ins) { + const m = JSON.parse(readFileSync(f, "utf8")); + for (const d of m.districts || []) districts.push(d); + if (m.aux && !aux) aux = m.aux; +} +const merged = aux ? { districts, aux } : { districts }; +writeFileSync(out, JSON.stringify(merged, null, 2)); +console.log(`merge-manifests: ${districts.length} archive(s) → ${out}`); diff --git a/docs/sidebars.js b/docs/sidebars.js index a4dbf73..ae178ad 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -6,7 +6,7 @@ const sidebars = { 'intro', 'installation', 'getting-started', - 'chart1', + 'test-harness', 'widget', 'cli', 'architecture', diff --git a/docs/src/components/Chart1Tests.js b/docs/src/components/Chart1Tests.js deleted file mode 100644 index b46a835..0000000 --- a/docs/src/components/Chart1Tests.js +++ /dev/null @@ -1,285 +0,0 @@ -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 widget — and turns the docs page into a symbol-compliance -// checker: every panel of the sheet is a row in the list; click one (or arrow up/down) -// and the widget frames that panel. The whole sheet is one contiguous synthetic ENC, -// so navigation is just map.fitBounds(panel). The widget runs in `spec` mode (no -// chrome) with the reference mariner settings forced on, so what you see is an -// apples-to-apples diff against the spec's own reference plots. -// 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)); - -// Spec mode hides all widget chrome, so the fit just needs a small, symmetric margin -// to keep edge symbology off the frame edge — like the thin border around each -// PresLib reference plot. (No asymmetric chrome padding any more.) -const PAD = {top: 18, bottom: 18, left: 18, right: 18}; - -// Mariner display state pinned to match the IHO PresLib reference plots — kept in -// step with the MARINER block in scripts/preslib-chart1.mjs. The widget is read-only -// here (spec mode), so the viewer can't change anything; we force these at ready so -// the render is an apples-to-apples diff against the spec's own figures. ALL symbology -// shown; data-quality overlay on (CATZOC panels); metres (IHO, not NOAA feet); the -// depth-shading demo's 0/5/10/30 contours labelled; date-dependency + meta boundaries -// shown; 25 mm sectors and symbolized boundaries (the S-52 defaults the plots use). -const MARINER = { - displayBase: true, displayStandard: true, displayOther: true, - dataQuality: true, - depthUnit: 'm', - showContourLabels: true, - shallowContour: 5, safetyContour: 10, deepContour: 30, - highlightDateDependent: true, - dateDependent: false, - showMetaBounds: true, - showFullSectorLines: false, - boundaryStyle: 'symbolized', - simplifiedPoints: false, -}; - -// 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 a symmetric margin. 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 listRef = useRef(null); // the
    of panel buttons (for focus moves) - const activeRef = useRef(SHEET); // current panel, for handlers in stale closures - const [active, setActive] = useState(238); - const [status, setStatus] = useState('checking'); // checking | ready | missing - const [full, setFull] = useState(false); // fullscreen: panel list + widget fill the viewport - - // 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: pin the reference mariner settings + Day scheme - // (spec mode is read-only — the viewer can't change them), floor the zoom past the - // SCAMIN cutoff, and frame the whole sheet. - useEffect(() => { - if (status !== 'ready') return undefined; - let tries = 0; - const iv = setInterval(() => { - const el = ref.current; - const m = el && el.map; - if (!m) { if (++tries > 60) clearInterval(iv); return; } - clearInterval(iv); - try { m.setMinZoom(SCAMIN_MIN_ZOOM); } catch (e) { /* older map */ } - // Force the reference display state. applyScheme('day') also resets any scheme - // a previous Day/Dusk click (or the sibling demo) left in localStorage. - if (typeof el.applyMariner === 'function') { - try { el.applyMariner(MARINER); } catch (e) { /* widget best-effort */ } - } - if (typeof el.applyScheme === 'function') { - try { el.applyScheme('day'); } catch (e) { /* widget best-effort */ } - } - fitPanel(el, SHEET, false); - }, 200); - return () => clearInterval(iv); - }, [status]); - - // Entering/leaving fullscreen resizes the map frame, so let the map relayout and - // re-frame the active panel. Also lock page scroll + wire Escape / arrow nav. - useEffect(() => { - if (typeof document !== 'undefined') { - document.body.style.overflow = full ? 'hidden' : ''; - } - const t = setTimeout(() => { - const m = ref.current && ref.current.map; - if (!m) return; - try { m.resize(); } catch (e) { /* older map */ } - fitPanel(ref.current, activeRef.current, false); - }, 80); - if (!full) return () => clearTimeout(t); - // Fullscreen owns the screen, so Up/Down nav works globally even when focus is on - // the map. But if focus is on a panel button, the list's own onKeyDown already - // handled it (and called preventDefault) — bail so we don't step twice. - const onKey = (e) => { - if (e.key === 'Escape') { setFull(false); return; } - if (e.defaultPrevented) return; - onNavKey(e); - }; - window.addEventListener('keydown', onKey); - return () => { - clearTimeout(t); - window.removeEventListener('keydown', onKey); - if (typeof document !== 'undefined') document.body.style.overflow = ''; - }; - }, [full]); // eslint-disable-line react-hooks/exhaustive-deps - - const go = (p) => { - setActive(p.page); - activeRef.current = p; - const el = ref.current; - if (!el) return; - // Day/Dusk colour-test panels carry their own scheme; everything else stays Day. - if (typeof el.applyScheme === 'function') { - try { el.applyScheme(p.scheme || 'day'); } 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}); - } - }; - - // Keyboard nav: Up/Down step through the panel list. Uses activeRef (always - // current) so it works from the fullscreen effect's stale closure too. - const focusBtn = (page) => { - const root = listRef.current; - const btn = root && root.querySelector(`button[data-page="${page}"]`); - if (btn) btn.focus(); - }; - const step = (dir) => { - const i = PANELS.findIndex((p) => p.page === activeRef.current.page); - const ni = Math.min(PANELS.length - 1, Math.max(0, i + dir)); - if (ni === i) return; - go(PANELS[ni]); - focusBtn(PANELS[ni].page); - }; - const onNavKey = (e) => { - if (e.key === 'ArrowDown') { e.preventDefault(); step(1); } - else if (e.key === 'ArrowUp') { e.preventDefault(); step(-1); } - }; - - if (status === 'missing') { - return ( -
    - The S-52 ECDIS Chart 1 symbol sheet rendered by chartplotter -

    - The live, clickable version needs the baked tiles. Build them locally with{' '} - make demo DEMO_OUT=docs/static/demo and{' '} - make demo-chart1 DEMO_CHART1_OUT=docs/static/chart1, then{' '} - make docs. -

    -
    - ); - } - - const zoom = zoomForScale(INITIAL_SCALE, SHEET.lat); - return ( -
    -
    -
    -
    - Reference panels PresLib §16, pp. 238–253 -
    - -
    -
      - {PANELS.map((p) => ( -
    1. - -
    2. - ))} -
    -
    -
    - {/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles. - spec = chrome-free clean map (no controls/databox/attr/scalebar) so the - render matches the PresLib reference plots for side-by-side diffing. */} - -
    -
    - ); -} - -export default function Chart1Tests() { - return ( - Loading the chart…}> - {() => } - - ); -} diff --git a/docs/src/components/TestHarness.js b/docs/src/components/TestHarness.js new file mode 100644 index 0000000..40d2b06 --- /dev/null +++ b/docs/src/components/TestHarness.js @@ -0,0 +1,235 @@ +import React, {useEffect, useRef, useState, useCallback} from 'react'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import {SCENARIOS, framing, effectiveMariner} from './testharness/scenarios'; +import styles from './TestHarness.module.css'; + +// TestHarness publishes the interactive ENC-symbology review harness as a read-only +// docs page: pick a scenario (Chart 1 panels + S-64 viewer cells) and see our LIVE +// render — scaled to the cell's compilation scale — beside the +// official IHO reference plot, with the per-scenario mariner settings shown. It's the +// Docusaurus port of the standalone harness (web/test-harness/*), minus the +// "Send to Claude" note box (that needs the dev Node server). +// +// One long-lived widget in `spec` mode (chrome-free) driven only via +// applyScheme/applyMariner/jumpTo, so the render is an apples-to-apples diff against +// the spec's own figures. Tiles load from the /harness/ bundle (`make docs-harness`); +// the frontend assets are shared with the /demo/ bundle. + +const MAX_PX = 4000; // cap the off-screen render box for very large cells + +// Plain-text-ish summary of the mariner settings shown in the strip. +function marinerParts(m) { + const cat = m.displayOther ? 'Base + Standard + Other' : m.displayStandard ? 'Base + Standard' : 'Base only'; + return [ + ['display', cat], ['safety contour', `${m.safetyContour} m`], ['depths', m.depthUnit === 'm' ? 'metres' : 'feet'], + ['boundaries', m.boundaryStyle], ['quality', m.dataQuality], ['contour labels', m.showContourLabels], ['meta bounds', m.showMetaBounds], + ]; +} + +function Harness() { + // /demo/ holds the widget frontend (built by `make demo`); /harness/ holds the + // Chart 1 + S-64 tiles + merged manifest (built by `make docs-harness`). The widget + // reuses the former for assets and points its tile manifest at the latter. + const demo = useBaseUrl('/demo/'); + const manifest = useBaseUrl('/harness/charts-index.json'); + const refsBase = useBaseUrl('/harness/refs/'); + + const plotRef = useRef(null); // + const stageRef = useRef(null); // clipping viewport for the scaled render + const scalerRef = useRef(null); // the CSS-scaled box holding the plotter + const listRef = useRef(null); + const frameRef = useRef(null); // current framing(): {center, zoom, width, height} + const iRef = useRef(0); // current scenario index, for stale closures + + const [i, setI] = useState(0); + const [status, setStatus] = useState('checking'); // checking | ready | missing + const [full, setFull] = useState(false); + const [refMissing, setRefMissing] = useState(false); + + const scn = SCENARIOS[i]; + + // Only boot the live widget if the tile bundle is actually published. Locally + // (no `make docs-harness`) show the build-it hint instead. + 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]); + + // Size the off-screen render box to the cell at its compilation scale, then CSS-scale + // it to fit the stage — so our render shows the WHOLE cell at the reference plot's scale. + const fitStage = useCallback(() => { + const f = frameRef.current, stage = stageRef.current, scaler = scalerRef.current; + if (!f || !stage || !scaler) return; + const W = Math.min(f.width, MAX_PX), H = Math.min(f.height, MAX_PX); + const k = Math.min(stage.clientWidth / W, stage.clientHeight / H, 1); + scaler.style.transform = `translate(-50%, -50%) scale(${k})`; + }, []); + + const applyScenario = useCallback((s) => { + const p = plotRef.current; + if (!p) return; + try { p.applyScheme(s.scheme); } catch (e) { /* widget best-effort */ } + try { p.applyMariner(effectiveMariner(s)); } catch (e) { /* widget best-effort */ } + const f = framing(s); + frameRef.current = f; + const W = Math.min(f.width, MAX_PX), H = Math.min(f.height, MAX_PX); + if (scalerRef.current) { scalerRef.current.style.width = W + 'px'; scalerRef.current.style.height = H + 'px'; } + p.style.width = W + 'px'; p.style.height = H + 'px'; + fitStage(); + const map = p.map; + if (map) { try { map.resize(); map.jumpTo({center: f.center, zoom: f.zoom}); } catch (e) { /* older map */ } } + else { try { p.setView({lng: f.center[0], lat: f.center[1], zoom: f.zoom}); } catch (e) { /* widget */ } } + setRefMissing(false); + }, [fitStage]); + + // Apply the active scenario once the widget map is ready, and whenever it changes. + useEffect(() => { + if (status !== 'ready') return undefined; + let tries = 0; + const run = () => applyScenario(SCENARIOS[iRef.current]); + const iv = setInterval(() => { + const p = plotRef.current; + if (!p || !p.map) { if (++tries > 60) clearInterval(iv); return; } + clearInterval(iv); + run(); + }, 150); + return () => clearInterval(iv); + }, [status, applyScenario]); + + useEffect(() => { iRef.current = i; if (status === 'ready' && plotRef.current && plotRef.current.map) applyScenario(SCENARIOS[i]); }, [i, status, applyScenario]); + + // Re-fit on window resize and on entering/leaving fullscreen (the stage resizes). + useEffect(() => { + const onResize = () => { const m = plotRef.current && plotRef.current.map; if (m) { try { m.resize(); } catch (e) {} } fitStage(); }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [fitStage]); + + useEffect(() => { + if (typeof document !== 'undefined') document.body.style.overflow = full ? 'hidden' : ''; + const t = setTimeout(() => { const m = plotRef.current && plotRef.current.map; if (m) { try { m.resize(); } catch (e) {} } fitStage(); }, 80); + if (!full) return () => clearTimeout(t); + const onKey = (e) => { + if (e.key === 'Escape') { setFull(false); return; } + if (e.defaultPrevented || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return; + onNavKey(e); + }; + window.addEventListener('keydown', onKey); + return () => { clearTimeout(t); window.removeEventListener('keydown', onKey); if (typeof document !== 'undefined') document.body.style.overflow = ''; }; + }, [full]); // eslint-disable-line react-hooks/exhaustive-deps + + const step = (dir) => { + const ni = Math.min(SCENARIOS.length - 1, Math.max(0, iRef.current + dir)); + if (ni === iRef.current) return; + setI(ni); + const btn = listRef.current && listRef.current.querySelector(`button[data-idx="${ni}"]`); + if (btn) btn.focus(); + }; + const onNavKey = (e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') { e.preventDefault(); step(1); } + else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') { e.preventDefault(); step(-1); } + }; + + if (status === 'missing') { + return ( +
    +

    + The live harness needs the generated tiles + reference plots. Build them locally with{' '} + make docs-harness, then make docs (or cd docs && npm run start). +

    +
    + ); + } + + const eff = effectiveMariner(scn); + const refPage = scn.refPage; + const refSrc = `${refsBase}${scn.pdf}/p${refPage}.jpg`; + + return ( +
    + + +
    +
    + Reference · theirs + {scn.pdf} · p{refPage} +
    +
    + {refMissing + ?
    No reference image for this page.
    Run make docs-harness to extract the PDF crops, then nudge ◀ / ▶ to the exact plot page.
    + : {`reference setRefMissing(true)} />} +
    +
    + +
    +
    + Ours · live render {scn.title} + 1:{scn.cscl.toLocaleString()} + {scn.scheme} +
    +
    + {marinerParts(eff).map(([k, v]) => ( + + {k} {typeof v === 'boolean' ? (v ? 'on' : 'off') : v} + + ))} +
    +
    +
    + {/* widget = read-only viewer; spec = chrome-free clean map; assets = demo + frontend; catalog = Chart 1 + S-64 tiles. */} + +
    +
    +
    +
    + ); +} + +export default function TestHarness() { + return ( + Loading the harness…}> + {() => } + + ); +} diff --git a/docs/src/components/TestHarness.module.css b/docs/src/components/TestHarness.module.css new file mode 100644 index 0000000..b27d73a --- /dev/null +++ b/docs/src/components/TestHarness.module.css @@ -0,0 +1,75 @@ +/* Test-harness component — ported from the standalone web/test-harness.html styles, + adapted to an inline docs block (bounded height) with a fullscreen mode. Light + palette regardless of the docs theme: the chart render + reference plots are light. */ + +.root { + --ui-surface: #fff; --ui-surface-2: #eef1f4; --ui-text: #2a2f35; + --ui-text-dim: #7a828b; --ui-text-faint: #9aa0a8; --ui-border: #e2e2e2; + --ui-border-2: #ededed; --ui-border-strong: #cfcfcf; --ui-hover: #f0f3f6; + --ui-accent: #1565c0; --ui-shadow: rgba(0, 0, 0, .12); --ok: #1f7a36; + display: grid; + grid-template-columns: 220px 1fr 1fr; + height: 600px; + max-height: 80vh; + border: 1px solid var(--ui-border); + border-radius: 8px; + overflow: hidden; + background: var(--ui-surface); + color: var(--ui-text); + font: 13px/1.4 system-ui, -apple-system, sans-serif; +} +.full { + position: fixed; inset: 0; z-index: 1000; + height: 100vh; max-height: none; border: 0; border-radius: 0; + grid-template-columns: 300px 1fr 1fr; +} + +/* ── left rail: scenario list ──────────────────────────────────────────────── */ +.rail { display: grid; grid-template-rows: auto 1fr; min-height: 0; background: var(--ui-surface); border-right: 1px solid var(--ui-border); } +.railHead { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; font-weight: 600; border-bottom: 1px solid var(--ui-border); } +.railHead small { display: block; font-weight: 400; color: var(--ui-text-dim); font-size: 11px; } +.navbtns { display: flex; gap: 5px; flex: none; } +.fullBtn { flex: none; cursor: pointer; border: 1px solid var(--ui-border-strong); background: var(--ui-surface); color: var(--ui-text); border-radius: 6px; padding: 3px 8px; font: inherit; line-height: 1; } +.fullBtn:hover { background: var(--ui-hover); } + +.list { list-style: none; margin: 0; padding: 0; overflow-y: auto; min-height: 0; } +.list li { margin: 0; } +.item { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 8px 12px; border: 0; border-bottom: 1px solid var(--ui-border-2); border-left: 3px solid transparent; background: transparent; color: var(--ui-text); cursor: pointer; font: inherit; } +.item:hover { background: var(--ui-hover); } +.itemActive { background: var(--ui-hover); border-left-color: var(--ui-accent); } +.badge { display: inline-block; flex: none; min-width: 22px; text-align: center; font: 600 9px/1.5 ui-monospace, monospace; color: var(--ui-text-faint); border: 1px solid var(--ui-border); border-radius: 4px; padding: 0 3px; } +.ttl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; font-size: 12.5px; } + +/* ── compare panes ─────────────────────────────────────────────────────────── */ +.pane { position: relative; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid var(--ui-border); } +.pane:last-child { border-right: 0; } +.cap { display: flex; align-items: center; gap: 8px; padding: 7px 12px; background: var(--ui-surface); border-bottom: 1px solid var(--ui-border); color: var(--ui-text-dim); font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; } +.scn { color: var(--ui-text); text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; } +.grow { flex: 1; } +.tag { font: 600 11px/1 system-ui; text-transform: none; letter-spacing: 0; padding: 4px 8px; border-radius: 6px; background: var(--ui-surface-2); color: var(--ui-text-dim); white-space: nowrap; } +.iconbtn { cursor: pointer; border: 1px solid var(--ui-border-strong); background: var(--ui-surface); border-radius: 6px; padding: 4px 9px; font: inherit; color: var(--ui-text); line-height: 1; } +.iconbtn:hover { background: var(--ui-hover); } + +.refwrap { flex: 1; min-height: 0; background: var(--ui-surface-2); display: flex; align-items: center; justify-content: center; overflow: auto; padding: 10px; } +.refImg { max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: 0 2px 12px var(--ui-shadow); background: #fff; border-radius: 2px; } +.refMissing { color: var(--ui-text-dim); padding: 24px; text-align: center; line-height: 1.7; } +.refMissing code { color: var(--ui-accent); } + +.strip { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding: 6px 12px; background: var(--ui-surface-2); border-bottom: 1px solid var(--ui-border); font-size: 11.5px; color: var(--ui-text-dim); } +.s { background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: 5px; padding: 2px 7px; white-space: nowrap; } +.s b { color: var(--ui-text); font-weight: 600; } +.on b { color: var(--ok); } +.off b { color: var(--ui-text-faint); } + +.stage { flex: 1; min-height: 0; position: relative; overflow: hidden; background: var(--ui-surface-2); } +.scaler { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%) scale(1); transform-origin: center; box-shadow: 0 2px 12px var(--ui-shadow); } +.scaler chart-plotter { display: block; } + +.missing, .loading { padding: 24px; border: 1px solid var(--ifm-color-emphasis-300); border-radius: 8px; color: var(--ifm-color-emphasis-700); background: var(--ifm-background-surface-color); } +.missing code { color: var(--ifm-color-primary); } + +/* Narrow screens: drop the reference pane out of the inline grid (still in fullscreen). */ +@media (max-width: 996px) { + .root:not(.full) { grid-template-columns: 200px 1fr; } + .root:not(.full) .pane:nth-child(2) { display: none; } +} diff --git a/docs/src/components/testharness/scenarios.js b/docs/src/components/testharness/scenarios.js new file mode 100644 index 0000000..0ad5b56 --- /dev/null +++ b/docs/src/components/testharness/scenarios.js @@ -0,0 +1,134 @@ +// Scenario manifest for the docs test-harness page (TestHarness.js). Ported from +// the standalone harness's web/test-harness/scenarios.mjs, trimmed to the published +// VIEWER: no Node/extractor bits (REF_PDFS), and the S-64 §5/6/7 "detection" cells +// (nav hazards / special conditions / safety contour) are omitted — we don't claim to +// support those tests. +// +// Each scenario: { id, suite:"chart1"|"s64", title, b:[W,S,E,N], cscl, +// scheme:"day"|"dusk", mariner:{…full settings}, pdf:"chart1"|"s64", refPage }. +// `mariner` is the COMPLETE settings object (applyMariner merges; we pass the whole +// thing). `refPage` is the reference-plot page in the matching PDF. + +// Web-Mercator framing math (512-tile metres/px at z0, 1/96-inch CSS px). +export const M_PER_PX_Z0 = 78271.516964020485; +export const PX_PITCH_M = 0.00026458; +export const zoomForScale = (scale, lat) => + Math.log2((M_PER_PX_Z0 * Math.cos((lat * Math.PI) / 180)) / (PX_PITCH_M * scale)); +export const spanPx = (metres, scale) => Math.max(1, Math.round(metres / scale / PX_PITCH_M)); + +// Center / zoom / pixel size of a scenario's cell at its compilation scale (so the +// cell fills a box sized width×height that we then CSS-scale to the pane). +export function framing(s) { + const [w, sN, e, n] = s.b; + const lat = (sN + n) / 2; + const center = [(w + e) / 2, lat]; + const zoom = zoomForScale(s.cscl, lat); + const lonM = (e - w) * 111320 * Math.cos((lat * Math.PI) / 180); + const latM = (n - sN) * 110574; + return { lat, center, zoom, width: spanPx(lonM, s.cscl), height: spanPx(latM, s.cscl) }; +} + +// ── Chart 1 (IHO PresLib "ECDIS Chart 1") ──────────────────────────────────── +// One mariner state, ALL symbology shown — matches the PresLib reference plots. +export const CHART1_MARINER = { + displayBase: true, displayStandard: true, displayOther: true, + dataQuality: true, + depthUnit: "m", + showContourLabels: true, + shallowContour: 5, safetyContour: 10, deepContour: 30, + highlightDateDependent: true, + dateDependent: false, + showMetaBounds: true, + showFullSectorLines: false, + boundaryStyle: "symbolized", + simplifiedPoints: false, +}; +const c1merge = (o) => ({ ...CHART1_MARINER, ...o }); + +const HARBOR = 14000, OVERVIEW = 60000; +const CHART1_PANELS = [ + { page: 238, slug: "overview", b: [-5.135803, 15.00018, -4.997983, 15.133311], cscl: OVERVIEW, scheme: "day", mariner: { showInformCallouts: true, dataQuality: false } }, + { page: 239, slug: "info-AB1", b: [-5.1307, 15.0993, -5.1002, 15.1288], cscl: HARBOR, scheme: "day" }, + { page: 240, slug: "info-AB2", b: [-5.0982, 15.0993, -5.0677, 15.1288], cscl: HARBOR, scheme: "day" }, + { page: 241, slug: "natural-CDE", b: [-5.0656, 15.0992, -5.0351, 15.1288], cscl: HARBOR, scheme: "day" }, + { page: 242, slug: "port-FOO", b: [-5.0331, 15.0993, -5.0026, 15.1288], cscl: HARBOR, scheme: "day" }, + { page: 243, slug: "depths-HIO", b: [-5.1307, 15.0677, -5.1002, 15.0973], cscl: HARBOR, scheme: "day" }, + { page: 244, slug: "seabed-JKL", b: [-5.0982, 15.0677, -5.0677, 15.0973], cscl: HARBOR, scheme: "day" }, + { page: 245, slug: "traffic-MOO", b: [-5.0656, 15.0677, -5.0351, 15.0973], cscl: HARBOR, scheme: "day" }, + { page: 246, slug: "special-NOO", b: [-5.0331, 15.0677, -5.0026, 15.0973], cscl: HARBOR, scheme: "day" }, + { page: 247, slug: "aids-PRS", b: [-5.1307, 15.0362, -5.1002, 15.0657], cscl: HARBOR, scheme: "day" }, + { page: 248, slug: "buoys-QO1", b: [-5.0982, 15.0362, -5.0676, 15.0657], cscl: HARBOR, scheme: "day" }, + { page: 250, slug: "topmarks-QO2", b: [-5.0656, 15.0362, -5.0350, 15.0657], cscl: HARBOR, scheme: "day" }, + { page: 251, slug: "newobj-vais-MNS", b: [-5.1307, 15.0046, -5.1002, 15.0342], cscl: HARBOR, scheme: "day", mariner: { showInformCallouts: true } }, + { page: 252, slug: "colourtest-WOO-day", b: [-5.0331, 15.0362, -5.0026, 15.0657], cscl: HARBOR, scheme: "day" }, + { page: 253, slug: "colourtest-WOO-dusk", b: [-5.0331, 15.0362, -5.0026, 15.0657], cscl: HARBOR, scheme: "dusk" }, +]; + +// ── S-64 (IHO ENC test dataset) — VIEWER tests only ────────────────────────── +export const S64_MARINER = { + displayBase: true, displayStandard: true, displayOther: true, + dataQuality: true, depthUnit: "m", + shallowContour: 2, safetyContour: 10, safetyDepth: 10, deepContour: 30, + showFullSectorLines: false, boundaryStyle: "symbolized", simplifiedPoints: false, +}; +const s64merge = (o) => ({ ...S64_MARINER, ...o }); + +// Reset baseline applied before each scenario's overrides (applyMariner MERGES, so a +// key one scenario sets and the next omits would otherwise bleed across the one +// long-lived widget). Mirrors the widget's DEFAULT_MARINER. +export const HARNESS_MARINER_RESET = { + displayBase: true, displayStandard: true, displayOther: false, + dataQuality: false, depthUnit: "ft", + shallowContour: 2, safetyContour: 10, safetyDepth: 10, deepContour: 30, + showContourLabels: false, highlightDateDependent: false, dateDependent: true, + showMetaBounds: false, showFullSectorLines: false, showInformCallouts: false, + boundaryStyle: "symbolized", simplifiedPoints: false, +}; +export const effectiveMariner = (s) => ({ ...HARNESS_MARINER_RESET, ...s.mariner }); + +// Only the S-64 cells with a comparable full-cell reference SCREEN PLOT are kept: +// §3.1 Display (Base/Standard/Other) and §3.2 Invalid Objects. The behavioural / +// procedural tests (§2.1.1 power-up, §3.3 settings, §3.4 non-official, §3.6 display +// priorities, §3.7 overlap, §3.7.7 scale-minimum) have nothing to diff a render +// against, and IC3NEWPC isn't in the reference PDF at all — all dropped. The §5/6/7 +// "detection" cells are unsupported; §3.9 Polar / §3.8.5 AML / §4 were never in scope. +const S64_PAGES = [ + // §3.1 ENC Display — same area at the three display categories (every object class). + { section: "3.1 Base", slug: "AA5DBASE", b: [9.833, 10.0, 10.0, 10.167], cscl: 60000, refPage: 100, mariner: s64merge({ displayStandard: false, displayOther: false, dataQuality: false }) }, + { section: "3.1 Standard", slug: "AA5STNDR", b: [10.0, 10.0, 10.167, 10.167], cscl: 70000, refPage: 101, mariner: s64merge({ displayOther: false, dataQuality: false }) }, + { section: "3.1 Other", slug: "AA5OTHER", b: [10.167, 10.0, 10.333, 10.167], cscl: 60000, refPage: 103, mariner: s64merge({}) }, + // §3.2 Invalid objects (unknown class / attribute-driven special presentation). + { section: "3.2 InvalidObject", slug: "AA3INVOB", b: [-104.75, 39.333, -104.5, 39.5], cscl: 50000, refPage: 107, mariner: s64merge({}) }, +]; + +// ── Unified manifest ───────────────────────────────────────────────────────── +const slugify = (s) => s.replace(/[^a-z0-9]+/gi, "_").replace(/^_|_$/g, ""); + +export const SCENARIOS = [ + ...CHART1_PANELS.map((p) => ({ + id: `c1-${p.page}-${slugify(p.slug)}`, + suite: "chart1", + title: `Chart 1 · p${p.page} · ${p.slug}`, + b: p.b, cscl: p.cscl, scheme: p.scheme, + mariner: c1merge(p.mariner || {}), + // The PresLib PDF's printed pages run +9 vs its internal index (front matter). + pdf: "chart1", refPage: p.page + 9, + slug: p.slug, + })), + ...S64_PAGES.map((p) => ({ + id: `s64-${slugify(p.section)}-${p.slug}`, + suite: "s64", + title: `S-64 · §${p.section} · ${p.slug}`, + b: p.b, cscl: p.cscl, scheme: "day", + mariner: p.mariner, + pdf: "s64", refPage: p.refPage, + slug: p.slug, + })), +]; + +// Reference PDFs by `pdf` key, repo-root-relative. Used ONLY by the Node ref-plot +// extractor (docs/scripts/extract-refs.mjs); ignored in the browser bundle. +export const REF_PDFS = { + chart1: "../chartplotter-specs/s52/specs/pslb04_0_part1.pdf", + s64: "testdata/S-64 Ed 3.0.3_EN_Clean_Final.pdf", +};