diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cde97dd..1d1611c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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` @@ -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" diff --git a/.gitignore b/.gitignore index fdf67d7..1699cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -39,3 +43,4 @@ web/patterns.json web/patterns.png web/sprite.json web/sprite.png +/testdata/s64-pages-out/ diff --git a/Makefile b/Makefile index 038a65d..04dd0c3 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 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 @@ -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 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 @@ -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 + diff --git a/README.md b/README.md index 64fe7cc..0c06e9e 100644 --- a/README.md +++ b/README.md @@ -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) + +

+ + chartplotter's render of the full S-52 ECDIS Chart 1 symbol sheet + +
The S-52 PresLib ECDIS Chart 1 symbol sheet, rendered by chartplotter — make preslib-chart1. +

## 🧩 Beyond the chart diff --git a/docs/.gitignore b/docs/.gitignore index 7ecad62..1e50fad 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -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 diff --git a/docs/docs/chart1.mdx b/docs/docs/chart1.mdx new file mode 100644 index 0000000..c70fdff --- /dev/null +++ b/docs/docs/chart1.mdx @@ -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. + + + +## 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 cdae1db..4a7fe21 100644 --- a/docs/docs/intro.mdx +++ b/docs/docs/intro.mdx @@ -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 diff --git a/docs/sidebars.js b/docs/sidebars.js index cc80ea9..a4dbf73 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -6,6 +6,7 @@ const sidebars = { 'intro', 'installation', 'getting-started', + 'chart1', 'widget', 'cli', 'architecture', diff --git a/docs/src/components/Chart1Tests.js b/docs/src/components/Chart1Tests.js new file mode 100644 index 0000000..473905c --- /dev/null +++ b/docs/src/components/Chart1Tests.js @@ -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 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 ( +
+ 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 */} + +
+
+ ); +} + +export default function Chart1Tests() { + return ( + Loading the chart…}> + {() => } + + ); +} diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index c5bfc35..7b74ee3 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -52,3 +52,125 @@ font-size: 0.85rem; text-align: right; } + +/* The symbol-compliance checker: a live widget over the S-52 ECDIS + Chart 1 sheet, with the reference-panel list beside it so the whole thing fits + one screen. Click a panel → the widget fitBounds() to it (chrome-aware padding). */ +.chart1 { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: 1rem; + align-items: start; + margin: 1.5rem 0; +} +.chart1--poster { + display: block; +} +.chart1__panel { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + max-height: 78vh; + min-height: 520px; +} +.chart1__title { + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ifm-color-emphasis-700); + padding: 0 0.3rem 0.4rem; +} +.chart1__sub { + display: block; + font-weight: 400; + text-transform: none; + letter-spacing: 0; + font-size: 0.72rem; + color: var(--ifm-color-emphasis-500); +} +.chart1__list { + list-style: none; + margin: 0; + padding: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 2px; +} +.chart1__test { + display: flex; + align-items: baseline; + gap: 0.5rem; + width: 100%; + padding: 0.4rem 0.55rem; + text-align: left; + cursor: pointer; + border: 0; + border-left: 3px solid transparent; + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + transition: background 0.12s, border-color 0.12s; +} +.chart1__test:hover { + background: var(--ifm-color-emphasis-100); +} +.chart1__test--active { + background: var(--ifm-color-primary-lightest); + border-left-color: var(--ifm-color-primary); +} +.chart1__page { + min-width: 2.7em; + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + color: var(--ifm-color-emphasis-600); + white-space: nowrap; +} +.chart1__label { + flex: 1; + font-size: 0.85rem; + line-height: 1.25; +} +.chart1__map { + grid-column: 2; + grid-row: 1; + margin: 0; + height: 78vh; + min-height: 520px; +} +.chart1__poster { + width: 100%; + border-radius: 12px; + box-shadow: 0 2px 20px rgba(0, 0, 0, 0.18); +} +.chart1__hint { + margin: 0.75rem 0 0; + font-size: 0.85rem; + color: var(--ifm-color-emphasis-700); +} + +/* Narrow screens (Docusaurus drops the right TOC < 997px): stack, map first. */ +@media (max-width: 996px) { + .chart1 { + grid-template-columns: 1fr; + } + .chart1__map { + grid-column: 1; + grid-row: 1; + height: 58vh; + min-height: 340px; + } + .chart1__panel { + grid-column: 1; + grid-row: 2; + max-height: none; + min-height: 0; + } + .chart1__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } +} diff --git a/docs/static/img/chart1/page-238-overview.png b/docs/static/img/chart1/page-238-overview.png new file mode 100644 index 0000000..c89644d Binary files /dev/null and b/docs/static/img/chart1/page-238-overview.png differ diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 89a8b88..d439390 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -397,6 +397,14 @@ type covMeta struct { displayMin uint32 // lowest zoom this cell's data is shown at (0 for overview/general which overzoom down) bb geo.BoundingBox rings [][][]float64 + // derived is true when these rings were synthesised from the cell's geometry + // extent because it carried no M_COVR (see extractCoverage). A derived rectangle + // marks where the cell IS, not where it actually has area data — so it is trusted + // only for POINT suppression (a finer cell's footprint supersedes a coarser cell's + // point symbol), NOT for area/line FILL suppression, where it would punch nodata + // holes wherever the finer cell's extent has no fill (a sparse legend cell, an + // inter-cell gap). Always false for conformant cells, where M_COVR == data extent. + derived bool } // sectorKey identifies one constructed sector-figure element (anchor + ray/arc @@ -485,7 +493,8 @@ func (b *Baker) groupCoLocatedLights(features []s57.Feature) (primaryText map[in // extractCoverage records a cell's M_COVR (CATCOV=1) data-coverage polygons into // covMeta (keyed to the cell's native band [zr]) — the input to best-available // suppression and DATCVR scale boundaries. -func (b *Baker) extractCoverage(features []s57.Feature, zr ZoomRange, cell string, cscl, displayMin uint32) { +func (b *Baker) extractCoverage(features []s57.Feature, zr ZoomRange, cell string, cscl, displayMin uint32) int { + added := 0 for i := range features { f := &features[i] if f.ObjectClass() != "M_COVR" || intAttr(f.Attributes(), "CATCOV") != 1 { @@ -507,7 +516,59 @@ func (b *Baker) extractCoverage(features []s57.Feature, zr ZoomRange, cell strin b.coverage = append(b.coverage, cov) b.covMeta = append(b.covMeta, cm) b.bbox.ExtendBox(cm.bb) // so the streaming bake has full bounds after pass 1 + added++ + } + // Fallback for a cell with no M_COVR(CATCOV=1) coverage (S-57 requires it, but + // synthetic/test cells — e.g. the S-52 PresLib ECDIS Chart 1 — omit it): derive a + // rectangular coverage from the bounding box of all the cell's geometry. Without + // this the cell contributes NO covMeta, so best-available suppression and DATCVR + // scale boundaries have nothing to test against and a coarser cell's symbols + // double-draw over a finer cell covering the same ground. Real NOAA cells always + // carry M_COVR, so this never triggers for them (no behaviour change). + if added == 0 { + if rect, bb, ok := cellExtentRect(features); ok { + b.coverage = append(b.coverage, CellCoverage{Cell: cell, Rings: [][][]float64{rect}}) + b.covMeta = append(b.covMeta, covMeta{bandMin: zr.Min, bandMax: zr.Max, cscl: cscl, displayMin: displayMin, bb: bb, rings: [][][]float64{rect}, derived: true}) + b.bbox.ExtendBox(bb) + added++ + } + } + return added +} + +// cellExtentRect returns a closed rectangular ring ([lon,lat] points, CW from the +// SW corner) spanning the bounding box of every feature's geometry in a cell, plus +// that box. Used as a coverage fallback for cells lacking M_COVR (see +// extractCoverage). ok is false when the cell has no spatial geometry at all. +func cellExtentRect(features []s57.Feature) ([][]float64, geo.BoundingBox, bool) { + bb := geo.EmptyBox() + any := false + ext := func(pt []float64) { + if len(pt) >= 2 { + bb.ExtendPoint(geo.LatLon{Lon: pt[0], Lat: pt[1]}) + any = true + } + } + for i := range features { + g := features[i].Geometry() + for _, pt := range g.Coordinates { + ext(pt) + } + for _, r := range g.Rings { + for _, pt := range r.Coordinates { + ext(pt) + } + } } + if !any || bb.MinLon > bb.MaxLon || bb.MinLat > bb.MaxLat { + return nil, bb, false + } + rect := [][]float64{ + {bb.MinLon, bb.MinLat}, {bb.MaxLon, bb.MinLat}, + {bb.MaxLon, bb.MaxLat}, {bb.MinLon, bb.MaxLat}, + {bb.MinLon, bb.MinLat}, + } + return rect, bb, true } // cellStem is a cell's dataset name without the .000/.NNN extension. @@ -521,12 +582,14 @@ func cellStem(name string) string { // AddCellCoverage extracts ONLY a cell's coverage + native band (no feature // routing) — the streaming bake's first pass, building the global covMeta once so // each later per-band routing pass can suppress against finer bands without -// re-deriving coverage. Returns the cell's native band. -func (b *Baker) AddCellCoverage(chart *s57.Chart) Band { +// re-deriving coverage. Returns the cell's native band and how many coverage +// polygons it contributed (0 ⇒ the cell had no M_COVR and the extent fallback found +// no geometry — e.g. an M_COVR-only filtered parse; the caller re-parses fully). +func (b *Baker) AddCellCoverage(chart *s57.Chart) (Band, int) { band := BandForScale(uint32(chart.CompilationScale())) cscl := uint32(chart.CompilationScale()) - b.extractCoverage(chart.Features(), band.ZoomRange(), cellStem(chart.DatasetName()), cscl, cellDisplayMin(band, band.ZoomRange())) - return band + n := b.extractCoverage(chart.Features(), band.ZoomRange(), cellStem(chart.DatasetName()), cscl, cellDisplayMin(band, band.ZoomRange())) + return band, n } // cellDisplayMin is the lowest zoom a band's cells are actually drawn at (matches @@ -783,7 +846,15 @@ func (b *Baker) addCell(chart *s57.Chart, pc CellPortrayal) { continue } } - b.route(p, class, fb.DisplayPriority, fb.DisplayCategory, zr, zMin, dr.Max, bnd, pts, drval1, drval2, valdco) + // SY(INFORM01) is the S-52 §10.6.1.1 "additional information available" + // marker — always display priority 8, category Other, regardless of the + // host feature's category (so it clears Standard display and only shows + // when the mariner enables Other). + cat, prio := fb.DisplayCategory, fb.DisplayPriority + if sc, ok := p.(portrayal.SymbolCall); ok && sc.SymbolName == "INFORM01" { + cat, prio = displayCatOther, 8 + } + b.route(p, class, prio, cat, zr, zMin, dr.Max, bnd, pts, drval1, drval2, valdco) } } } @@ -1251,7 +1322,7 @@ func (b *Baker) addScaleBoundary(pts []geo.LatLon, zMin, zMax uint32) { attrs: []mvt.KeyValue{ {Key: "class", Value: mvt.StringVal("SCLBDY")}, {Key: "color_token", Value: mvt.StringVal("CHGRD")}, - {Key: "width_px", Value: mvt.IntVal(2)}, + {Key: "width_px", Value: mvt.IntVal(1)}, // S-52 §10.1.9.1 LS(SOLD,1,CHGRD) }, } b.add(r, bb) @@ -1643,7 +1714,7 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 case r.kind == mvt.GeomPoint: // A point tests its OWN position — a boundary tile keeps coarse points // that fall outside the finer coverage. - if s := b.coverageScaleAt(unnormY(r.wMinY), r.wMinX*360-180, bandZ); s != 0 && s < r.cscl { + if s := b.coverageScaleAt(unnormY(r.wMinY), r.wMinX*360-180, bandZ, true); s != 0 && s < r.cscl { suppressed = true } case r.kind == mvt.GeomLineString: @@ -1658,7 +1729,7 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 // coarse line by the tile CENTRE (a line spans the tile, so the centre is // its representative point): it yields only where the centre has no finer // cell — best-available where the finer cell genuinely carries no data. - if s := b.coverageScaleAt(ctrLat, ctrLon, bandZ); s != 0 && s < r.cscl { + if s := b.coverageScaleAt(ctrLat, ctrLon, bandZ, false); s != 0 && s < r.cscl { suppressed = true } default: @@ -1676,7 +1747,7 @@ func (b *Baker) emitTileInto(coord tile.TileCoord, extent uint32, buffer float64 nLat, sLat := unnormY(float64(coord.Y)/n), unnormY(float64(coord.Y+1)/n) suppressed = true for _, pt := range [...][2]float64{{ctrLat, ctrLon}, {nLat, wLon}, {nLat, eLon}, {sLat, wLon}, {sLat, eLon}} { - if s := b.coverageScaleAt(pt[0], pt[1], bandZ); s == 0 || s >= r.cscl { + if s := b.coverageScaleAt(pt[0], pt[1], bandZ, false); s == 0 || s >= r.cscl { suppressed = false // part of the tile has no finer cell — keep the coarse prim break } @@ -1944,7 +2015,15 @@ func tessellateFigure(sp *sectorPrim, z uint32) []sectorStroke { } return append(out, sectorStroke{points: pts, colorToken: sp.fig.ColorToken, widthPx: float32(widthPx), dashed: dashed, sleg: sleg}) } + // Short leg: display mm by default, but a GeographicCRS leg length is a fixed + // GROUND distance (metres) — convert it to px at this zoom (like the full leg), + // not metres-as-mm (which rendered legs ~10× too long, "shooting out"). legShort := sp.fig.LengthMM * pxPerMM + if sp.fig.LengthGroundM > 0 { + if cosLat := math.Cos(sp.fig.Anchor.Lat * math.Pi / 180.0); cosLat > 1e-6 { + legShort = sp.fig.LengthGroundM / (cosLat * earthCircumM) * worldPx + } + } if sp.fig.FullLengthNM <= 0 { return emit(nil, legShort, -1) // can't extend: the leg is always shown } @@ -2008,8 +2087,8 @@ func (b *Baker) coverageBandAt(lat, lon float64) uint32 { p := geo.LatLon{Lat: lat, Lon: lon} for i := range b.covMeta { cm := &b.covMeta[i] - if cm.bandMax <= best || !cm.bb.Contains(p) { - continue + if cm.derived || cm.bandMax <= best || !cm.bb.Contains(p) { + continue // derived extents gate point suppression only, not area/line fills (see covMeta.derived) } if pointInRings(lon, lat, cm.rings) { best = cm.bandMax @@ -2025,7 +2104,7 @@ func (b *Baker) coverageBandAt(lat, lon float64) uint32 { // across bands AND between cells of different scale that fall in the SAME band (the // per-band coverageBandAt above can't distinguish those). bandZ-gated so a finer // cell that isn't shown yet at this zoom doesn't punch a hole in the coarser one. -func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32) uint32 { +func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32, pointQuery bool) uint32 { var best uint32 // 0 = none found yet; otherwise the finest (smallest) cscl p := geo.LatLon{Lat: lat, Lon: lon} for i := range b.covMeta { @@ -2033,6 +2112,9 @@ func (b *Baker) coverageScaleAt(lat, lon float64, bandZ uint32) uint32 { if cm.cscl == 0 || cm.displayMin > bandZ { continue // unscaled, or this cell isn't drawn at this zoom } + if cm.derived && !pointQuery { + continue // a derived extent rectangle suppresses points, not fills (see covMeta.derived) + } if best != 0 && cm.cscl >= best { continue // not finer than the best so far — skip the costly point test } diff --git a/internal/engine/bake/crossband_line_test.go b/internal/engine/bake/crossband_line_test.go index 5118347..7fb73b0 100644 --- a/internal/engine/bake/crossband_line_test.go +++ b/internal/engine/bake/crossband_line_test.go @@ -82,7 +82,7 @@ func TestCrossBandLineNoDoubleDraw(t *testing.T) { ctrLat := unnormY((float64(c.Y) + 0.5) / n) // A strictly-finer cell covers this tile's centre, yet a coarse line was // kept here AND the finer band also drew one: an interior double-draw. - if s := b.coverageScaleAt(ctrLat, ctrLon, z); s != 0 && s < approachCscl { + if s := b.coverageScaleAt(ctrLat, ctrLon, z, false); s != 0 && s < approachCscl { interior++ t.Errorf("interior line double-draw at %v: tile centre covered by finer cell (cscl %d < approach %d)", c, s, approachCscl) } diff --git a/internal/engine/bake/preslib_chart1_test.go b/internal/engine/bake/preslib_chart1_test.go new file mode 100644 index 0000000..eb5fcba --- /dev/null +++ b/internal/engine/bake/preslib_chart1_test.go @@ -0,0 +1,130 @@ +package bake + +import ( + "archive/zip" + "strings" + "testing" + + "github.com/beetlebugorg/chartplotter/pkg/geo" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// preslibZip is the IHO S-52 PresLib e4.0.0 digital-files download (untracked; +// see scripts/preslib-chart1.sh). Its "ECDIS Chart 1" is a fully symbol-exercising +// dataset of 14 cells, one overview (1:60 000) + 13 harbor pages (1:14 000), but — +// unlike conformant NOAA data — the cells carry NO M_COVR data-coverage features. +const preslibZip = "../../../testdata/S-52_PresLib_e4.0.0_Digital_Files_Draft.zip" + +// loadPresLibChart1 parses every ECDIS-Chart-1 .000 cell straight out of the zip, +// or skips the test if the (untracked) download is absent. +func loadPresLibChart1(t *testing.T) []*s57.Chart { + t.Helper() + zr, err := zip.OpenReader(preslibZip) + if err != nil { + t.Skipf("PresLib zip not present (%v); see scripts/preslib-chart1.sh", err) + } + t.Cleanup(func() { zr.Close() }) + var charts []*s57.Chart + for _, f := range zr.File { + if !strings.Contains(f.Name, "ECDIS_Chart_1/") || !strings.HasSuffix(f.Name, ".000") { + continue + } + chart, err := s57.ParseFS(zr, f.Name) + if err != nil { + t.Fatalf("parse %s: %v", f.Name, err) + } + charts = append(charts, chart) + } + if len(charts) == 0 { + t.Fatal("no ECDIS_Chart_1 .000 cells found in zip") + } + return charts +} + +// TestPresLibChart1DerivedCoverage guards the cross-band best-available fix for +// cells lacking M_COVR. The S-52 PresLib ECDIS Chart 1 stacks a 1:60 000 overview +// over thirteen 1:14 000 harbor pages covering the same ground, but none of the +// cells carry an M_COVR coverage polygon — so the M_COVR-driven suppression had +// nothing to test and every overview feature double-drew over the harbor pages +// (the "smashed together" symbols). extractCoverage now derives a coverage +// rectangle from each cell's data extent when M_COVR is absent, restoring +// per-cell (block) suppression. The invariant: a point inside a harbor page must +// report a FINER (smaller) covering scale than the overview, so an overview +// symbol there is suppressed. +func TestPresLibChart1DerivedCoverage(t *testing.T) { + charts := loadPresLibChart1(t) + + b := New() + var overviewCscl, harborCscl uint32 + for _, chart := range charts { + // Sanity: these cells really have no M_COVR — that's why the fix is needed. + for i := range chart.Features() { + if chart.Features()[i].ObjectClass() == "M_COVR" { + t.Fatalf("cell %s unexpectedly has M_COVR — fixture changed", chart.DatasetName()) + } + } + band, n := b.AddCellCoverage(chart) + if n == 0 { + t.Errorf("cell %s contributed no coverage (derived-extent fallback failed)", chart.DatasetName()) + } + switch cscl := uint32(chart.CompilationScale()); band { + case BandApproach: + overviewCscl = cscl + case BandHarbor: + harborCscl = cscl + } + } + if overviewCscl == 0 || harborCscl == 0 { + t.Fatalf("expected both an approach overview and harbor pages (overview=%d harbor=%d)", overviewCscl, harborCscl) + } + if harborCscl >= overviewCscl { + t.Fatalf("harbor scale %d should be finer (smaller) than overview %d", harborCscl, overviewCscl) + } + + // A point in the middle of harbor page AA5C1CDE (top row, third column). At a + // harbor display zoom the finest covering cell there must be the harbor page, + // not the overview — i.e. an overview prim at this point gets suppressed. + const harborZ = 13 // BandHarbor display min + if got := b.coverageScaleAt(15.11405, -5.05035, harborZ, true); got != harborCscl { + t.Errorf("coverageScaleAt inside harbor page = %d, want harbor cscl %d "+ + "(no finer cover ⇒ overview would double-draw)", got, harborCscl) + } + + // Every cell's derived coverage must lie within the overview's footprint + // (they tile the same ground), confirming the rectangles are sane. + ov := geo.LatLon{Lat: 15.0668, Lon: -5.0669} // overview centre + if !b.coverageBandAtOK(ov) { + t.Error("overview centre not covered by any derived coverage polygon") + } +} + +// coverageBandAtOK reports whether any coverage polygon contains p (test helper). +func (b *Baker) coverageBandAtOK(p geo.LatLon) bool { + for i := range b.covMeta { + cm := &b.covMeta[i] + if cm.bb.Contains(p) && pointInRings(p.Lon, p.Lat, cm.rings) { + return true + } + } + return false +} + +// TestPresLibChart1NoMCovrIsExtentFallback verifies the lower-level contract: a +// cell with no M_COVR yields exactly one DERIVED coverage rectangle spanning its +// geometry, and a (hypothetical) conformant cell would not. +func TestPresLibChart1NoMCovrIsExtentFallback(t *testing.T) { + charts := loadPresLibChart1(t) + b := New() + for _, chart := range charts { + before := len(b.covMeta) + b.AddCellCoverage(chart) + added := b.covMeta[before:] + if len(added) != 1 { + t.Errorf("cell %s: expected 1 derived coverage rect, got %d", chart.DatasetName(), len(added)) + continue + } + if !added[0].derived { + t.Errorf("cell %s: coverage not flagged derived", chart.DatasetName()) + } + } +} diff --git a/internal/engine/baker/baker.go b/internal/engine/baker/baker.go index dda44f3..c201e86 100644 --- a/internal/engine/baker/baker.go +++ b/internal/engine/baker/baker.go @@ -419,7 +419,19 @@ func BakeToPMTilesBandsStreaming(cells map[string]CellData, maxZoom uint32, onSk }, func(string, *s57.Chart) struct{} { return struct{}{} }, func(name string, chart *s57.Chart, _ struct{}) { - band := b.AddCellCoverage(chart) + band, n := b.AddCellCoverage(chart) + if n == 0 { + // The M_COVR-only coverage parse found no data-coverage polygon (the + // cell omits M_COVR — non-conformant, e.g. the S-52 PresLib test cells). + // Re-parse it fully so AddCellCoverage's bounding-box fallback has the + // cell's geometry to derive a coverage rectangle from; otherwise the cell + // contributes nothing to covMeta and a coarser band's symbols double-draw + // over it. Rare (real ENCs always carry M_COVR), so the extra parse is fine. + cd := cells[name] + if full, err := ParseCellWithUpdates(name, cd.Base, cd.Updates); err == nil { + band, _ = b.AddCellCoverage(full) + } + } byBand[band.ZoomRange().Max] = append(byBand[band.ZoomRange().Max], name) parsed++ if progress != nil { diff --git a/internal/engine/baker/s101audit_test.go b/internal/engine/baker/s101audit_test.go index efe8bba..811709c 100644 --- a/internal/engine/baker/s101audit_test.go +++ b/internal/engine/baker/s101audit_test.go @@ -196,6 +196,12 @@ func TestS101Audit(t *testing.T) { } } // Empty-but-mapped is the silent-suppression gap; list those classes. + t.Logf("=== classes with errors (errd>0) ===") + for _, c := range sortedKeys3(classStat) { + if s := classStat[c]; s.errd > 0 { + t.Logf(" %-8s errd=%d total=%d ok=%d", c, s.errd, s.total, s.ok) + } + } t.Logf("=== classes with empty (mapped, no-error, emitted nothing) ===") for _, c := range sortedKeys3(classStat) { s := classStat[c] diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index 715f91b..80d7f4f 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -657,6 +657,92 @@ func unknownObjectBuild(f *s57.Feature) FeatureBuild { } } +// newObjectBuild portrays an S-57 NEWOBJ whose primitive the S-101 alias rule +// (VirtualAISAidToNavigation) rejects: that rule is POINT-only, so a line or area +// NEWOBJ errors and would otherwise be suppressed (drawing nothing). The S-52 +// PresLib reference (§10.3.3.8, "Default symbol for NEWOBJ") draws line/area new +// objects with a dashed magenta boundary, so emit that. Point NEWOBJ never reaches +// here — it portrays through the V-AIS rule, which is correct for real S-101 data +// (V-AIS is encoded as a point NEWOBJ). +func newObjectBuild(f *s57.Feature) FeatureBuild { + g := f.Geometry() + toLL := func(cs [][]float64) []geo.LatLon { + out := make([]geo.LatLon, 0, len(cs)) + for _, c := range cs { + if len(c) >= 2 { + out = append(out, geo.LatLon{Lat: c[1], Lon: c[0]}) + } + } + return out + } + dashed := func(pts []geo.LatLon, closed bool) Primitive { + if closed && len(pts) > 1 && pts[0] != pts[len(pts)-1] { + pts = append(pts, pts[0]) // close the ring + } + return StrokeLine{Points: pts, ColorToken: "CHMGF", WidthPx: 1.5, Dash: DashDashed} + } + var prims []Primitive + switch g.Type { + case s57.GeometryTypeLineString: + if pts := toLL(g.Coordinates); len(pts) >= 2 { + prims = append(prims, dashed(pts, false)) + } + case s57.GeometryTypePolygon: + for _, r := range g.Rings { + if pts := toLL(r.Coordinates); len(pts) >= 2 { + prims = append(prims, dashed(pts, true)) + } + } + } + if len(prims) == 0 { + return FeatureBuild{DisplayCategory: displayStandard} + } + return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard} +} + +// sweptAreaBuild portrays an S-57 SWPARE (swept area). Its S-101 class is +// SweptArea, but the Portrayal Catalogue ships no SweptArea.lua rule (an IHO gap), +// so it errors and would be suppressed. The S-52 PresLib reference (page 243) +// draws a dashed boundary around the area plus a "swept to " depth label, +// so emit that. +func sweptAreaBuild(f *s57.Feature) FeatureBuild { + g := f.Geometry() + if g.Type != s57.GeometryTypePolygon { + return FeatureBuild{DisplayCategory: displayStandard} + } + ringLL := func(cs [][]float64) []geo.LatLon { + out := make([]geo.LatLon, 0, len(cs)) + for _, c := range cs { + if len(c) >= 2 { + out = append(out, geo.LatLon{Lat: c[1], Lon: c[0]}) + } + } + if len(out) > 1 && out[0] != out[len(out)-1] { + out = append(out, out[0]) // close the ring + } + return out + } + var prims []Primitive + for _, r := range g.Rings { + if pts := ringLL(r.Coordinates); len(pts) >= 2 { + prims = append(prims, StrokeLine{Points: pts, ColorToken: "CHGRD", WidthPx: 1, Dash: DashDashed}) + } + } + if len(prims) == 0 { + return FeatureBuild{DisplayCategory: displayStandard} + } + // "swept to " depth label at the area's representative point. + if d, ok := floatAttr(f.Attributes(), "DRVAL1"); ok { + if a, ok := areaSurfacePoint(ringLL(exteriorRing(g))); ok { + prims = append(prims, DrawText{ + Anchor: a, Text: "swept to " + strconv.FormatFloat(d, 'f', -1, 64), + FontSizePx: 11, ColorToken: "CHBLK", HAlign: HAlignCenter, VAlign: VAlignMiddle, + }) + } + } + return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard} +} + // representativePoint returns a single lat/lon to anchor a point symbol on a // feature of any geometry: the point itself, a line's midpoint vertex, or an // area's exterior-ring centroid. ok is false when the geometry carries no usable diff --git a/internal/engine/portrayal/inform_test.go b/internal/engine/portrayal/inform_test.go new file mode 100644 index 0000000..3e10b6f --- /dev/null +++ b/internal/engine/portrayal/inform_test.go @@ -0,0 +1,38 @@ +package portrayal + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// TestInformSymbolAdditional: an object carrying INFORM gets SY(INFORM01) appended +// (S-52 §10.6.1.1); one without does not. +func TestInformSymbolAdditional(t *testing.T) { + pt := s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}} + countInform := func(fb FeatureBuild) int { + n := 0 + for _, p := range fb.Primitives { + if sc, ok := p.(SymbolCall); ok && sc.SymbolName == "INFORM01" { + n++ + } + } + return n + } + + withInfo := s57.NewFeature(1, "BOYLAT", pt, map[string]any{"INFORM": "lit by night"}) + if got := countInform(addInformSymbol(FeatureBuild{}, &withInfo)); got != 1 { + t.Errorf("INFORM-bearing feature: INFORM01 count = %d, want 1", got) + } + + noInfo := s57.NewFeature(2, "BOYLAT", pt, map[string]any{"COLOUR": "3"}) + if got := countInform(addInformSymbol(FeatureBuild{}, &noInfo)); got != 0 { + t.Errorf("plain feature: INFORM01 count = %d, want 0", got) + } + + // TXTDSC also qualifies (case 2 of §10.6.1.1). + txt := s57.NewFeature(3, "WRECKS", pt, map[string]any{"TXTDSC": "wreck.txt"}) + if got := countInform(addInformSymbol(FeatureBuild{}, &txt)); got != 1 { + t.Errorf("TXTDSC-bearing feature: INFORM01 count = %d, want 1", got) + } +} diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go index f66d184..367ebd2 100644 --- a/internal/engine/portrayal/primitive.go +++ b/internal/engine/portrayal/primitive.go @@ -12,9 +12,14 @@ import "github.com/beetlebugorg/chartplotter/pkg/geo" // DefaultPxPerSymbolUnit is screen px per 0.01-mm PresLib symbol unit at 100% // zoom — the nominal S-52 feature scale shared by the symbol/linestyle renderers -// and the tile engine's LC/AP/sector sizing. 0.01 / 0.35278 mm-per-pt renders -// every glyph at its encoded physical size. -const DefaultPxPerSymbolUnit float32 = 0.01 / 0.35278 +// and the tile engine's LC/AP/sector sizing. It MUST use the same reference pixel +// pitch the rest of the app measures the screen with (web util.mjs +// DEFAULT_PX_PITCH_MM = 0.26458 mm, the 1/96-inch CSS reference pixel) so a symbol +// renders at its encoded physical size: the S-52 size-check symbol SY(CHKSYM01), +// a 5 mm box, then measures 5 mm (500 units × this = 18.9 px × 0.26458 mm = 5 mm). +// (Previously 0.35278 — the 1/72-inch point — which rendered every symbol ~25% too +// small against the app's 0.26458 mm pixel.) +const DefaultPxPerSymbolUnit float32 = 0.01 / 0.26458 // Dash is a simple line-stroke dash style (LS instruction). type Dash uint8 @@ -162,6 +167,10 @@ type AugmentedFigure struct { // Ray params (true-north bearing, already from-seaward-reversed by the rule). BearingDeg float64 LengthMM float64 + // LengthGroundM is a ray leg's length when given as a GROUND distance (metres, + // from a GeographicCRS sectorLineLength / full-VALNMR leg) rather than display + // mm — drawn zoom-dependently. 0 ⇒ use LengthMM (display mm). See tessellateFigure. + LengthGroundM float64 // Arc params (centred on Anchor); a full 360° sweep is an all-round ring. RadiusMM float64 StartDeg float64 diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 3d0d293..e6860c0 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -249,8 +249,57 @@ func (b *S101Builder) Build(f *s57.Feature) (FeatureBuild, bool) { return m[f.ID()], true } -// buildFeature turns one feature's emitted instruction stream into its FeatureBuild. +// buildFeature turns one feature's emitted instruction stream into its FeatureBuild, +// then adds the S-52 §10.6.1.1 additional-information indicator when the object +// carries it (see addInformSymbol). func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { + fb := b.buildFeatureBody(f, stream) + return addInformSymbol(fb, f) +} + +// addInformSymbol appends SY(INFORM01) at the object's position when it carries +// additional information (INFORM/NINFOM, or TXTDSC/NTXTDS/PICREP) — S-52 §10.6.1.1. +// INFORM01 is a box-on-a-leader "info available" marker; it's baked display-category +// Other (the bake routes it so, overriding the host feature's category), so it +// clears Standard display and only shows when the mariner enables Other. The pivot +// goes at a point's position / a line's midpoint / an area's centre. +func addInformSymbol(fb FeatureBuild, f *s57.Feature) FeatureBuild { + if !hasAdditionalInfo(f.Attributes()) { + return fb + } + anchor, ok := representativePoint(f) + if !ok { + return fb + } + fb.Primitives = append(fb.Primitives, SymbolCall{ + Anchor: anchor, SymbolName: "INFORM01", Scale: DefaultPxPerSymbolUnit, + SoundingDepthM: nan32, DangerDepthM: nan32, + }) + return fb +} + +// hasAdditionalInfo reports whether an object carries S-52 §10.6.1.1 ancillary +// information (a non-empty INFORM/NINFOM/TXTDSC/NTXTDS/PICREP attribute). +func hasAdditionalInfo(attrs map[string]any) bool { + for _, k := range [...]string{"INFORM", "NINFOM", "TXTDSC", "NTXTDS", "PICREP"} { + if s, _ := attrs[k].(string); strings.TrimSpace(s) != "" { + return true + } + } + return false +} + +// buildFeatureBody turns one feature's emitted instruction stream into its FeatureBuild. +func (b *S101Builder) buildFeatureBody(f *s57.Feature, stream string) FeatureBuild { + // NEWOBJ with a SYMINS attribute: portray the producer's explicit symbol + // instruction (S-52 SYMINS02) rather than the S-101 V-AIS alias the engine + // emitted — SYMINS carries the real symbols, TX/TE labels, boundaries and fills + // (the bulk of the ECDIS-Chart-1 test content). See parseSYMINS. + if f.ObjectClass() == "NEWOBJ" { + if fb, ok := parseSYMINS(f); ok { + return fb + } + } // Genuinely-unknown object class (no S-101 alias) → the magenta "unknown // object" mark (S-52 §10.1.1 parity). if strings.HasPrefix(stream, "UNMAPPED:") { @@ -260,6 +309,19 @@ func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { // chart with placeholders. (Most current errors are line/area rules needing // the S-57 spatial topology the host doesn't model yet — a tracked gap.) if stream == "" || strings.HasPrefix(stream, "ERROR:") { + // NEWOBJ aliases to the POINT-only VirtualAISAidToNavigation rule, so its + // line/area variants always error here; draw the S-52 dashed magenta new-object + // boundary instead of dropping them (the missing boxes/lines around things). + switch f.ObjectClass() { + case "NEWOBJ": + if nb := newObjectBuild(f); len(nb.Primitives) > 0 { + return nb + } + case "SWPARE": + if sb := sweptAreaBuild(f); len(sb.Primitives) > 0 { + return sb + } + } return FeatureBuild{DisplayCategory: displayStandard} } diff --git a/internal/engine/portrayal/s101emit.go b/internal/engine/portrayal/s101emit.go index 03db125..e81865b 100644 --- a/internal/engine/portrayal/s101emit.go +++ b/internal/engine/portrayal/s101emit.go @@ -156,6 +156,7 @@ func emitPrimitives(cmd instructions.DrawCommand, geom S101Geometry, cat *catalo fig.Ray = true fig.BearingDeg = ag.BearingDeg fig.LengthMM = ag.LengthMM + fig.LengthGroundM = ag.LengthGroundM case instructions.AugArc: fig.RadiusMM = ag.RadiusMM fig.StartDeg = ag.StartDeg diff --git a/internal/engine/portrayal/symins.go b/internal/engine/portrayal/symins.go new file mode 100644 index 0000000..766a936 --- /dev/null +++ b/internal/engine/portrayal/symins.go @@ -0,0 +1,331 @@ +package portrayal + +import ( + "fmt" + "strconv" + "strings" + + "github.com/beetlebugorg/chartplotter/pkg/geo" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// SYMINS02 (S-52 PresLib §13.2.18 / §10.3.3.8) — portray an S-57 NEWOBJ from its +// SYMINS attribute, the producer's explicit "symbol instruction" string. SYMINS is +// a ';'-separated list of S-52 draw instructions — SY()/TX()/TE()/LS()/LC()/AC()/ +// AP() — that we render verbatim, instead of routing NEWOBJ to the V-AIS alias +// (the S-101 FeatureCatalogue maps NEWOBJ→VirtualAISAidToNavigation, which would +// stamp a V-AIS mark and ignore the producer's instruction). This is how the S-52 +// PresLib "ECDIS Chart 1" labels (164 TX), boundaries (LS/LC), fills (AC/AP) and +// the size-check symbol SY(CHKSYM01) are drawn. +// +// Returns ok=false when the feature has no usable SYMINS, so the caller falls back +// to the default new-object symbology. +func parseSYMINS(f *s57.Feature) (FeatureBuild, bool) { + attrs := f.Attributes() + raw, _ := attrs["SYMINS"].(string) + raw = strings.TrimSpace(raw) + if raw == "" { + return FeatureBuild{}, false + } + g := geometryOf(f.Geometry()) + anchor, hasAnchor := representativePoint(f) + + var prims []Primitive + for _, instr := range splitSyminsInstructions(raw) { + op, params, ok := splitSyminsOp(instr) + if !ok { + continue + } + switch op { + case "SY": // point symbol — SY(NAME[,rot]) + if !hasAnchor { + continue + } + args := splitSyminsArgs(params) + name := strings.TrimSpace(firstOr(args, "")) + if name == "" { + continue + } + rot := float32(0) + if len(args) > 1 { + if v, err := strconv.ParseFloat(strings.TrimSpace(args[1]), 32); err == nil { + rot = float32(v) + } + } + prims = append(prims, SymbolCall{ + Anchor: anchor, SymbolName: name, RotationDeg: rot, + Scale: DefaultPxPerSymbolUnit, SoundingDepthM: nan32, DangerDepthM: nan32, + }) + case "TX", "TE": // text label + if !hasAnchor { + continue + } + if t, ok := parseSyminsText(op, params, attrs, anchor); ok { + prims = append(prims, t) + } + case "LS": // simple line — LS(style,width,colour) + args := splitSyminsArgs(params) + if len(args) < 3 { + continue + } + w, _ := strconv.Atoi(strings.TrimSpace(args[1])) + if w <= 0 { + w = 1 + } + color := strings.TrimSpace(args[2]) + dash := syminsDash(strings.TrimSpace(args[0])) + for _, line := range syminsLines(g) { + prims = append(prims, StrokeLine{Points: line, ColorToken: color, WidthPx: float32(w), Dash: dash}) + } + case "LC": // complex (symbolised) line — LC(LINESTYLE) + name := strings.TrimSpace(firstOr(splitSyminsArgs(params), "")) + if name == "" { + continue + } + for _, line := range syminsLines(g) { + prims = append(prims, LinePattern{Points: line, LinestyleName: name}) + } + case "AC": // area colour fill — AC(COLOUR[,transp]) + color := strings.TrimSpace(firstOr(splitSyminsArgs(params), "")) + if color != "" && len(g.area) > 0 { + prims = append(prims, FillPolygon{Rings: g.area, ColorToken: color}) + } + case "AP": // area pattern fill — AP(PATTERN) + name := strings.TrimSpace(firstOr(splitSyminsArgs(params), "")) + if name != "" && len(g.area) > 0 { + prims = append(prims, PatternFill{Rings: g.area, PatternName: name}) + } + } + } + if len(prims) == 0 { + return FeatureBuild{}, false + } + return FeatureBuild{Primitives: prims, DisplayPriority: 6, DisplayCategory: displayStandard}, true +} + +// parseSyminsText parses a SYMINS TX()/TE() instruction into a DrawText. +// +// TX(string|attr, hjust, vjust, space, chars, xoffs, yoffs, colour, display) +// TE(format, attribs, hjust, vjust, space, chars, xoffs, yoffs, colour, display) +func parseSyminsText(op, params string, attrs map[string]any, anchor geo.LatLon) (DrawText, bool) { + args := splitSyminsArgs(params) + var text string + var hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx int + if op == "TE" { + if len(args) < 10 { + return DrawText{}, false + } + format := strings.Trim(args[0], "'\"") + var names []string + for _, a := range strings.Split(strings.Trim(args[1], "'\""), ",") { + if a = strings.TrimSpace(a); a != "" { + names = append(names, a) + } + } + t, ok := formatSubstitute(attrs, format, names) + if !ok { + return DrawText{}, false + } + text = t + hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx = 2, 3, 5, 6, 7, 8, 9 + } else { // TX + if len(args) < 9 { + return DrawText{}, false + } + rawStr := args[0] + if strings.HasPrefix(rawStr, "'") || strings.HasPrefix(rawStr, "\"") { + text = strings.Trim(rawStr, "'\"") // literal + } else { // attribute reference + v, ok := attrs[strings.TrimSpace(rawStr)] + if !ok || v == nil { + return DrawText{}, false + } + text = fmt.Sprintf("%v", v) + } + hjustIdx, vjustIdx, charsIdx, xoffIdx, yoffIdx, colorIdx, displayIdx = 1, 2, 4, 5, 6, 7, 8 + } + if text == "" { + return DrawText{}, false + } + color := strings.TrimSpace(argAt(args, colorIdx)) + if color == "" { + color = "CHBLK" + } + hjust, _ := strconv.Atoi(strings.TrimSpace(argAt(args, hjustIdx))) + vjust, _ := strconv.Atoi(strings.TrimSpace(argAt(args, vjustIdx))) + group, _ := strconv.Atoi(strings.TrimSpace(argAt(args, displayIdx))) + xoff, _ := strconv.Atoi(strings.TrimSpace(argAt(args, xoffIdx))) + yoff, _ := strconv.Atoi(strings.TrimSpace(argAt(args, yoffIdx))) + fontPx := syminsFontPx(strings.Trim(argAt(args, charsIdx), "'\"")) + var halo *TextHalo + if fontPx >= 10 { + halo = &TextHalo{ColorToken: "CHWHT", WidthPx: 1} + } + return DrawText{ + Anchor: anchor, Text: text, FontSizePx: fontPx, ColorToken: color, Halo: halo, + HAlign: syminsHAlign(hjust), VAlign: syminsVAlign(vjust), + // S-52 §8.3.3.2 XOFFS/YOFFS are in units of the text body size (+x right, +y down). + OffsetXPx: float32(xoff) * fontPx, OffsetYPx: float32(yoff) * fontPx, + Group: group, + }, true +} + +// syminsFontPx converts a SYMINS CHARS field (e.g. '15110' = style/weight/slant + +// two-digit body size) to a pixel font size. The body size is in points; one point +// is 0.351 mm, scaled to px at the app's reference pixel pitch (100·DefaultPxPerSymbolUnit +// px/mm). Falls back to the engine default (12 px) on a malformed field. +func syminsFontPx(chars string) float32 { + if len(chars) >= 5 { + if body, err := strconv.Atoi(chars[3:5]); err == nil && body > 0 { + return float32(body) * 0.351 * 100 * float32(DefaultPxPerSymbolUnit) + } + } + return 12 +} + +// syminsHAlign maps S-52 HJUST (1 centre, 2 right, 3 left) to HAlign. +func syminsHAlign(h int) HAlign { + switch h { + case 1: + return HAlignCenter + case 2: + return HAlignRight + default: + return HAlignLeft + } +} + +// syminsVAlign maps S-52 VJUST (1 bottom, 2 centre, 3 top) to VAlign. +func syminsVAlign(v int) VAlign { + switch v { + case 1: + return VAlignBottom + case 3: + return VAlignTop + default: + return VAlignMiddle + } +} + +func syminsDash(style string) Dash { + switch strings.ToUpper(style) { + case "DASH": + return DashDashed + case "DOTT": + return DashDotted + default: + return DashSolid + } +} + +// syminsLines returns the polyline(s) a line/area instruction (LS/LC) strokes: a +// line feature's polyline, or each ring of an area feature (closed). +func syminsLines(g geom) [][]geo.LatLon { + switch g.kind { + case geomLine: + if len(g.line) >= 2 { + return [][]geo.LatLon{g.line} + } + case geomArea: + var out [][]geo.LatLon + for _, r := range g.area { + if len(r) >= 2 { + if r[0] != r[len(r)-1] { + r = append(append([]geo.LatLon(nil), r...), r[0]) + } + out = append(out, r) + } + } + return out + } + return nil +} + +// splitSyminsInstructions splits a SYMINS string on ';', honouring quotes and +// nested parens (so a ';' inside TX('a;b',…) or between parens isn't a split). +func splitSyminsInstructions(s string) []string { + var out []string + var cur strings.Builder + depth, inQuote := 0, false + for i := 0; i < len(s); i++ { + switch c := s[i]; c { + case '\'', '"': + inQuote = !inQuote + cur.WriteByte(c) + case '(': + if !inQuote { + depth++ + } + cur.WriteByte(c) + case ')': + if !inQuote { + depth-- + } + cur.WriteByte(c) + case ';': + if !inQuote && depth == 0 { + out = append(out, cur.String()) + cur.Reset() + } else { + cur.WriteByte(c) + } + default: + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} + +// splitSyminsOp splits "OP(params)" into the op and the inner params. +func splitSyminsOp(instr string) (op, params string, ok bool) { + instr = strings.TrimSpace(instr) + open := strings.IndexByte(instr, '(') + closeI := strings.LastIndexByte(instr, ')') + if open <= 0 || closeI < open { + return "", "", false + } + return strings.TrimSpace(instr[:open]), instr[open+1 : closeI], true +} + +// splitSyminsArgs splits an instruction's params on ',', honouring single/double +// quotes (so a comma inside a quoted format/string stays in one arg). +func splitSyminsArgs(params string) []string { + var out []string + var cur strings.Builder + inQuote := false + for i := 0; i < len(params); i++ { + switch c := params[i]; c { + case '\'', '"': + inQuote = !inQuote + cur.WriteByte(c) + case ',': + if inQuote { + cur.WriteByte(c) + } else { + out = append(out, strings.TrimSpace(cur.String())) + cur.Reset() + } + default: + cur.WriteByte(c) + } + } + out = append(out, strings.TrimSpace(cur.String())) + return out +} + +func firstOr(args []string, def string) string { + if len(args) > 0 { + return args[0] + } + return def +} + +func argAt(args []string, i int) string { + if i >= 0 && i < len(args) { + return args[i] + } + return "" +} diff --git a/internal/engine/portrayal/symins_test.go b/internal/engine/portrayal/symins_test.go new file mode 100644 index 0000000..ead2158 --- /dev/null +++ b/internal/engine/portrayal/symins_test.go @@ -0,0 +1,98 @@ +package portrayal + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// TestSYMINSPointSymbol: a NEWOBJ point with SYMINS="SY(INFORM01)" portrays the +// named symbol (the producer's instruction), NOT the V-AIS alias. +func TestSYMINSPointSymbol(t *testing.T) { + f := s57.NewFeature(1, "NEWOBJ", + s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}}, + map[string]any{"SYMINS": "SY(INFORM01)"}, + ) + fb, ok := parseSYMINS(&f) + if !ok { + t.Fatal("parseSYMINS returned ok=false") + } + if len(fb.Primitives) != 1 { + t.Fatalf("want 1 primitive, got %d", len(fb.Primitives)) + } + sc, ok := fb.Primitives[0].(SymbolCall) + if !ok || sc.SymbolName != "INFORM01" { + t.Fatalf("want SymbolCall INFORM01, got %#v", fb.Primitives[0]) + } +} + +// TestSYMINSTextLabel: a TX literal label is parsed into a DrawText with the text, +// colour and text group (display field) from the instruction. +func TestSYMINSTextLabel(t *testing.T) { + f := s57.NewFeature(2, "NEWOBJ", + s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}}, + map[string]any{"SYMINS": "TX('Information about',3,2,2,'14108',0,0,CHBLK,11)"}, + ) + fb, ok := parseSYMINS(&f) + if !ok { + t.Fatal("ok=false") + } + dt, ok := fb.Primitives[0].(DrawText) + if !ok { + t.Fatalf("want DrawText, got %#v", fb.Primitives[0]) + } + if dt.Text != "Information about" { + t.Errorf("text = %q, want %q", dt.Text, "Information about") + } + if dt.ColorToken != "CHBLK" { + t.Errorf("colour = %q, want CHBLK", dt.ColorToken) + } + if dt.Group != 11 { + t.Errorf("text group = %d, want 11", dt.Group) + } + if dt.HAlign != HAlignLeft { // HJUST 3 = left + t.Errorf("HAlign = %v, want left", dt.HAlign) + } +} + +// TestSYMINSAreaBoundaryAndFill: an area NEWOBJ with a dashed boundary + colour +// fill emits a StrokeLine per ring and a FillPolygon. +func TestSYMINSAreaBoundaryAndFill(t *testing.T) { + ring := [][]float64{{-5.1, 15.1}, {-5.0, 15.1}, {-5.0, 15.2}, {-5.1, 15.2}, {-5.1, 15.1}} + f := s57.NewFeature(3, "NEWOBJ", + s57.Geometry{Type: s57.GeometryTypePolygon, Coordinates: ring}, + map[string]any{"SYMINS": "AC(CHMGF);LS(DASH,2,CHMGD)"}, + ) + fb, ok := parseSYMINS(&f) + if !ok { + t.Fatal("ok=false") + } + var hasFill, hasDashedStroke bool + for _, p := range fb.Primitives { + switch v := p.(type) { + case FillPolygon: + if v.ColorToken == "CHMGF" { + hasFill = true + } + case StrokeLine: + if v.ColorToken == "CHMGD" && v.Dash == DashDashed { + hasDashedStroke = true + } + } + } + if !hasFill || !hasDashedStroke { + t.Fatalf("want CHMGF fill + dashed CHMGD stroke, got %#v", fb.Primitives) + } +} + +// TestSYMINSEmptyFallsThrough: no SYMINS ⇒ ok=false so the caller uses the default +// new-object symbology. +func TestSYMINSEmptyFallsThrough(t *testing.T) { + f := s57.NewFeature(4, "NEWOBJ", + s57.Geometry{Type: s57.GeometryTypePoint, Coordinates: [][]float64{{-5.1, 15.1}}}, + map[string]any{}, + ) + if _, ok := parseSYMINS(&f); ok { + t.Fatal("want ok=false for a feature with no SYMINS") + } +} diff --git a/internal/engine/server/cellindex.go b/internal/engine/server/cellindex.go new file mode 100644 index 0000000..2237c75 --- /dev/null +++ b/internal/engine/server/cellindex.go @@ -0,0 +1,158 @@ +package server + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/beetlebugorg/chartplotter/internal/engine/baker" +) + +// cellIndex is a small, persistent name→bounding-box index over the cached source +// cells (/ENC_ROOT//.000). It lets the server answer "where +// is cell X" and "which installed cells are active" without re-parsing thousands +// of cells on every request: each cell's header is read ONCE (the bbox cached to +// /cells-index.json), then queries hit the in-memory map. Kept +// deliberately simple — a flat JSON map, not a database; the data is tiny (a few +// floats per cell) and read-mostly. +type cellIndex struct { + mu sync.RWMutex + bbox map[string][4]float64 // cell stem → [W,S,E,N] + path string // cells-index.json + encRoot string // /ENC_ROOT + built bool // backfill scan finished +} + +func newCellIndex(dataDir string) *cellIndex { + ci := &cellIndex{ + bbox: map[string][4]float64{}, + path: filepath.Join(dataDir, "cells-index.json"), + encRoot: filepath.Join(dataDir, "ENC_ROOT"), + } + if data, err := os.ReadFile(ci.path); err == nil { + _ = json.Unmarshal(data, &ci.bbox) + } + return ci +} + +// get returns a cell's [W,S,E,N] bounds if indexed. +func (ci *cellIndex) get(name string) ([4]float64, bool) { + ci.mu.RLock() + defer ci.mu.RUnlock() + b, ok := ci.bbox[name] + return b, ok +} + +// snapshot returns a copy of the current index (sorted names + their bboxes). +func (ci *cellIndex) snapshot() ([]string, map[string][4]float64) { + ci.mu.RLock() + defer ci.mu.RUnlock() + names := make([]string, 0, len(ci.bbox)) + out := make(map[string][4]float64, len(ci.bbox)) + for n, b := range ci.bbox { + names = append(names, n) + out[n] = b + } + sort.Strings(names) + return names, out +} + +func (ci *cellIndex) save() { + ci.mu.RLock() + data, err := json.Marshal(ci.bbox) + ci.mu.RUnlock() + if err != nil { + return + } + tmp := ci.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return + } + _ = os.Rename(tmp, ci.path) +} + +// rebuild re-opens the backfill (e.g. after an import added new cached cells) and +// indexes any not already present. Run in a goroutine. +func (ci *cellIndex) rebuild() { + ci.mu.Lock() + ci.built = false + ci.mu.Unlock() + ci.build() +} + +// build backfills the index by reading every cached cell's header once. Runs in a +// background goroutine (started once) so it never blocks a request; queries see +// the index grow as it fills, and it's a no-op after the first complete pass. +func (ci *cellIndex) build() { + ci.mu.Lock() + if ci.built { + ci.mu.Unlock() + return + } + ci.built = true // claim the build; reset only if the scan can't start + ci.mu.Unlock() + + entries, err := os.ReadDir(ci.encRoot) + if err != nil { + ci.mu.Lock() + ci.built = false + ci.mu.Unlock() + return + } + present := make(map[string]bool, len(entries)) + added := 0 + for _, e := range entries { + if !e.IsDir() || !isCellName(e.Name()) { + continue + } + name := e.Name() + present[name] = true + if _, ok := ci.get(name); ok { + continue // already indexed (forget() drops a re-imported cell so it re-parses) + } + data, err := os.ReadFile(filepath.Join(ci.encRoot, name, name+".000")) + if err != nil { + continue + } + chart, err := baker.ParseCellBytes(name, data) + if err != nil { + continue + } + b := chart.Bounds() + ci.mu.Lock() + ci.bbox[name] = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat} + ci.mu.Unlock() + added++ + if added%200 == 0 { + ci.save() // periodic checkpoint for a long backfill + } + } + // Reconcile: drop entries for cells no longer on disk (removed packs/cells), so + // the index never reports a chart that isn't installed anymore. + removed := 0 + ci.mu.Lock() + for name := range ci.bbox { + if !present[name] { + delete(ci.bbox, name) + removed++ + } + } + ci.mu.Unlock() + if added > 0 || removed > 0 { + ci.save() + log.Printf("cell index: +%d / -%d cell bound(s) → %s", added, removed, ci.path) + } +} + +// forget drops cells from the index so the next build re-parses them — used when +// an import re-caches a cell whose bounds may have changed. +func (ci *cellIndex) forget(names []string) { + ci.mu.Lock() + for _, n := range names { + delete(ci.bbox, n) + } + ci.mu.Unlock() +} diff --git a/internal/engine/server/cellindex_test.go b/internal/engine/server/cellindex_test.go new file mode 100644 index 0000000..38e98c4 --- /dev/null +++ b/internal/engine/server/cellindex_test.go @@ -0,0 +1,95 @@ +package server + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBBoxOverlapsAny — the ?active overlap test. +func TestBBoxOverlapsAny(t *testing.T) { + world := [4]float64{-180, -90, 180, 90} + cell := [4]float64{-5.13, 15.0, -5.0, 15.13} + if !bboxOverlapsAny(cell, [][4]float64{world}) { + t.Error("cell should overlap the world pack") + } + far := [4]float64{100, -40, 120, -20} + if bboxOverlapsAny(cell, [][4]float64{far}) { + t.Error("cell should NOT overlap a disjoint pack") + } + if bboxOverlapsAny(cell, nil) { + t.Error("no packs ⇒ not active") + } +} + +// TestCellIndexBuild — backfill reads a cached cell's header once and records its +// bounds; a reload from disk sees the same. +func TestCellIndexBuild(t *testing.T) { + const cell = "US4MD81M" + data, err := os.ReadFile("../../../testdata/" + cell + ".000") + if err != nil { + t.Skipf("testdata cell absent: %v", err) + } + dir := t.TempDir() + cdir := filepath.Join(dir, "ENC_ROOT", cell) + if err := os.MkdirAll(cdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cdir, cell+".000"), data, 0o644); err != nil { + t.Fatal(err) + } + + ci := newCellIndex(dir) + ci.build() + bb, ok := ci.get(cell) + if !ok { + t.Fatal("cell not indexed after build") + } + if !(bb[0] < bb[2] && bb[1] < bb[3]) { + t.Errorf("degenerate bounds %v", bb) + } + // Persisted: a fresh index loads the same bounds without re-parsing. + if bb2, ok := newCellIndex(dir).get(cell); !ok || bb2 != bb { + t.Errorf("reload mismatch: %v vs %v (ok=%v)", bb2, bb, ok) + } +} + +// TestCellIndexFreshness: rebuild prunes a removed cell, and forget() drops one so +// it re-indexes — the add/update/remove freshness contract. +func TestCellIndexFreshness(t *testing.T) { + const cell = "US4MD81M" + data, err := os.ReadFile("../../../testdata/" + cell + ".000") + if err != nil { + t.Skipf("testdata cell absent: %v", err) + } + dir := t.TempDir() + cdir := filepath.Join(dir, "ENC_ROOT", cell) + if err := os.MkdirAll(cdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cdir, cell+".000"), data, 0o644); err != nil { + t.Fatal(err) + } + ci := newCellIndex(dir) + ci.build() + if _, ok := ci.get(cell); !ok { + t.Fatal("not indexed") + } + // forget → re-build re-parses it (update path). + ci.forget([]string{cell}) + if _, ok := ci.get(cell); ok { + t.Fatal("forget did not drop the entry") + } + ci.rebuild() + if _, ok := ci.get(cell); !ok { + t.Fatal("rebuild did not re-index after forget") + } + // remove the cell on disk → rebuild prunes it (remove path). + if err := os.RemoveAll(cdir); err != nil { + t.Fatal(err) + } + ci.rebuild() + if _, ok := ci.get(cell); ok { + t.Error("rebuild did not prune a removed cell") + } +} diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go index 641c2e8..04336d1 100644 --- a/internal/engine/server/http.go +++ b/internal/engine/server/http.go @@ -45,6 +45,7 @@ type Server struct { packs map[string]string // ALL baked packs on disk: set name → pmtiles path prefs *prefs // persisted enable/disable state (/prefs.json) auxIdx *auxIndex // index of companion aux.zips for /api/aux (TXTDSC/PICREP) + cellIdx *cellIndex // persistent name→bbox index over cached cells (/api/cells, search fly-to) vessel *nmea.Store // latest NMEA0183 vessel state (fed by nmeaMgr) nmeaMgr *nmea.Manager // live NMEA0183 connections (writes into vessel) @@ -63,7 +64,8 @@ func New(assetsDir, cacheDir, dataDir string, allowRemote bool) *Server { if dataDir == "" { dataDir = cacheDir } - s := &Server{assetsDir: assetsDir, cacheDir: cacheDir, dataDir: dataDir, allowRemote: allowRemote, sets: newTileSets(), imports: newImportJobs(), auxIdx: newAuxIndex()} + s := &Server{assetsDir: assetsDir, cacheDir: cacheDir, dataDir: dataDir, allowRemote: allowRemote, sets: newTileSets(), imports: newImportJobs(), auxIdx: newAuxIndex(), cellIdx: newCellIndex(dataDir)} + go s.cellIdx.build() // backfill cell bounds once, in the background (never blocks a request) // Discover every baked pack on disk (provider trees + flat tiles/), then // register the ENABLED ones (disabled packs stay on disk but off the map). State // lives in /prefs.json so it survives restarts and is shared across clients. diff --git a/internal/engine/server/import.go b/internal/engine/server/import.go index 54d6925..39334cf 100644 --- a/internal/engine/server/import.go +++ b/internal/engine/server/import.go @@ -329,6 +329,14 @@ func (s *Server) cacheCells(cells map[string]baker.CellData) { _ = os.WriteFile(filepath.Join(dir, filepath.Base(un)), ub, 0o644) } } + if s.cellIdx != nil { + stems := make([]string, 0, len(cells)) + for name := range cells { + stems = append(stems, strings.TrimSuffix(name, ".000")) + } + s.cellIdx.forget(stems) // re-imported cells: drop stale bounds so the rebuild re-parses + go s.cellIdx.rebuild() // index the (re-)cached cells' bounds in the background + } } // filterCells keeps only the cells whose stem (name sans .000) is in names. diff --git a/internal/engine/server/tilesets.go b/internal/engine/server/tilesets.go index 59c127a..0256cd1 100644 --- a/internal/engine/server/tilesets.go +++ b/internal/engine/server/tilesets.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "fmt" "io" "math" @@ -289,22 +290,68 @@ func (s *Server) handleSetEnabled(w http.ResponseWriter, r *http.Request) { // serveCells returns the names of cells currently in the server's ENC_ROOT source // store. The client uses this so its installed-set (and the persisted baked sets) // survive a page reload — the cells live server-side in the XDG data dir. +// serveCells returns the installed source cells. The "cells" array is every +// cached cell name (back-compat: the installed list). "bbox" maps each INDEXED +// cell to its [W,S,E,N] footprint (fills in as the background index backfills), +// so the client can search a cell by name and fly to it. With ?active=1 the +// result is restricted to cells whose footprint overlaps an ENABLED pack — i.e. +// charts actually on the map right now (and only those that are indexed, since an +// un-indexed cell has no footprint to test or fly to). func (s *Server) serveCells(w http.ResponseWriter, r *http.Request) { + active := r.URL.Query().Get("active") == "1" + var enabled [][4]float64 + if active { + enabled = s.enabledPackBounds() + } + _, idx := s.cellIdx.snapshot() entries, _ := os.ReadDir(filepath.Join(s.dataDir, "ENC_ROOT")) names := make([]string, 0, len(entries)) + boxes := make(map[string][4]float64) for _, e := range entries { - if e.IsDir() && isCellName(e.Name()) { - names = append(names, e.Name()) + if !e.IsDir() || !isCellName(e.Name()) { + continue + } + n := e.Name() + box, has := idx[n] + if active && (!has || !bboxOverlapsAny(box, enabled)) { + continue + } + names = append(names, n) + if has { + boxes[n] = box } } sort.Strings(names) w.Header().Set("Content-Type", jsonCT) - fmt.Fprint(w, `{"cells":[`) - for i, n := range names { - if i > 0 { - fmt.Fprint(w, ",") + _ = json.NewEncoder(w).Encode(struct { + Cells []string `json:"cells"` + BBox map[string][4]float64 `json:"bbox"` + }{names, boxes}) +} + +// enabledPackBounds is each enabled pack's [W,S,E,N] (read from its archive), +// used by the ?active filter to test which cells are currently on the map. +func (s *Server) enabledPackBounds() [][4]float64 { + var out [][4]float64 + for _, name := range sortedKeys(s.packs) { + if s.prefs.isDisabled(name) { + continue + } + if src, err := tilesource.Open(s.packs[name]); err == nil { + m := src.Meta() + _ = tilesource.Close(src) + out = append(out, [4]float64{m.W, m.S, m.E, m.N}) } - fmt.Fprintf(w, "%q", n) } - fmt.Fprint(w, "]}") + return out +} + +// bboxOverlapsAny reports whether [W,S,E,N] box intersects any of the rects. +func bboxOverlapsAny(b [4]float64, rects [][4]float64) bool { + for _, r := range rects { + if b[0] <= r[2] && b[2] >= r[0] && b[1] <= r[3] && b[3] >= r[1] { + return true + } + } + return false } diff --git a/pkg/s100/instructions/augray_crs_test.go b/pkg/s100/instructions/augray_crs_test.go new file mode 100644 index 0000000..22225e8 --- /dev/null +++ b/pkg/s100/instructions/augray_crs_test.go @@ -0,0 +1,32 @@ +package instructions + +import "testing" + +// TestAugmentedRayLengthCRS: the AugmentedRay length's CRS decides its unit. +// LocalCRS ⇒ display millimetres (the 25 mm short sector leg); GeographicCRS ⇒ +// a fixed ground distance in metres (a sectorLineLength / full-VALNMR leg). +// Conflating them rendered geographic legs at metres-as-mm — ~10× too long. +func TestAugmentedRayLengthCRS(t *testing.T) { + cases := []struct { + in string + wantMM, wantGndM float64 + }{ + {"AugmentedRay:GeographicCRS,123.4,GeographicCRS,185.2;LineInstruction:_simple_", 0, 185.2}, + {"AugmentedRay:GeographicCRS,123.4,LocalCRS,25;LineInstruction:_simple_", 25, 0}, + } + for _, c := range cases { + cmds, _ := Reduce(ParseStream(c.in)) + var got *AugmentedGeom + for i := range cmds { + if cmds[i].Augmented != nil { + got = cmds[i].Augmented + } + } + if got == nil { + t.Fatalf("%s: no augmented geom", c.in) + } + if got.LengthMM != c.wantMM || got.LengthGroundM != c.wantGndM { + t.Errorf("%s: LengthMM=%v LengthGroundM=%v, want %v / %v", c.in, got.LengthMM, got.LengthGroundM, c.wantMM, c.wantGndM) + } + } +} diff --git a/pkg/s100/instructions/instructions.go b/pkg/s100/instructions/instructions.go index 00fe20c..49932ec 100644 --- a/pkg/s100/instructions/instructions.go +++ b/pkg/s100/instructions/instructions.go @@ -81,6 +81,10 @@ type AugmentedGeom struct { // +180 reversal) of length LengthMM. BearingDeg float64 LengthMM float64 + // LengthGroundM is the leg length when the rule gave it in GeographicCRS — a + // fixed GROUND distance in metres (a sectorLineLength or full-VALNMR leg), + // rendered zoom-dependently. Mutually exclusive with LengthMM (display mm). + LengthGroundM float64 // Arc ("ArcByRadius:,,,,"): centred on the // anchor, RadiusMM, from StartDeg sweeping SweepDeg degrees clockwise. A full // 360° sweep is an all-round ring. @@ -226,9 +230,19 @@ func Reduce(ins []Instruction) (cmds []DrawCommand, unsupported []string) { curAug = nil // --- geometry construction (screen-space figures the rule builds) --- case "AugmentedRay": - // "AugmentedRay:,,," — a leg from the - // anchor. The rule emits the bearing already from-seaward-reversed. - curAug = &AugmentedGeom{Kind: AugRay, BearingDeg: atof(arg(in, 1)), LengthMM: atof(arg(in, 3))} + // "AugmentedRay:,,," — a leg from the + // anchor. The rule emits the bearing already from-seaward-reversed. The + // LENGTH's CRS (arg 2) decides its unit: LocalCRS ⇒ display mm (the 25 mm + // short sector leg); GeographicCRS ⇒ ground metres (a sectorLineLength / + // full-VALNMR leg — a fixed ground distance, NOT mm). Conflating the two + // rendered geographic legs at metres-as-mm, ~10× too long ("shooting out"). + ar := &AugmentedGeom{Kind: AugRay, BearingDeg: atof(arg(in, 1))} + if arg(in, 2) == "GeographicCRS" { + ar.LengthGroundM = atof(arg(in, 3)) + } else { + ar.LengthMM = atof(arg(in, 3)) + } + curAug = ar case "ArcByRadius": // "ArcByRadius:,,,," — an arc/ring // centred on the anchor (the cx,cy offset is 0 for sector figures). diff --git a/scripts/fetch-preslib-cells.sh b/scripts/fetch-preslib-cells.sh new file mode 100755 index 0000000..346e983 --- /dev/null +++ b/scripts/fetch-preslib-cells.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Fetch the S-52 PresLib "ECDIS Chart 1" ENC cells for the live docs demo. The +# source is the IHO PresLib e4.0.0 digital-files draft zip; we extract just the +# ENC cells (*.000 + any update files) into $OUT/cells so `make demo-chart1` can +# bake them. Mirrors scripts/fetch-demo-cells.sh. +# +# Idempotent: if $OUT/cells already holds a *.000 it does nothing, so CI can cache +# the directory across runs. Prefers a local copy of the zip (the untracked +# testdata download) so local builds don't re-download. Override via env: +# PRESLIB_CACHE output dir (default ./.preslib-cache) +# PRESLIB_URL download URL (default the IHO legacy host) +# PRESLIB_ZIP path to a local zip to use instead of downloading +set -euo pipefail + +OUT="${PRESLIB_CACHE:-.preslib-cache}" +URL="${PRESLIB_URL:-https://legacy.iho.int/iho_pubs/draft_pubs/PresLib_e4.0.0/Digital_Files/S-52_PresLib_e4.0.0_Digital_Files_Draft.zip}" +LOCAL="${PRESLIB_ZIP:-testdata/S-52_PresLib_e4.0.0_Digital_Files_Draft.zip}" + +mkdir -p "$OUT/cells" +if compgen -G "$OUT/cells/*.000" >/dev/null; then + echo "cached PresLib Chart 1 cells in $OUT/cells" + ls -1 "$OUT/cells" + exit 0 +fi + +ZIP="$OUT/preslib.zip" +if [ -s "$LOCAL" ]; then + echo "local $LOCAL" + ZIP="$LOCAL" +elif [ ! -s "$ZIP" ]; then + echo "fetch $URL" + curl -fSL --retry 3 -o "$ZIP" "$URL" +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +echo "extract Chart 1 ENC cells" +unzip -qo "$ZIP" -d "$TMP" +ENC_ROOT="$(dirname "$(find "$TMP" -name '*.000' | head -1)")" +[ -n "$ENC_ROOT" ] || { echo "no *.000 cells in $ZIP" >&2; exit 1; } +# Copy the cell base files and any ENC update files (*.001, *.002, …) flat. +find "$ENC_ROOT" -maxdepth 1 -type f \( -name '*.000' -o -regex '.*\.[0-9][0-9][0-9]$' \) \ + -exec cp {} "$OUT/cells/" \; + +echo "PresLib Chart 1 cells ready in $OUT/cells:" +ls -1 "$OUT/cells" diff --git a/scripts/preslib-chart1.mjs b/scripts/preslib-chart1.mjs new file mode 100644 index 0000000..763cbf6 --- /dev/null +++ b/scripts/preslib-chart1.mjs @@ -0,0 +1,106 @@ +// Render every panel of the S-52 PresLib "ECDIS Chart 1" (PresLib e4.0.0 Part I +// §16, document pages 238–253) with OUR implementation, one PNG per reference +// page, for diffing against the spec's reference plots. +// +// Each of the 14 ECDIS-Chart-1 cells IS one reference panel (its name encodes the +// panel letters, e.g. AA5C1CDE → "C,D,E"). The PANELS table maps each reference +// page to its cell BOUNDS + compilation scale. We render in "spec mode" (?spec — +// no app chrome) and size the window so the cell fills it AT ITS COMPILATION SCALE +// (1:14 000 for the harbor pages, 1:60 000 for the overview): viewport_px = +// ground_metres / scale / pixel_pitch. So each page is captured full-screen at the +// scale the legend was drawn for, exactly like the reference figure. +// +// Usage: node scripts/preslib-chart1.mjs [settleMs] +import { createRequire } from "node:module"; +import { execSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +const require = createRequire(import.meta.url); +function findPlaywright() { + try { return require("playwright-core"); } catch {} + const root = execSync("npm root -g", { encoding: "utf8" }).trim(); + return require(`${root}/promptfoo/node_modules/playwright-core`); +} +function findChromium() { + for (const p of ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/chrome"]) { + try { execSync(`test -x ${p}`); return p; } catch {} + } + return undefined; +} + +const [baseURL = "http://127.0.0.1:8101", outDir = "/tmp/preslib-chart1", settle = "8000"] = process.argv.slice(2); + +// Display geometry, shared with the app (web/src/lib/util.mjs): the 512-tile +// metres-per-pixel at z0 and the 1/96-inch CSS reference pixel. +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)); +const spanPx = (metres, scale) => Math.max(1, Math.round(metres / scale / PX_PITCH_M)); + +// page → cell: bounds [W,S,E,N] + compilation scale (CSCL). Bounds are the cells' +// data extents (AA4C1XMS = the 1:60 000 overview; AA5C1* = 1:14 000 harbor pages). +const HARBOR = 14000, OVERVIEW = 60000; +const PANELS = [ + { page: 238, slug: "overview", b: [-5.135803, 15.00018, -4.997983, 15.133311], cscl: OVERVIEW, scheme: "day" }, + { 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" }, + { 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" }, +]; + +// Mariner display state matching the IHO PresLib reference plots: ALL symbology +// shown. Display category Other on (INFORM01 callouts, "other marks", magnetic +// variation…); data-quality overlay on (the CATZOC "quality of data" panels); +// metres (IHO depths, not NOAA feet); 25 mm short sector legs and symbolized +// boundaries (both the S-52 defaults the reference uses). Everything else +// (soundings, text groups, light descriptions) is default-on. +const MARINER = { + displayBase: true, displayStandard: true, displayOther: true, + dataQuality: true, + depthUnit: "m", + showFullSectorLines: false, + boundaryStyle: "symbolized", + simplifiedPoints: false, +}; + +mkdirSync(outDir, { recursive: true }); +const { chromium } = findPlaywright(); +const browser = await chromium.launch({ executablePath: findChromium(), args: ["--no-sandbox", "--hide-scrollbars"] }); + +for (const p of PANELS) { + const [w, s, e, n] = p.b; + const lat = (s + n) / 2; + const center = [(w + e) / 2, lat]; + const zoom = zoomForScale(p.cscl, lat); + // Window sized to the cell at its compilation scale → the page fills it. + const lonM = (e - w) * 111320 * Math.cos((lat * Math.PI) / 180); + const latM = (n - s) * 110574; + const width = spanPx(lonM, p.cscl), height = spanPx(latM, p.cscl); + + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + page.on("pageerror", (err) => console.error(`[page ${p.page}]`, err.message)); + await page.addInitScript((a) => { + localStorage.setItem("chartplotter:scheme", a.scheme); + localStorage.setItem("chartplotter:basemap", "coastline"); + localStorage.setItem("chartplotter:enc-agreement", "1"); + localStorage.setItem("chartplotter:mariner", JSON.stringify(a.mariner)); + localStorage.setItem("chartplotter:view", JSON.stringify({ center: a.center, zoom: a.zoom })); + }, { scheme: p.scheme, center, zoom, mariner: MARINER }); + try { await page.goto(baseURL + "/?prod&spec", { waitUntil: "domcontentloaded", timeout: 45000 }); } + catch (err) { console.error(`[page ${p.page}] nav: ${err.message} — continuing`); } + await page.waitForTimeout(+settle); + const out = `${outDir}/page-${p.page}-${p.slug}.png`; + await page.screenshot({ path: out }); + console.log(`wrote ${out} (${width}x${height} @ 1:${p.cscl})`); + await page.close(); +} +await browser.close(); diff --git a/scripts/preslib-chart1.sh b/scripts/preslib-chart1.sh new file mode 100755 index 0000000..eb16a7f --- /dev/null +++ b/scripts/preslib-chart1.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Render every panel of the S-52 PresLib "ECDIS Chart 1" with our implementation, +# one PNG per reference-plot page (PresLib e4.0.0 Part I §16, doc pages 238–253), +# for diffing against the spec. Self-contained: extracts the cells, bakes+serves +# them through the normal server-side import path, drives scripts/preslib-chart1.mjs, +# then tears the server down. Re-runnable as-is. +# +# scripts/preslib-chart1.sh [OUT_DIR] +# +# OUT_DIR defaults to testdata/preslib-chart1-out/ (gitignored). Requires the +# PresLib zip in testdata/ (an untracked IHO download) and a headless Chromium. +set -euo pipefail +cd "$(dirname "$0")/.." + +ZIP="testdata/S-52_PresLib_e4.0.0_Digital_Files_Draft.zip" +OUT="${1:-testdata/preslib-chart1-out}" +PORT="${PORT:-8123}" +BIN="bin/chartplotter" + +if [[ ! -f "$ZIP" ]]; then + echo "missing $ZIP — download the S-52 PresLib e4.0.0 digital files into testdata/" >&2 + exit 1 +fi + +echo "==> building $BIN" +make build >/dev/null + +WORK="$(mktemp -d)" +SRV_PID="" +cleanup() { + [[ -n "$SRV_PID" ]] && kill "$SRV_PID" 2>/dev/null || true + rm -rf "$WORK" +} +trap cleanup EXIT + +echo "==> extracting Chart-1 cells" +unzip -qo "$ZIP" -d "$WORK" +ENC_ROOT="$(dirname "$(find "$WORK" -name '*.000' | head -1)")" +( cd "$ENC_ROOT/.." && zip -qr "$WORK/chart1.zip" "$(basename "$ENC_ROOT")" ) + +echo "==> serving on :$PORT (temp cache/data)" +"$BIN" serve --assets web/ --cache "$WORK/cache" --data "$WORK/data" --port "$PORT" >"$WORK/serve.log" 2>&1 & +SRV_PID=$! +for _ in $(seq 1 30); do curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 0.5; done + +echo "==> importing (server-side bake)" +JOB="$(curl -fsS -X POST "http://127.0.0.1:$PORT/api/import?set=preslib" \ + --data-binary @"$WORK/chart1.zip" -H 'Content-Type: application/zip' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["job"])')" +for _ in $(seq 1 60); do + STATE="$(curl -fsS "http://127.0.0.1:$PORT/api/import/status?job=$JOB" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("state",""))' 2>/dev/null || true)" + [[ "$STATE" == "done" ]] && break + [[ "$STATE" == "error" ]] && { echo "import failed"; cat "$WORK/serve.log"; exit 1; } + sleep 1 +done + +echo "==> rendering panels → $OUT" +node scripts/preslib-chart1.mjs "http://127.0.0.1:$PORT" "$OUT" + +echo "==> done: $(ls "$OUT" | wc -l) PNG(s) in $OUT" diff --git a/scripts/s64-pages.mjs b/scripts/s64-pages.mjs new file mode 100644 index 0000000..08ec430 --- /dev/null +++ b/scripts/s64-pages.mjs @@ -0,0 +1,106 @@ +// Render the IHO S-64 ENC test dataset's rendering pages with OUR implementation, +// one PNG per test, for diffing against the S-64 reference plots (testdata/"S-64 +// Ed 3.0.3_EN_Clean_Final.pdf"). Mirrors scripts/preslib-chart1.mjs: spec mode +// (chrome-free), each page framed to its cell at the cell's compilation scale. +// +// Unlike PresLib Chart 1 (one all-symbology plot), S-64 tests vary the MARINER +// settings per test — most importantly §3.1 ENC Display renders the SAME area at +// the Base / Standard / Other display categories. The S-64 setup uses safety +// contour 10 m / safety depth 10 m, symbolized boundaries, metres. +// +// Usage: node scripts/s64-pages.mjs [settleMs] +import { createRequire } from "node:module"; +import { execSync } from "node:child_process"; +import { mkdirSync } from "node:fs"; +const require = createRequire(import.meta.url); +function findPlaywright() { + try { return require("playwright-core"); } catch {} + const root = execSync("npm root -g", { encoding: "utf8" }).trim(); + return require(`${root}/promptfoo/node_modules/playwright-core`); +} +function findChromium() { + for (const p of ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/usr/bin/chrome"]) { + try { execSync(`test -x ${p}`); return p; } catch {} + } + return undefined; +} + +const [baseURL = "http://127.0.0.1:8101", outDir = "/tmp/s64-pages", settle = "8000"] = process.argv.slice(2); + +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)); +const spanPx = (metres, scale) => Math.max(1, Math.round(metres / scale / PX_PITCH_M)); + +// S-64 standard mariner setup: safety contour/depth 10 m, symbolized boundaries, +// metres, all categories on (per-page overrides below). Mirrors DEFAULT_MARINER keys. +const S64 = { + 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 merge = (o) => ({ ...S64, ...o }); + +// One page per row: the S-64 test section (the reference figure to diff against), +// a slug, the cell's bounds [W,S,E,N] + compilation scale (from parsing the cells), +// the colour scheme, and the mariner overrides for that test. +const HARBOR = 25000; +const 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, mariner: merge({ displayStandard: false, displayOther: false, dataQuality: false }) }, + { section: "3.1 Standard", slug: "AA5STNDR", b: [10.0, 10.0, 10.167, 10.167], cscl: 70000, mariner: merge({ displayOther: false, dataQuality: false }) }, + { section: "3.1 Other", slug: "AA5OTHER", b: [10.167, 10.0, 10.333, 10.167], cscl: 60000, mariner: merge({}) }, + // §3.6 Display Priorities (overlapping object draw order). + { section: "3.6 DisplayPriorities", slug: "2J5X0001", b: [61.333, -32.375, 61.4, -32.333], cscl: HARBOR, mariner: merge({}) }, + // §3.7 Overlap / §3.7.7 Scale minimum / §3.3 Settings / §3.2 Invalid object. + { section: "3.7 Overlap", slug: "GB3OVRLP", b: [60.6, -32.5, 61.1, -32.2], cscl: 90000, mariner: merge({}) }, + { section: "3.7.7 ScaleMin", slug: "AA3SCAMN", b: [60.267, -32.633, 60.767, -32.317], cscl: 90000, mariner: merge({}) }, + { section: "3.3 Settings", slug: "GB4X0001", b: [61.333, -32.633, 61.5, -32.317], cscl: 52000, mariner: merge({}) }, + { section: "3.2 InvalidObject", slug: "AA3INVOB", b: [-104.75, 39.333, -104.5, 39.5], cscl: 50000, mariner: merge({}) }, + // §3.4 Non-official data (new producer codes). + { section: "3.4 NonOfficial", slug: "1B5X01NE", b: [60.967, -32.533, 61.0, -32.45], cscl: HARBOR, mariner: merge({}) }, + { section: "3.4 NewProducer", slug: "IC3NEWPC", b: [60.0, -30.5, 60.1, -30.4], cscl: 90000, mariner: merge({}) }, + // §2.1.1 Power Up — the GB region overview (band-4 cell covering the GB5X tiles). + { section: "2.1.1 PowerUp", slug: "GB4X0000", b: [60.767, -32.633, 61.333, -32.317], cscl: 52000, mariner: merge({}) }, + // §5/6/7 detection tests (Colorado): nav hazards, special conditions, safety contour. + { section: "5.0 NavHazards", slug: "AA3NAVHZ", b: [-105.0, 39.833, -104.75, 40.0], cscl: 75000, mariner: merge({}) }, + { section: "5.0 Overview", slug: "AA2OVRVU", b: [-105.5, 39.167, -104.167, 40.167], cscl: 350000, mariner: merge({}) }, + { section: "6.0 SpecialConditions", slug: "AA3ARSPC", b: [-105.0, 39.667, -104.75, 39.833], cscl: 90000, mariner: merge({}) }, + { section: "7.0 SafetyContour", slug: "AA3SAFCO", b: [-105.0, 39.5, -104.75, 39.667], cscl: 90000, mariner: merge({}) }, +]; +// NOTE: §3.9 Polar (AA1NPOL*) is omitted — those cells sit at 85–90°N, beyond the +// Web-Mercator limit, so they can't be displayed. §3.8.5 AML non-ENC cells are +// omitted — their long underscored names aren't ENC cell names (not baked). + +mkdirSync(outDir, { recursive: true }); +const { chromium } = findPlaywright(); +const browser = await chromium.launch({ executablePath: findChromium(), args: ["--no-sandbox", "--hide-scrollbars"] }); + +for (const p of PAGES) { + const [w, s, e, n] = p.b; + const lat = (s + n) / 2; + const center = [(w + e) / 2, lat]; + const zoom = zoomForScale(p.cscl, lat); + const lonM = (e - w) * 111320 * Math.cos((lat * Math.PI) / 180); + const latM = (n - s) * 110574; + const width = spanPx(lonM, p.cscl), height = spanPx(latM, p.cscl); + + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + page.on("pageerror", (err) => console.error(`[${p.section}]`, err.message)); + await page.addInitScript((a) => { + localStorage.setItem("chartplotter:scheme", "day"); + localStorage.setItem("chartplotter:basemap", "coastline"); + localStorage.setItem("chartplotter:enc-agreement", "1"); + localStorage.setItem("chartplotter:mariner", JSON.stringify(a.mariner)); + localStorage.setItem("chartplotter:view", JSON.stringify({ center: a.center, zoom: a.zoom })); + }, { mariner: p.mariner, center, zoom }); + try { await page.goto(baseURL + "/?prod&spec", { waitUntil: "domcontentloaded", timeout: 45000 }); } + catch (err) { console.error(`[${p.section}] nav: ${err.message} — continuing`); } + await page.waitForTimeout(+settle); + const file = `${outDir}/${p.section.replace(/[ .]/g, "_")}-${p.slug}.png`; + await page.screenshot({ path: file }); + console.log(`wrote ${file} (${width}x${height} @ 1:${p.cscl})`); + await page.close(); +} +await browser.close(); diff --git a/scripts/s64-pages.sh b/scripts/s64-pages.sh new file mode 100755 index 0000000..3763732 --- /dev/null +++ b/scripts/s64-pages.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Render the IHO S-64 ENC test dataset's rendering pages with our implementation, +# one PNG per test, for diffing against the S-64 reference plots (testdata/"S-64 +# Ed 3.0.3_EN_Clean_Final.pdf"). Self-contained: extracts the cells, bakes+serves +# them through the normal server-side import path, drives scripts/s64-pages.mjs, +# then tears the server down. Re-runnable. Sibling of scripts/preslib-chart1.sh. +# +# scripts/s64-pages.sh [OUT_DIR] +# +# OUT_DIR defaults to testdata/s64-pages-out/ (gitignored). Requires the S-64 zip +# in testdata/ (an untracked IHO download) and a headless Chromium. +set -euo pipefail +cd "$(dirname "$0")/.." + +ZIP="testdata/S-64_ENC_Unencrypted_TDS.zip" +OUT="${1:-testdata/s64-pages-out}" +PORT="${PORT:-8124}" +BIN="bin/chartplotter" + +[[ -f "$ZIP" ]] || { echo "missing $ZIP — download the S-64 ENC unencrypted TDS into testdata/" >&2; exit 1; } + +echo "==> building $BIN" +make build >/dev/null + +WORK="$(mktemp -d)" +SRV_PID="" +cleanup() { [[ -n "$SRV_PID" ]] && kill "$SRV_PID" 2>/dev/null || true; rm -rf "$WORK"; } +trap cleanup EXIT + +echo "==> serving on :$PORT (temp cache/data)" +"$BIN" serve --assets web/ --cache "$WORK/cache" --data "$WORK/data" --port "$PORT" >"$WORK/serve.log" 2>&1 & +SRV_PID=$! +for _ in $(seq 1 30); do curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1 && break; sleep 0.5; done + +echo "==> importing S-64 (server-side bake of all cells across the test sections)" +JOB="$(curl -fsS -X POST "http://127.0.0.1:$PORT/api/import?set=s64" \ + --data-binary @"$ZIP" -H 'Content-Type: application/zip' \ + | python3 -c 'import sys,json;print(json.load(sys.stdin)["job"])')" +for _ in $(seq 1 120); do + STATE="$(curl -fsS "http://127.0.0.1:$PORT/api/import/status?job=$JOB" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("state",""))' 2>/dev/null || true)" + [[ "$STATE" == "done" ]] && break + [[ "$STATE" == "error" ]] && { echo "import failed"; cat "$WORK/serve.log"; exit 1; } + sleep 1 +done + +echo "==> rendering pages → $OUT" +node scripts/s64-pages.mjs "http://127.0.0.1:$PORT" "$OUT" + +echo "==> done: $(ls "$OUT" | wc -l) PNG(s) in $OUT" diff --git a/web/src/chart-canvas/chart-canvas.mjs b/web/src/chart-canvas/chart-canvas.mjs index 54dc035..00da080 100644 --- a/web/src/chart-canvas/chart-canvas.mjs +++ b/web/src/chart-canvas/chart-canvas.mjs @@ -369,6 +369,9 @@ export class ChartCanvas extends HTMLElement { // since 1 NM ≡ 1 arcminute of latitude. Re-rendered on every move. this._scaleEl = document.createElement("div"); this._scaleEl.className = "s52-scalebar maplibregl-ctrl"; + // Spec mode (chrome-free capture, see chartplotter.mjs) hides the scalebar too — + // it lives in this element's shadow root, out of reach of the app's :host([spec]) CSS. + if (document.querySelector("chart-plotter-app[spec], chart-plotter[spec]")) this._scaleEl.style.display = "none"; map.addControl({ onAdd: () => this._scaleEl, onRemove: () => { this._scaleEl = null; } }, "bottom-left"); map.on("move", () => this._renderScalebar()); diff --git a/web/src/chart-canvas/chart-style.mjs b/web/src/chart-canvas/chart-style.mjs index 1ca5e47..5612450 100644 --- a/web/src/chart-canvas/chart-style.mjs +++ b/web/src/chart-canvas/chart-style.mjs @@ -89,6 +89,11 @@ function textLayers(mariner, palette) { "text-field": ["coalesce", ["get", "text"], ""], "text-font": FONT, "text-size": ["coalesce", ["get", "font_size_px"], 11], "text-anchor": TEXT_ANCHOR, + // S-52 TX/TE labels are single-line; MapLibre's default text-max-width (10 em) + // wrapped longer labels (e.g. "Information about chart display (A,B)") onto a + // second line that then collided and dropped. A wide max-width keeps each label + // on one line, matching the spec plots. + "text-max-width": 40, "symbol-sort-key": TEXT_SORT_KEY, "text-allow-overlap": false, "text-optional": true, visibility: "visible", diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index 8c34e78..1a99f18 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -205,6 +205,7 @@ export class ChartPlotter extends HTMLElement { // NOAA catalogue/discovery lives in this._dl (ChartDownloader, created in // boot); _catalog/_byName/_districts/_catalogDate are proxy getters onto it. this._installed = new Set(); // all stored cell names + this._activeCells = []; // active (enabled-pack) cells {n,l,bb} — the search catalog this._cellError = new Map(); // name -> error message, for cells that failed to parse this._cellBounds = new Map(); // name -> [w,s,e,n] footprint (from the baker), to locate uploaded cells this._cellScale = new Map(); // name -> compilation scale (CSCL) of uploaded cells, for picking a detail zoom @@ -284,6 +285,10 @@ export class ChartPlotter extends HTMLElement { // attribute so the :host([widget]) styles apply either way. this._widget = this.hasAttribute("widget") || new URLSearchParams(location.search).has("widget"); if (this._widget) this.setAttribute("widget", ""); + // Spec mode (?spec / [spec]): a clean, chrome-free full-bleed map — every + // floating control + readout hidden — for capturing reference-style plots (the + // S-52 PresLib "ECDIS Chart 1" panels diff against the spec). See :host([spec]). + if (this.hasAttribute("spec") || new URLSearchParams(location.search).has("spec")) this.setAttribute("spec", ""); // Display settings (scheme · basemap · mariner toggles · cell-boundary toggle · // bands-off) are persisted SERVER-side so every screen pointed at this boat's // server shares them and they survive a restart. Adopt them BEFORE the renderer @@ -528,7 +533,10 @@ export class ChartPlotter extends HTMLElement { try { this._plotter.setHiddenCells([...this._hiddenCells]); } catch (e) { console.warn(e); } } await this._catalogReady; - this.addCatalogOverlay(map); + // Best-effort: if the style is mid-rebuild this no-ops (or throws on older + // maps) — either way the style.load handler below re-adds the overlay once the + // fresh style is ready, so never let it skip registering that listener. + try { this.addCatalogOverlay(map); } catch (e) { console.warn("[overlay] deferring to style.load:", e); } // The plotter rebuilds the whole style (setStyle) when server sets load or the // SCAMIN buckets refresh, wiping every app-added overlay (coverage boxes, pick & // inspect highlights). Re-apply them after each rebuild, and repopulate the @@ -1065,6 +1073,13 @@ export class ChartPlotter extends HTMLElement { return this._plotter ? this._plotter.setView(opts) : null; } + // Public: the underlying MapLibre map, once ready (null before the first paint). + // Lets embedders frame a region with the library's own camera helpers, e.g. + // app.map?.fitBounds([[w, s], [e, n]], { padding: 56 }) + get map() { + return this._map || null; + } + saveView() { // The cell-picker "charts mode" (whose zoomed-out framing we used to skip // persisting) was removed; the live view is always the one to save. @@ -1082,6 +1097,12 @@ export class ChartPlotter extends HTMLElement { // layers. A style.load handler (see onReady) re-invokes this against the fresh // style; the guard makes a redundant call (when the overlay is still present) a // no-op so we never double-add. + // + // The style may still be REBUILDING when this first runs from onReady (a + // setStyle for the physical-scale restage / SCAMIN buckets can be in flight + // after the awaited catalog load) — addSource would throw "Style is not done + // loading". Bail; the onReady style.load handler re-invokes us once it's ready. + if (!map.isStyleLoaded()) return; if (map.getSource("focus")) return; const empty = { type: "FeatureCollection", features: [] }; map.addSource("focus", { type: "geojson", data: empty }); @@ -1482,6 +1503,9 @@ export class ChartPlotter extends HTMLElement { if (this._aux && !this._dl.auxUrl) this._aux.loadApi(this._assets).catch(() => {}); const cells = await this._api.cells(); if (cells) this._installed = cells; // null → keep current view + // Active (enabled-pack) cells WITH bounds — the search catalog, so you can find + // an installed chart by name and fly to it (esp. on a blank/no-basemap map). + this._activeCells = await this._api.activeCells(); // Management keys on the DISTRICT name (noaa-d5); enable/disable/remove hit the // district and the server fans to its band-sets. this._installedSets = new Set(packs.map((p) => p.name)); @@ -1848,14 +1872,14 @@ export class ChartPlotter extends HTMLElement { getInput: () => $("search-input"), getSearchPop: () => $("search"), getSearchTab: () => $("search-tab"), - getCatalog: () => this._catalog, + getCatalog: () => this._activeCells || [], // search ACTIVE installed charts (name → fly to footprint) isChartSource, classLabel: (acr) => S57_CLASS[acr], layerLabel: (srcLayer) => INSPECT_LAYER_LABEL[srcLayer], positionCaret: (pop, tab) => this._positionCaret(pop, tab), }); const closeSearch = () => { $("search").hidden = true; $("search-tab").classList.remove("on"); }; - const openSearch = () => { $("search").hidden = false; $("search-tab").classList.add("on"); this._search.position(); si.focus(); }; + const openSearch = () => { $("search").hidden = false; $("search-tab").classList.add("on"); this._search.position(); si.focus(); this._search.doSearch(si.value); /* show the browse list (active charts) right away */ }; $("search-tab").onclick = () => ($("search").hidden ? openSearch() : closeSearch()); si.oninput = () => this._search.doSearch(si.value); si.onkeydown = (e) => { diff --git a/web/src/chartplotter.view.mjs b/web/src/chartplotter.view.mjs index c6d8b85..5f6d14f 100644 --- a/web/src/chartplotter.view.mjs +++ b/web/src/chartplotter.view.mjs @@ -85,6 +85,12 @@ export const STYLE = ` affordances. Settings drop the Advanced tab (gated in JS). */ :host([widget]) #empty-add, :host([widget]) #empty .welcome-sub { display:none; } :host([widget]) #charts-btn, :host([widget]) #share-btn { display:none; } + /* Spec mode: a clean, chrome-free full-bleed map for capturing reference-style + plots (the S-52 PresLib "ECDIS Chart 1" panels). Hide every floating control, + the status readout, the attribution and the load bar so only the chart shows. */ + :host([spec]) #tl-controls, :host([spec]) #tr-controls, :host([spec]) #br-controls, + :host([spec]) #databox, :host([spec]) #noaa-attr, :host([spec]) #load-bar, + :host([spec]) #toasts { display:none; } .box-sel { position:absolute; z-index:5; border:2px solid var(--ui-accent); background:rgba(21,101,192,.12); pointer-events:none; } /* charts panel: action header + "your charts" cards */ .charts-actions { display:flex; gap:8px; margin-bottom:10px; } diff --git a/web/src/data/chart-service.mjs b/web/src/data/chart-service.mjs index b2f7006..5984699 100644 --- a/web/src/data/chart-service.mjs +++ b/web/src/data/chart-service.mjs @@ -148,6 +148,17 @@ export class ChartService { catch (e) { return null; } } + // GET /api/cells?active=1 — the ACTIVE (enabled-pack) cells that are indexed, + // as search-catalog entries {n,l,bb}: so a cell can be found by name and flown + // to its footprint. Only cells with known bounds (indexed) are returned. + async activeCells() { + try { + const j = await fetch(this._url("api/cells?active=1")).then((r) => (r.ok ? r.json() : null)); + const bb = (j && j.bbox) || {}; + return Object.keys(bb).map((n) => ({ n, l: n, bb: bb[n] })); + } catch (e) { return []; } + } + // POST /api/set/{enable,disable} — toggle a pack's rendering (data is kept). async setEnabled(set, on) { await fetch(this._url(`api/set/${on ? "enable" : "disable"}?set=${encodeURIComponent(set)}`), { method: "POST" }); diff --git a/web/src/plugins/coverage-boxes.mjs b/web/src/plugins/coverage-boxes.mjs index bdc8b77..549ae13 100644 --- a/web/src/plugins/coverage-boxes.mjs +++ b/web/src/plugins/coverage-boxes.mjs @@ -44,6 +44,23 @@ export class CoverageBoxes { const f = ["==", ["get", "band"], band]; if (!map.getLayer(`inst-fill-${band}`)) map.addLayer({ id: `inst-fill-${band}`, type: "fill", source: "inst-bounds", maxzoom: mz, filter: f, layout: { visibility: vis }, paint: { "fill-color": BAND_COLOR[band], "fill-opacity": 0.06 } }); if (!map.getLayer(`inst-line-${band}`)) map.addLayer({ id: `inst-line-${band}`, type: "line", source: "inst-bounds", maxzoom: mz, filter: f, layout: { visibility: vis }, paint: { "line-color": BAND_COLOR[band], "line-width": 1.1, "line-opacity": 0.85 } }); + // Cell-name label at the footprint centroid, so on a blank (no-basemap) map you + // can SEE which chart is where (and tap it). Capped at the band's render zoom + // (same as the fill/line) so it vanishes once the chart itself draws — not + // obtrusive when you're already there. Decluttered (drops on overlap). + if (!map.getLayer(`inst-label-${band}`)) map.addLayer({ id: `inst-label-${band}`, type: "symbol", source: "inst-bounds", maxzoom: mz, filter: f, layout: { + visibility: vis, + "symbol-placement": "point", + "text-field": ["coalesce", ["get", "name"], ""], + "text-font": ["Noto Sans Regular"], + "text-size": 12, + "text-allow-overlap": false, + "text-optional": true, + }, paint: { + "text-color": BAND_COLOR[band], + "text-halo-color": "#ffffff", + "text-halo-width": 1.8, + } }); } // Always-on footprint outline (NOT maxzoom-capped): when SCAMIN suppresses every // feature in a cell the tiles render blank, so keep a thin dashed outline at ALL @@ -71,7 +88,7 @@ export class CoverageBoxes { setVisible(on) { this._visible = !!on; const map = this.map, vis = on ? "visible" : "none"; - for (const band of BANDS) for (const pre of ["inst-fill-", "inst-line-"]) if (map.getLayer(pre + band)) map.setLayoutProperty(pre + band, "visibility", vis); + for (const band of BANDS) for (const pre of ["inst-fill-", "inst-line-", "inst-label-"]) if (map.getLayer(pre + band)) map.setLayoutProperty(pre + band, "visibility", vis); if (map.getLayer("inst-outline")) map.setLayoutProperty("inst-outline", "visibility", vis); } diff --git a/web/src/plugins/search-box.mjs b/web/src/plugins/search-box.mjs index fc3888d..0777969 100644 --- a/web/src/plugins/search-box.mjs +++ b/web/src/plugins/search-box.mjs @@ -42,35 +42,53 @@ export class SearchBox { if (!el) return; const raw = (q || "").trim(); const needle = raw.toLowerCase(); - if (needle.length < 2) { el.hidden = true; el.innerHTML = ""; this._hits = []; this.position(); return; } - // 0) A typed coordinate (any common notation) → a "Go to" hit pinned on top, - // so Enter jumps straight there. Other matches still list below. - const coord = parseLatLon(raw); - // 1) Catalog cells (chart titles / numbers), fuzzy-matched. Best score wins; - // ties break to the coarser chart (overview before an arbitrary harbour inset). + // BROWSE mode: nothing typed yet (or too short to fuzzy-match). Instead of a + // blank box, list the active charts — so you can find one without knowing its + // name. Ordered NEAREST-to-view first (most relevant to where you're looking), + // capped, with a "type to narrow" footer when there are more. + const browse = needle.length < 2; + const BROWSE_LIMIT = 40; + let center = null; + try { const c = this._getMap().getCenter(); center = [c.lng, c.lat]; } catch {} + // A typed coordinate (any common notation) → a "Go to" hit pinned on top, so + // Enter jumps straight there. Only when something is typed (browse has no query). + const coord = browse ? null : parseLatLon(raw); + // 1) Catalog cells (active installed charts), fuzzy-matched — or all, in browse. const cells = []; for (const c of this._getCatalog()) { if (!Array.isArray(c.bb) || c.bb.length !== 4) continue; + if (browse) { cells.push({ c, score: 0 }); continue; } const score = Math.max(fuzzyScore(needle, (c.l || "").toLowerCase()), fuzzyScore(needle, c.n.toLowerCase())); if (score >= 0) cells.push({ c, score }); } - cells.sort((a, b) => (b.score - a.score) || ((b.c.s || 0) - (a.c.s || 0))); - // 2) Every loaded chart feature, fuzzy-matched across its attribute data. - const feats = this._searchFeatures(needle); + if (browse) { + const d2 = (c) => center ? ((c.bb[0] + c.bb[2]) / 2 - center[0]) ** 2 + ((c.bb[1] + c.bb[3]) / 2 - center[1]) ** 2 : 0; + cells.sort((a, b) => (d2(a.c) - d2(b.c)) || (a.c.n < b.c.n ? -1 : 1)); // nearest first, then by name + } else { + cells.sort((a, b) => (b.score - a.score) || ((b.c.s || 0) - (a.c.s || 0))); // best match; ties → coarser chart + } + // 2) Loaded chart features (skip in browse — there's no query to match). + const feats = browse ? [] : this._searchFeatures(needle); + const more = browse && cells.length > BROWSE_LIMIT; + // A typed coordinate pins a "Go to" hit on top; cells then features below. const hits = [ ...(coord ? [{ type: "coord", lat: coord.lat, lng: coord.lng }] : []), - ...cells.slice(0, 5).map(({ c }) => ({ type: "cell", c })), + ...cells.slice(0, browse ? BROWSE_LIMIT : 5).map(({ c }) => ({ type: "cell", c })), ...feats.slice(0, 8), ]; this._hits = hits; - el.innerHTML = hits.length - ? hits.map((h, i) => { - const sel = i === 0 ? " sel" : ""; - if (h.type === "coord") return `
${esc(fmtLatLon(h.lat, h.lng))}
Go to coordinate
`; - if (h.type === "cell") return `
${esc(h.c.l || h.c.n)}
Chart · ${esc(h.c.n)} · 1:${(h.c.s || 0).toLocaleString()}
`; - return `
${esc(h.label)}
${esc(h.sub)}
`; - }).join("") - : `
No matches in view
`; + const rows = hits.map((h, i) => { + const sel = i === 0 ? " sel" : ""; + if (h.type === "coord") return `
${esc(fmtLatLon(h.lat, h.lng))}
Go to coordinate
`; + if (h.type === "cell") { const sub = h.c.s ? `Chart · ${esc(h.c.n)} · 1:${h.c.s.toLocaleString()}` : `Chart · ${esc(h.c.n)}`; return `
${esc(h.c.l || h.c.n)}
${sub}
`; } + return `
${esc(h.label)}
${esc(h.sub)}
`; + }); + if (hits.length) { + if (more) rows.push(`
${cells.length} charts — type to narrow
`); + el.innerHTML = rows.join(""); + } else { + el.innerHTML = `
${browse ? "No installed charts" : "No matches in view"}
`; + } el.hidden = false; el.querySelectorAll(".sr-item[data-i]").forEach((d) => (d.onmousedown = (e) => { e.preventDefault(); this.gotoHit(+d.dataset.i); })); this.position(); // re-align to the search tab as the result count changes the height