From 9dbee0cc9970e43b662f89f5f99f9d63216a88c4 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Fri, 26 Jun 2026 19:39:04 -0400 Subject: [PATCH] docs(chart1): fullscreen spec-mode validator with keyboard nav Make the ECDIS Chart 1 page an easy side-by-side compliance checker against the PresLib reference plots: - Fullscreen toggle lifts the reference-panel list + live widget out of the docs column to fill the viewport together (Esc to exit). - Run the widget in `spec` mode (chrome-free clean map) and force the reference mariner settings on at ready (the viewer is read-only), so the render is an apples-to-apples diff against the spec figures. The MARINER block mirrors scripts/preslib-chart1.mjs. - Keyboard nav: Up/Down step through the panels (list-focused in any mode; global while fullscreen). Guard against the focused-button + window listeners both firing (was skipping by 2). - Restyle the reference-panel list into a proper card with a header bar and clearer active-row treatment; symmetric fit padding now that spec mode removes the chrome the old asymmetric padding cleared. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/src/components/Chart1Tests.js | 145 ++++++++++++++++++++++++----- docs/src/css/custom.css | 93 ++++++++++++++++-- 2 files changed, 206 insertions(+), 32 deletions(-) diff --git a/docs/src/components/Chart1Tests.js b/docs/src/components/Chart1Tests.js index 473905c..b46a835 100644 --- a/docs/src/components/Chart1Tests.js +++ b/docs/src/components/Chart1Tests.js @@ -4,10 +4,11 @@ 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. +// checker: every panel of the sheet is a row in the list; click one (or arrow up/down) +// and the widget frames that panel. The whole sheet is one contiguous synthetic ENC, +// so navigation is just map.fitBounds(panel). The widget runs in `spec` mode (no +// chrome) with the reference mariner settings forced on, so what you see is an +// apples-to-apples diff against the spec's own reference plots. // Tiles load from the /chart1/ bundle (`make demo-chart1`); the frontend assets are // shared with the /demo/ bundle. @@ -18,8 +19,31 @@ 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}; +// Spec mode hides all widget chrome, so the fit just needs a small, symmetric margin +// to keep edge symbology off the frame edge — like the thin border around each +// PresLib reference plot. (No asymmetric chrome padding any more.) +const PAD = {top: 18, bottom: 18, left: 18, right: 18}; + +// Mariner display state pinned to match the IHO PresLib reference plots — kept in +// step with the MARINER block in scripts/preslib-chart1.mjs. The widget is read-only +// here (spec mode), so the viewer can't change anything; we force these at ready so +// the render is an apples-to-apples diff against the spec's own figures. ALL symbology +// shown; data-quality overlay on (CATZOC panels); metres (IHO, not NOAA feet); the +// depth-shading demo's 0/5/10/30 contours labelled; date-dependency + meta boundaries +// shown; 25 mm sectors and symbolized boundaries (the S-52 defaults the plots use). +const MARINER = { + displayBase: true, displayStandard: true, displayOther: true, + dataQuality: true, + depthUnit: 'm', + showContourLabels: true, + shallowContour: 5, safetyContour: 10, deepContour: 30, + highlightDateDependent: true, + dateDependent: false, + showMetaBounds: true, + showFullSectorLines: false, + boundaryStyle: 'symbolized', + simplifiedPoints: false, +}; // One row per PresLib reference-plot page (Part I §16, doc pages 238–253). Bounds // are the cells' data extents [W, S, E, N]; the harbor pages are 1:14 000, the @@ -52,7 +76,7 @@ const INITIAL_SCALE = 105000; // generous pre-fit paint; fitBounds refines on re // 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 +// Fit the map to a panel's bounds with a symmetric margin. Returns false if the map // isn't up yet (caller falls back to setView). function fitPanel(el, p, animate) { const m = el && el.map; @@ -70,8 +94,11 @@ function Chart() { const manifest = useBaseUrl('/chart1/charts-index.json'); const overviewImg = useBaseUrl('/img/chart1/page-238-overview.png'); const ref = useRef(null); + const listRef = useRef(null); // the
    of panel buttons (for focus moves) + const activeRef = useRef(SHEET); // current panel, for handlers in stale closures const [active, setActive] = useState(238); const [status, setStatus] = useState('checking'); // checking | ready | missing + const [full, setFull] = useState(false); // fullscreen: panel list + widget fill the viewport // Only boot the live widget if the tile bundle is actually published. Locally // (no `make demo-chart1`) fall back to the static overview image. @@ -95,36 +122,93 @@ function Chart() { 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). + // Once the widget's map is ready: pin the reference mariner settings + Day scheme + // (spec mode is read-only — the viewer can't change them), floor the zoom past the + // SCAMIN cutoff, and frame the whole sheet. useEffect(() => { if (status !== 'ready') return undefined; let tries = 0; const iv = setInterval(() => { - const 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); + const el = ref.current; + const m = el && el.map; + if (!m) { if (++tries > 60) clearInterval(iv); return; } + clearInterval(iv); + try { m.setMinZoom(SCAMIN_MIN_ZOOM); } catch (e) { /* older map */ } + // Force the reference display state. applyScheme('day') also resets any scheme + // a previous Day/Dusk click (or the sibling demo) left in localStorage. + if (typeof el.applyMariner === 'function') { + try { el.applyMariner(MARINER); } catch (e) { /* widget best-effort */ } + } + if (typeof el.applyScheme === 'function') { + try { el.applyScheme('day'); } catch (e) { /* widget best-effort */ } } + fitPanel(el, SHEET, false); }, 200); return () => clearInterval(iv); }, [status]); + // Entering/leaving fullscreen resizes the map frame, so let the map relayout and + // re-frame the active panel. Also lock page scroll + wire Escape / arrow nav. + useEffect(() => { + if (typeof document !== 'undefined') { + document.body.style.overflow = full ? 'hidden' : ''; + } + const t = setTimeout(() => { + const m = ref.current && ref.current.map; + if (!m) return; + try { m.resize(); } catch (e) { /* older map */ } + fitPanel(ref.current, activeRef.current, false); + }, 80); + if (!full) return () => clearTimeout(t); + // Fullscreen owns the screen, so Up/Down nav works globally even when focus is on + // the map. But if focus is on a panel button, the list's own onKeyDown already + // handled it (and called preventDefault) — bail so we don't step twice. + const onKey = (e) => { + if (e.key === 'Escape') { setFull(false); return; } + if (e.defaultPrevented) return; + onNavKey(e); + }; + window.addEventListener('keydown', onKey); + return () => { + clearTimeout(t); + window.removeEventListener('keydown', onKey); + if (typeof document !== 'undefined') document.body.style.overflow = ''; + }; + }, [full]); // eslint-disable-line react-hooks/exhaustive-deps + const go = (p) => { setActive(p.page); + activeRef.current = p; const el = ref.current; if (!el) return; - if (p.scheme && typeof el.applyScheme === 'function') { - try { el.applyScheme(p.scheme); } catch (e) { /* widget-mode best-effort */ } + // Day/Dusk colour-test panels carry their own scheme; everything else stays Day. + if (typeof el.applyScheme === 'function') { + try { el.applyScheme(p.scheme || 'day'); } catch (e) { /* widget-mode best-effort */ } } if (!fitPanel(el, p, true) && typeof el.setView === 'function') { el.setView({lng: p.lng, lat: p.lat, scale: p.scale, animate: true, duration: 900}); } }; + // Keyboard nav: Up/Down step through the panel list. Uses activeRef (always + // current) so it works from the fullscreen effect's stale closure too. + const focusBtn = (page) => { + const root = listRef.current; + const btn = root && root.querySelector(`button[data-page="${page}"]`); + if (btn) btn.focus(); + }; + const step = (dir) => { + const i = PANELS.findIndex((p) => p.page === activeRef.current.page); + const ni = Math.min(PANELS.length - 1, Math.max(0, i + dir)); + if (ni === i) return; + go(PANELS[ni]); + focusBtn(PANELS[ni].page); + }; + const onNavKey = (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); step(1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); step(-1); } + }; + if (status === 'missing') { return (
    @@ -141,16 +225,28 @@ function Chart() { const zoom = zoomForScale(INITIAL_SCALE, SHEET.lat); return ( -
    +
    -
    - Reference panels PresLib §16, pp. 238–253 +
    +
    + Reference panels PresLib §16, pp. 238–253 +
    +
    -
      +
        {PANELS.map((p) => (
    - {/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles */} + {/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles. + spec = chrome-free clean map (no controls/databox/attr/scalebar) so the + render matches the PresLib reference plots for side-by-side diffing. */}