Skip to content

Commit 2a10031

Browse files
authored
Merge pull request #30 from beetlebugorg/docs/chart1-fullscreen-validate
docs(chart1): fullscreen spec-mode validator with keyboard nav
2 parents 2c5646e + 9dbee0c commit 2a10031

2 files changed

Lines changed: 206 additions & 32 deletions

File tree

docs/src/components/Chart1Tests.js

Lines changed: 122 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import useBaseUrl from '@docusaurus/useBaseUrl';
44

55
// Chart1Tests embeds the S-52 PresLib "ECDIS Chart 1" reference sheet LIVE — one
66
// read-only <chart-plotter> widget — and turns the docs page into a symbol-compliance
7-
// checker: every panel of the sheet is a row in the list; click one and the widget
8-
// frames that panel. The whole sheet is one contiguous synthetic ENC, so navigation
9-
// is just map.fitBounds(panel) — which fits the panel to the actual map size with
10-
// padding that keeps the widget's own chrome (HUD, controls, scalebar) off the data.
7+
// checker: every panel of the sheet is a row in the list; click one (or arrow up/down)
8+
// and the widget frames that panel. The whole sheet is one contiguous synthetic ENC,
9+
// so navigation is just map.fitBounds(panel). The widget runs in `spec` mode (no
10+
// chrome) with the reference mariner settings forced on, so what you see is an
11+
// apples-to-apples diff against the spec's own reference plots.
1112
// Tiles load from the /chart1/ bundle (`make demo-chart1`); the frontend assets are
1213
// shared with the /demo/ bundle.
1314

@@ -18,8 +19,31 @@ const PX_PITCH_M = 0.00026458;
1819
const zoomForScale = (scale, lat) =>
1920
Math.log2((M_PER_PX_Z0 * Math.cos((lat * Math.PI) / 180)) / (PX_PITCH_M * scale));
2021

21-
// Inset the fit so the sheet clears the widget's overlaid chrome on every edge.
22-
const PAD = {top: 48, bottom: 56, left: 48, right: 48};
22+
// Spec mode hides all widget chrome, so the fit just needs a small, symmetric margin
23+
// to keep edge symbology off the frame edge — like the thin border around each
24+
// PresLib reference plot. (No asymmetric chrome padding any more.)
25+
const PAD = {top: 18, bottom: 18, left: 18, right: 18};
26+
27+
// Mariner display state pinned to match the IHO PresLib reference plots — kept in
28+
// step with the MARINER block in scripts/preslib-chart1.mjs. The widget is read-only
29+
// here (spec mode), so the viewer can't change anything; we force these at ready so
30+
// the render is an apples-to-apples diff against the spec's own figures. ALL symbology
31+
// shown; data-quality overlay on (CATZOC panels); metres (IHO, not NOAA feet); the
32+
// depth-shading demo's 0/5/10/30 contours labelled; date-dependency + meta boundaries
33+
// shown; 25 mm sectors and symbolized boundaries (the S-52 defaults the plots use).
34+
const MARINER = {
35+
displayBase: true, displayStandard: true, displayOther: true,
36+
dataQuality: true,
37+
depthUnit: 'm',
38+
showContourLabels: true,
39+
shallowContour: 5, safetyContour: 10, deepContour: 30,
40+
highlightDateDependent: true,
41+
dateDependent: false,
42+
showMetaBounds: true,
43+
showFullSectorLines: false,
44+
boundaryStyle: 'symbolized',
45+
simplifiedPoints: false,
46+
};
2347

2448
// One row per PresLib reference-plot page (Part I §16, doc pages 238–253). Bounds
2549
// 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
5276
// map so neither the whole-sheet fit (on a small map) nor a scroll can cross it.
5377
const SCAMIN_MIN_ZOOM = zoomForScale(139000, SHEET.lat);
5478

55-
// Fit the map to a panel's bounds with chrome padding. Returns false if the map
79+
// Fit the map to a panel's bounds with a symmetric margin. Returns false if the map
5680
// isn't up yet (caller falls back to setView).
5781
function fitPanel(el, p, animate) {
5882
const m = el && el.map;
@@ -70,8 +94,11 @@ function Chart() {
7094
const manifest = useBaseUrl('/chart1/charts-index.json');
7195
const overviewImg = useBaseUrl('/img/chart1/page-238-overview.png');
7296
const ref = useRef(null);
97+
const listRef = useRef(null); // the <ol> of panel buttons (for focus moves)
98+
const activeRef = useRef(SHEET); // current panel, for handlers in stale closures
7399
const [active, setActive] = useState(238);
74100
const [status, setStatus] = useState('checking'); // checking | ready | missing
101+
const [full, setFull] = useState(false); // fullscreen: panel list + widget fill the viewport
75102

76103
// Only boot the live widget if the tile bundle is actually published. Locally
77104
// (no `make demo-chart1`) fall back to the static overview image.
@@ -95,36 +122,93 @@ function Chart() {
95122
return () => { cancelled = true; };
96123
}, [demo, manifest]);
97124

98-
// Once the widget's map is ready, frame the whole sheet (fitBounds, not a guessed
99-
// scale, so the entire box + labels show with margin for the chrome).
125+
// Once the widget's map is ready: pin the reference mariner settings + Day scheme
126+
// (spec mode is read-only — the viewer can't change them), floor the zoom past the
127+
// SCAMIN cutoff, and frame the whole sheet.
100128
useEffect(() => {
101129
if (status !== 'ready') return undefined;
102130
let tries = 0;
103131
const iv = setInterval(() => {
104-
const m = ref.current && ref.current.map;
105-
if (m) {
106-
try { m.setMinZoom(SCAMIN_MIN_ZOOM); } catch (e) { /* older map */ }
107-
fitPanel(ref.current, SHEET, false);
108-
clearInterval(iv);
109-
} else if (++tries > 60) {
110-
clearInterval(iv);
132+
const el = ref.current;
133+
const m = el && el.map;
134+
if (!m) { if (++tries > 60) clearInterval(iv); return; }
135+
clearInterval(iv);
136+
try { m.setMinZoom(SCAMIN_MIN_ZOOM); } catch (e) { /* older map */ }
137+
// Force the reference display state. applyScheme('day') also resets any scheme
138+
// a previous Day/Dusk click (or the sibling demo) left in localStorage.
139+
if (typeof el.applyMariner === 'function') {
140+
try { el.applyMariner(MARINER); } catch (e) { /* widget best-effort */ }
141+
}
142+
if (typeof el.applyScheme === 'function') {
143+
try { el.applyScheme('day'); } catch (e) { /* widget best-effort */ }
111144
}
145+
fitPanel(el, SHEET, false);
112146
}, 200);
113147
return () => clearInterval(iv);
114148
}, [status]);
115149

150+
// Entering/leaving fullscreen resizes the map frame, so let the map relayout and
151+
// re-frame the active panel. Also lock page scroll + wire Escape / arrow nav.
152+
useEffect(() => {
153+
if (typeof document !== 'undefined') {
154+
document.body.style.overflow = full ? 'hidden' : '';
155+
}
156+
const t = setTimeout(() => {
157+
const m = ref.current && ref.current.map;
158+
if (!m) return;
159+
try { m.resize(); } catch (e) { /* older map */ }
160+
fitPanel(ref.current, activeRef.current, false);
161+
}, 80);
162+
if (!full) return () => clearTimeout(t);
163+
// Fullscreen owns the screen, so Up/Down nav works globally even when focus is on
164+
// the map. But if focus is on a panel button, the list's own onKeyDown already
165+
// handled it (and called preventDefault) — bail so we don't step twice.
166+
const onKey = (e) => {
167+
if (e.key === 'Escape') { setFull(false); return; }
168+
if (e.defaultPrevented) return;
169+
onNavKey(e);
170+
};
171+
window.addEventListener('keydown', onKey);
172+
return () => {
173+
clearTimeout(t);
174+
window.removeEventListener('keydown', onKey);
175+
if (typeof document !== 'undefined') document.body.style.overflow = '';
176+
};
177+
}, [full]); // eslint-disable-line react-hooks/exhaustive-deps
178+
116179
const go = (p) => {
117180
setActive(p.page);
181+
activeRef.current = p;
118182
const el = ref.current;
119183
if (!el) return;
120-
if (p.scheme && typeof el.applyScheme === 'function') {
121-
try { el.applyScheme(p.scheme); } catch (e) { /* widget-mode best-effort */ }
184+
// Day/Dusk colour-test panels carry their own scheme; everything else stays Day.
185+
if (typeof el.applyScheme === 'function') {
186+
try { el.applyScheme(p.scheme || 'day'); } catch (e) { /* widget-mode best-effort */ }
122187
}
123188
if (!fitPanel(el, p, true) && typeof el.setView === 'function') {
124189
el.setView({lng: p.lng, lat: p.lat, scale: p.scale, animate: true, duration: 900});
125190
}
126191
};
127192

193+
// Keyboard nav: Up/Down step through the panel list. Uses activeRef (always
194+
// current) so it works from the fullscreen effect's stale closure too.
195+
const focusBtn = (page) => {
196+
const root = listRef.current;
197+
const btn = root && root.querySelector(`button[data-page="${page}"]`);
198+
if (btn) btn.focus();
199+
};
200+
const step = (dir) => {
201+
const i = PANELS.findIndex((p) => p.page === activeRef.current.page);
202+
const ni = Math.min(PANELS.length - 1, Math.max(0, i + dir));
203+
if (ni === i) return;
204+
go(PANELS[ni]);
205+
focusBtn(PANELS[ni].page);
206+
};
207+
const onNavKey = (e) => {
208+
if (e.key === 'ArrowDown') { e.preventDefault(); step(1); }
209+
else if (e.key === 'ArrowUp') { e.preventDefault(); step(-1); }
210+
};
211+
128212
if (status === 'missing') {
129213
return (
130214
<div className="chart1 chart1--poster">
@@ -141,16 +225,28 @@ function Chart() {
141225

142226
const zoom = zoomForScale(INITIAL_SCALE, SHEET.lat);
143227
return (
144-
<div className="chart1">
228+
<div className={'chart1' + (full ? ' chart1--full' : '')}>
145229
<div className="chart1__panel">
146-
<div className="chart1__title">
147-
Reference panels <span className="chart1__sub">PresLib §16, pp. 238–253</span>
230+
<div className="chart1__head">
231+
<div className="chart1__title">
232+
Reference panels <span className="chart1__sub">PresLib §16, pp. 238–253</span>
233+
</div>
234+
<button
235+
type="button"
236+
className="chart1__full"
237+
onClick={() => setFull((v) => !v)}
238+
aria-pressed={full}
239+
title={full ? 'Exit fullscreen (Esc)' : 'Fullscreen — compare against the spec plots'}
240+
>
241+
{full ? '✕ Exit' : '⤢ Fullscreen'}
242+
</button>
148243
</div>
149-
<ol className="chart1__list">
244+
<ol className="chart1__list" ref={listRef} onKeyDown={onNavKey}>
150245
{PANELS.map((p) => (
151246
<li key={p.page}>
152247
<button
153248
type="button"
249+
data-page={p.page}
154250
className={'chart1__test' + (active === p.page ? ' chart1__test--active' : '')}
155251
onClick={() => go(p)}
156252
>
@@ -162,10 +258,13 @@ function Chart() {
162258
</ol>
163259
</div>
164260
<div className="liveChart chart1__map">
165-
{/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles */}
261+
{/* widget = read-only viewer; assets = demo frontend; catalog = Chart 1 tiles.
262+
spec = chrome-free clean map (no controls/databox/attr/scalebar) so the
263+
render matches the PresLib reference plots for side-by-side diffing. */}
166264
<chart-plotter
167265
ref={ref}
168266
widget=""
267+
spec=""
169268
assets={demo}
170269
catalog={manifest}
171270
basemap="none"

docs/src/css/custom.css

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,58 @@
7373
flex-direction: column;
7474
max-height: 78vh;
7575
min-height: 520px;
76+
border: 1px solid var(--ifm-color-emphasis-200);
77+
border-radius: 12px;
78+
background: var(--ifm-card-background-color, var(--ifm-background-surface-color));
79+
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.06);
80+
overflow: hidden;
81+
}
82+
.chart1__head {
83+
display: flex;
84+
align-items: center;
85+
gap: 0.5rem;
86+
padding: 0.55rem 0.6rem 0.5rem 0.7rem;
87+
border-bottom: 1px solid var(--ifm-color-emphasis-200);
88+
background: var(--ifm-color-emphasis-100);
7689
}
7790
.chart1__title {
91+
flex: 1;
92+
min-width: 0;
7893
font-size: 0.78rem;
79-
font-weight: 600;
94+
font-weight: 700;
8095
text-transform: uppercase;
8196
letter-spacing: 0.04em;
82-
color: var(--ifm-color-emphasis-700);
83-
padding: 0 0.3rem 0.4rem;
97+
color: var(--ifm-color-emphasis-800);
8498
}
8599
.chart1__sub {
86100
display: block;
87101
font-weight: 400;
88102
text-transform: none;
89103
letter-spacing: 0;
90104
font-size: 0.72rem;
91-
color: var(--ifm-color-emphasis-500);
105+
color: var(--ifm-color-emphasis-600);
106+
}
107+
.chart1__full {
108+
flex: none;
109+
cursor: pointer;
110+
border: 1px solid var(--ifm-color-primary);
111+
border-radius: 6px;
112+
padding: 0.25rem 0.55rem;
113+
font-size: 0.72rem;
114+
font-weight: 600;
115+
white-space: nowrap;
116+
color: var(--ifm-color-primary);
117+
background: transparent;
118+
transition: background 0.12s, color 0.12s;
119+
}
120+
.chart1__full:hover {
121+
background: var(--ifm-color-primary);
122+
color: #fff;
92123
}
93124
.chart1__list {
94125
list-style: none;
95126
margin: 0;
96-
padding: 0;
127+
padding: 0.4rem;
97128
overflow: auto;
98129
display: flex;
99130
flex-direction: column;
@@ -102,9 +133,9 @@
102133
.chart1__test {
103134
display: flex;
104135
align-items: baseline;
105-
gap: 0.5rem;
136+
gap: 0.55rem;
106137
width: 100%;
107-
padding: 0.4rem 0.55rem;
138+
padding: 0.45rem 0.55rem;
108139
text-align: left;
109140
cursor: pointer;
110141
border: 0;
@@ -116,19 +147,27 @@
116147
transition: background 0.12s, border-color 0.12s;
117148
}
118149
.chart1__test:hover {
119-
background: var(--ifm-color-emphasis-100);
150+
background: var(--ifm-color-emphasis-200);
120151
}
121152
.chart1__test--active {
122153
background: var(--ifm-color-primary-lightest);
123154
border-left-color: var(--ifm-color-primary);
124155
}
156+
.chart1__test--active .chart1__label {
157+
font-weight: 600;
158+
color: var(--ifm-color-primary-darkest);
159+
}
125160
.chart1__page {
126161
min-width: 2.7em;
127-
font-size: 0.68rem;
162+
font-size: 0.66rem;
163+
font-weight: 600;
128164
font-variant-numeric: tabular-nums;
129165
color: var(--ifm-color-emphasis-600);
130166
white-space: nowrap;
131167
}
168+
.chart1__test--active .chart1__page {
169+
color: var(--ifm-color-primary);
170+
}
132171
.chart1__label {
133172
flex: 1;
134173
font-size: 0.85rem;
@@ -141,6 +180,28 @@
141180
height: 78vh;
142181
min-height: 520px;
143182
}
183+
184+
/* Fullscreen: lift the whole checker out of the docs column so the reference
185+
list and the live widget fill the viewport together — easy spec diffing. */
186+
.chart1--full {
187+
position: fixed;
188+
inset: 0;
189+
z-index: 9999;
190+
margin: 0;
191+
padding: 0.75rem;
192+
gap: 0.75rem;
193+
background: var(--ifm-background-color);
194+
grid-template-columns: 280px minmax(0, 1fr);
195+
}
196+
.chart1--full .chart1__panel {
197+
max-height: none;
198+
min-height: 0;
199+
height: 100%;
200+
}
201+
.chart1--full .chart1__map {
202+
height: 100%;
203+
min-height: 0;
204+
}
144205
.chart1__poster {
145206
width: 100%;
146207
border-radius: 12px;
@@ -173,4 +234,18 @@
173234
display: grid;
174235
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
175236
}
237+
/* Fullscreen on a narrow screen: keep it stacked but fill the viewport —
238+
map on top takes the room, panel list below scrolls. */
239+
.chart1--full {
240+
grid-template-columns: 1fr;
241+
grid-template-rows: minmax(0, 1fr) auto;
242+
}
243+
.chart1--full .chart1__map {
244+
grid-row: 1;
245+
height: auto;
246+
}
247+
.chart1--full .chart1__panel {
248+
grid-row: 2;
249+
max-height: 38vh;
250+
}
176251
}

0 commit comments

Comments
 (0)