/**
 * Tour map — an inline SVG route diagram, with an on-demand MapLibre GL map
 * behind a button. Geography comes from window.RLJ.geo.tours[id] (see geo.js)
 * and is handed in as a prop; this file only projects and draws it, so the same
 * component serves every tour.
 */

const MAP_W = 1000;

// MapLibre GL v6 is ESM-only and code-split, so it cannot be loaded as a
// classic script and a dynamic import() cannot carry an integrity hash. It is
// vendored into public/vendor/ instead: same-origin, so SRI is unnecessary,
// nothing third-party is fetched to render the map itself, and the library
// resolves its own shared and worker chunks relative to import.meta.url.
const MAPLIBRE_DIR = '/vendor/maplibre-6.1.0/';

// Line treatments, shared across tours so a mode always looks the same. A tour
// draws only the modes its legs use, in the order its geo entry asks for.
//
// 'ticks' overprints the line with a fine dashed stripe in the page colour, so
// a heavy-rail corridor reads as sleepers rather than a plain stroke. The closed
// New Zealand corridor is instead a wide ghosted band, so the cycle and coach
// days read as things happening on top of a railway that no longer runs.
const MODE_STYLE = {
'rail-operating': {stroke: 'var(--accent-rust)', width: 5, ticks: true},
'rail-closed': {stroke: 'var(--ink-300)', width: 12, dash: '16 10', opacity: 0.85},
cycle: {stroke: 'var(--tussock-500)', width: 4.5, dash: '16 10'},
coach: {stroke: 'var(--ink-900)', width: 3.8, dash: '0.1 11', cap: 'round'},
air: {stroke: 'var(--text-tertiary)', width: 2, dash: '11 9', arc: true},
hsr: {stroke: 'var(--rust-600)', width: 5.5, ticks: true},
'rail-conventional': {stroke: 'var(--rail-blue-700)', width: 4.5, ticks: true},
'mountain-rail': {stroke: 'var(--ink-700)', width: 4.5, dash: '13 7'},
metro: {stroke: 'var(--tussock-600)', width: 4},
lightrail: {stroke: 'var(--ochre-600)', width: 4, dash: '9 5'},
'airport-rail': {stroke: 'var(--rail-blue-400)', width: 3.4, dash: '14 8'}
};

function projector(bounds, width){
const rad = d => d * Math.PI / 180;
const mercY = lat => Math.log(Math.tan(Math.PI / 4 + rad(lat) / 2));
const scale = width / (rad(bounds.maxLng) - rad(bounds.minLng));
const top = mercY(bounds.maxLat);
const height = (top - mercY(bounds.minLat)) * scale;
return {
height,
project: ([lat, lng]) => [(rad(lng) - rad(bounds.minLng)) * scale, (top - mercY(lat)) * scale]
};
}

const polyline = pts => pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ');

// Flights get a bowed line so they do not read as a railway.
function arc(a, b){
const dx = b[0] - a[0], dy = b[1] - a[1], k = 0.16;
return `M${a[0].toFixed(1)},${a[1].toFixed(1)} Q${(a[0] + dx / 2 - dy * k).toFixed(1)},${(a[1] + dy / 2 + dx * k).toFixed(1)} ${b[0].toFixed(1)},${b[1].toFixed(1)}`;
}

// A disc stands for one day, or for a run of days at the same place. Contiguous
// runs read as a range; anything else is listed, because collapsing 1, 2 and 7
// into '1–7' would claim days the marker does not represent.
function markerLabel(days){
const runs = [];
for (const d of days) {
const last = runs[runs.length - 1];
if (last && d === last[1] + 1) last[1] = d; else runs.push([d, d]);
}
return runs.map(([a, b]) => (a === b ? String(a) : a + '–' + b)).join(', ');
}

const markerKey = m => m.place + ':' + m.days.join('-');

// '1, 2 and 3' rather than '1 and 2 and 3', which is what a screen reader would
// otherwise read out for a marker covering three days.
function dayList(days){
if (days.length < 2) return String(days[0]);
return days.slice(0, -1).join(', ') + ' and ' + days[days.length - 1];
}

function TourMap({geo, itinerary, tourTitle, activeDay, onSelectDay}){
const [slippy, setSlippy] = React.useState(false);
const {project, height} = React.useMemo(() => projector(geo.bounds, MAP_W), [geo]);
const at = key => project(geo.places[key]);
const titleId = 'tourmap-t', descId = 'tourmap-d';

const legPath = leg => {
const pts = leg.path.map(at);
return MODE_STYLE[leg.mode].arc ? arc(pts[0], pts[pts.length - 1]) : polyline(pts);
};
const dayTitle = day => {
const d = itinerary.find(x => x.day === day);
return d ? `Day ${d.day} — ${d.location}` : `Day ${day}`;
};

return <div>
<div className="g-2" style={{display: 'grid', gridTemplateColumns: '1.55fr 1fr', gap: 'var(--space-10)', alignItems: 'start'}}>

{/* The SVG scales with its column, and below about 500px the day discs fall
    under a usable tap target. The wrapper scrolls instead of shrinking, so the
    markers keep a readable size on a phone. */}
<div className="rlj-map-frame" style={{background: 'var(--surface-raised)', border: '1px solid var(--border-subtle)', padding: 'var(--space-5)', overflowX: 'auto'}}>
{/* role="group", not "img": an img role flattens descendant semantics, so the
    day markers below would stop being exposed as buttons to a screen reader
    even though they stay keyboard-focusable. */}
<svg viewBox={`0 0 ${MAP_W} ${height.toFixed(0)}`} role="group" aria-labelledby={titleId} aria-describedby={descId} style={{width: '100%', height: 'auto', display: 'block', overflow: 'visible'}}>
<title id={titleId}>{geo.title}</title>
<desc id={descId}>{geo.desc}</desc>

{geo.land.map((ring, i) => <path key={i} d={polyline(ring.map(project)) + ' Z'}
fill="var(--surface-sunken)" stroke="var(--border-default)" strokeWidth="1.5" strokeLinejoin="round" />)}

{geo.drawOrder.map(mode => geo.legs.filter(l => l.mode === mode).map((leg, i) => {
const s = MODE_STYLE[mode];
return <g key={mode + i}>
<path d={legPath(leg)} fill="none" stroke={s.stroke} strokeWidth={s.width} strokeDasharray={s.dash}
strokeLinecap={s.cap || 'butt'} strokeLinejoin="round" opacity={s.opacity || 1}>
<title>{leg.label}</title>
</path>
{s.ticks && <path d={legPath(leg)} fill="none" stroke="var(--surface-raised)" strokeWidth={s.width}
strokeDasharray="1.6 9" strokeLinejoin="round" pointerEvents="none" />}
</g>;
}))}

{geo.markers.map(m => {
const p = at(m.place);
const off = m.offset || [0, 0];
const c = [p[0] + off[0], p[1] + off[1]];
const on = m.days.includes(activeDay);
const label = markerLabel(m.days);
// A marker standing for more than one day steps through them on repeat
// activation, so every day it represents is reachable from the map.
const next = () => m.days[(m.days.indexOf(activeDay) + 1) % m.days.length];
const aria = m.days.length > 1
? `Days ${dayList(m.days)}, ${m.label}. Activate repeatedly to step through each day.`
: `Day ${m.days[0]}, ${m.label}`;
// The disc holds a fixed radius, so a long label such as '1–3' has to shrink
// to stay inside it rather than spilling over the ring.
const fontSize = label.length > 3 ? 22 : label.length > 2 ? 26 : 30;
return <g key={markerKey(m)} role="button" tabIndex={0} aria-label={aria}
onClick={() => onSelectDay(next())}
onKeyDown={e => {if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); onSelectDay(next());}}}
style={{cursor: 'pointer'}}>
<title>{m.days.map(dayTitle).join(' · ')}</title>
{(off[0] || off[1]) ? <React.Fragment>
<line x1={p[0]} y1={p[1]} x2={c[0]} y2={c[1]} stroke="var(--text-tertiary)" strokeWidth="1.4" />
<circle cx={p[0]} cy={p[1]} r="4.5" fill="var(--text-tertiary)" />
</React.Fragment> : null}
<circle cx={c[0]} cy={c[1]} r="27" fill={on ? 'var(--accent-rust)' : 'var(--surface-page)'}
stroke={on ? 'var(--accent-rust)' : 'var(--text-tertiary)'} strokeWidth="2.5" />
<text x={c[0]} y={c[1]} textAnchor="middle" dominantBaseline="central" pointerEvents="none"
style={{font: `600 ${fontSize}px var(--font-mono)`, fill: on ? 'var(--text-on-inverse)' : 'var(--text-primary)'}}>{label}</text>
{/* Transparent target on top, so the whole disc is tappable rather than just
    the stroked ring, and the leader line is not part of the hit area. */}
<circle cx={c[0]} cy={c[1]} r="34" fill="transparent" />
</g>;
})}
</svg>
</div>

<div>
<Eyebrow>How to read it</Eyebrow>
<ul style={{listStyle: 'none', margin: 'var(--space-5) 0 0', padding: 0, display: 'grid', gap: 'var(--space-4)'}}>
{geo.modes.map(([mode, label]) => {
const s = MODE_STYLE[mode];
return <li key={mode} style={{display: 'grid', gridTemplateColumns: '54px 1fr', gap: 'var(--space-4)', alignItems: 'center', font: 'var(--text-body-sm)', color: 'var(--text-secondary)'}}>
<svg viewBox="0 0 54 12" aria-hidden="true" style={{width: 54, height: 12}}>
<line x1="1" y1="6" x2="53" y2="6" stroke={s.stroke} strokeWidth={s.width} strokeDasharray={s.dash} strokeLinecap={s.cap || 'butt'} opacity={s.opacity || 1} />
{s.ticks && <line x1="1" y1="6" x2="53" y2="6" stroke="var(--surface-page)" strokeWidth={s.width} strokeDasharray="1.6 9" />}
</svg>
<span>{label}</span>
</li>;
})}
</ul>
<p style={{font: 'var(--text-body-sm)', color: 'var(--text-tertiary)', marginTop: 'var(--space-6)', textWrap: 'pretty'}}>
Numbered discs mark the itinerary days. Select one to open that day below. Where places sit close together the disc is drawn beside its true position, joined by a fine line.
</p>
<div style={{marginTop: 'var(--space-6)'}}>
<Button variant="secondary" size="sm" onClick={() => setSlippy(true)}>Open interactive map</Button>
</div>
<p style={{font: 'var(--text-caption)', color: 'var(--text-tertiary)', marginTop: 'var(--space-4)', marginBottom: 0}}>
{geo.attribution}
</p>
</div>
</div>

<ol className="rlj-sr">
{itinerary.map(d => <li key={d.day}>Day {d.day}: {d.location} — {d.theme}.</li>)}
</ol>

{slippy && <InteractiveTourMap geo={geo} itinerary={itinerary} tourTitle={tourTitle} onClose={() => setSlippy(false)} />}
</div>;
}

// Babel standalone transpiles these files in the browser and rewrites a literal
// import() into a CommonJS require shim, which throws "require is not defined".
// Building the import through the Function constructor hides it from Babel and
// leaves a genuine native dynamic import. Babel standalone already requires
// 'unsafe-eval' to run at all, so this adds no new constraint on the page.
const nativeImport = new Function('specifier', 'return import(specifier);');

// Fetched the first time someone opens the map, never on page load.
let maplibrePromise = null;
function loadMapLibre(){
if (maplibrePromise) return maplibrePromise;
if (!document.getElementById('maplibre-css')) {
const link = document.createElement('link');
link.id = 'maplibre-css';
link.rel = 'stylesheet';
link.href = MAPLIBRE_DIR + 'maplibre-gl.css';
document.head.appendChild(link);
}
maplibrePromise = nativeImport(MAPLIBRE_DIR + 'maplibre-gl.mjs').catch(e => {
maplibrePromise = null;
throw e;
});
return maplibrePromise;
}

// MapLibre renders through WebGL2. Everything else on the page degrades without
// it, so check before loading a megabyte of library that cannot draw anything.
function hasWebGL2(){
try {
const c = document.createElement('canvas');
return !!(window.WebGL2RenderingContext && c.getContext('webgl2'));
} catch (e) { return false; }
}

// A minimal raster style over OpenStreetMap, built inline so no style JSON is
// fetched from anywhere. Used when there is no MapTiler key configured, and as
// the fallback when a configured key turns out not to work.
function rasterStyle(){
return {
version: 8,
sources: {osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}},
layers: [{id: 'osm', type: 'raster', source: 'osm'}]
};
}

const maptilerConfig = () => (window.RLJ.geo && window.RLJ.geo.tiles) || {};

// Resolve the basemap style before the map is constructed, rather than handing
// MapLibre a URL and reacting if it fails.
//
// Handing it a URL is not recoverable: when the style request fails — an
// invalid, expired or origin-blocked key, or MapTiler being down — MapLibre v6
// emits nothing at all. No 'error', no 'styledata', no 'style.load'. Verified
// by attaching listeners for all of them to a map built on a bad key: six
// seconds, zero events. So there is no signal to fall back on, and the dialog
// would sit there as a blank map for ever.
//
// Fetching the style JSON first turns that silence into an ordinary rejected
// promise. The parsed object is handed straight to MapLibre, so it is not
// fetched twice.
// The request is aborted rather than merely awaited: a blocked or unreachable
// tile host does not refuse the connection, it hangs. Left alone the promise
// never settles and the dialog stays blank for ever, which is the same bug as
// before with a slower trigger. Measured here against an unreachable host: no
// rejection after 12 seconds.
const STYLE_TIMEOUT_MS = 5000;

async function resolveStyle(){
const t = maptilerConfig();
if (!t.maptilerKey) return {style: rasterStyle(), maptiler: false};
const url = `https://api.maptiler.com/maps/${t.maptilerStyle || 'outdoor-v2'}/style.json?key=${encodeURIComponent(t.maptilerKey)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), STYLE_TIMEOUT_MS);
try {
const res = await fetch(url, {credentials: 'omit', signal: controller.signal});
if (!res.ok) throw new Error('HTTP ' + res.status);
return {style: await res.json(), maptiler: true};
} catch (e) {
console.warn('MapTiler style unavailable, falling back to OpenStreetMap.', e);
return {style: rasterStyle(), maptiler: false};
} finally {
clearTimeout(timer);
}
}

// MapLibre takes [lng, lat]; geo.js stores [lat, lng].
const lngLat = ([lat, lng]) => [lng, lat];

// Paint per mode, matched against the 'mode' property on each leg feature.
// Mirrors MODE_STYLE above — the SVG and the interactive map share geo.js, so
// a mode must not change appearance between them. Literal hex because MapLibre
// cannot read CSS custom properties; the values are the resolved tokens.
// 'cap' is per mode, not derived from whether there is a dash: the coach
// pattern is a near-zero segment that only renders as a dot with round caps.
// With butt caps it disappears, and the coach legs would read as rail again —
// the thing the dash pattern exists to prevent.
const PAINT = {
'rail-operating': {color: '#A8532E', width: 4, dash: null, cap: 'round'},
'rail-closed': {color: '#A79D89', width: 7, dash: [2, 1.4], cap: 'butt'},
cycle: {color: '#748457', width: 3.5, dash: [3, 2], cap: 'butt'},
coach: {color: '#242019', width: 3, dash: [0.1, 2.6], cap: 'round'},
air: {color: '#736B5A', width: 2, dash: [3, 2.4], cap: 'butt'},
hsr: {color: '#A8532E', width: 4.5, dash: null, cap: 'round'},
'rail-conventional': {color: '#255172', width: 3.5, dash: null, cap: 'round'},
'mountain-rail': {color: '#4A4438', width: 3.5, dash: [3, 1.8], cap: 'butt'},
metro: {color: '#5C6B45', width: 3, dash: null, cap: 'round'},
lightrail: {color: '#A97C24', width: 3, dash: [2.4, 1.4], cap: 'butt'},
'airport-rail': {color: '#7CA3BC', width: 2.5, dash: [3, 2], cap: 'butt'}
};


// The design-system Dialog is fixed at 420px wide and has no Escape handling,
// which a map needs, so this is a local modal built from the same tokens.
function MapDialog({title, onClose, children}){
const panel = React.useRef(null);
const FOCUSABLE = 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])';
React.useEffect(() => {
const previous = document.activeElement;
const overflow = document.body.style.overflow;
// Tab must not reach the header, itinerary and footer behind the overlay, or
// a keyboard user can operate controls they cannot see.
const onKey = e => {
if (e.key === 'Escape') return onClose();
if (e.key !== 'Tab' || !panel.current) return;
const items = [...panel.current.querySelectorAll(FOCUSABLE)].filter(el => el.offsetParent !== null);
if (!items.length) return e.preventDefault();
const first = items[0], last = items[items.length - 1];
// The panel holds focus when the dialog opens. It is a boundary in both
// directions, or Shift+Tab straight after opening lands on the trigger
// behind the overlay.
if (!panel.current.contains(document.activeElement) || document.activeElement === panel.current) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
if (panel.current) panel.current.focus();
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = overflow;
if (previous && previous.focus) previous.focus();
};
}, [onClose]);
return <div onClick={e => {if (e.target === e.currentTarget) onClose();}}
style={{position: 'fixed', inset: 0, background: 'rgba(21,44,63,.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100, padding: 'var(--space-6)'}}>
<div ref={panel} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1}
style={{background: 'var(--surface-raised)', borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)', width: 1100, maxWidth: '100%', maxHeight: '100%', overflowY: 'auto', padding: 'var(--space-8)', outline: 'none'}}>
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-6)', marginBottom: 'var(--space-5)'}}>
<h2 style={{font: 'var(--text-headline)', color: 'var(--text-primary)', margin: 0}}>{title}</h2>
<Button variant="ghost" size="sm" onClick={onClose}>Close</Button>
</div>
{children}
</div>
</div>;
}

function InteractiveTourMap({geo, itinerary, tourTitle, onClose}){
const host = React.useRef(null);
const [error, setError] = React.useState(null);
// What the basemap is actually being drawn from, which is not the same as what
// is configured: a key that fails falls back to OpenStreetMap. The caption below
// reads this rather than the config, so it cannot credit a provider the
// visitor's browser never successfully contacted.
const [usingMapTiler, setUsingMapTiler] = React.useState(!!maptilerConfig().maptilerKey);

React.useEffect(() => {
if (!hasWebGL2()) {
setError('This browser does not support WebGL2, which the interactive map needs.');
return;
}
let map = null, cancelled = false;
Promise.all([loadMapLibre(), resolveStyle()]).then(([maplibregl, resolved]) => {
if (cancelled || !host.current) return;
setUsingMapTiler(resolved.maptiler);

// Frame the route rather than the island framing the SVG uses, so the tour
// fills the panel instead of floating in a lot of empty ocean.
const lngs = [], lats = [];
for (const leg of geo.legs) for (const k of leg.path) { lats.push(geo.places[k][0]); lngs.push(geo.places[k][1]); }
const routeBounds = [[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]];

map = new maplibregl.Map({
container: host.current,
style: resolved.style,
bounds: routeBounds,
fitBoundsOptions: {padding: 48},
scrollZoom: false,
attributionControl: {compact: false}
});
map.addControl(new maplibregl.NavigationControl({visualizePitch: false}), 'top-left');

// Runtime problems only — a failing basemap is already handled by
// resolveStyle before the map exists. Tile errors are not worth a dialog.
map.on('error', e => console.warn('Map error', (e && e.error) || e));

// 'style.load' rather than 'load': the latter also waits for the first tiles,
// so a tile outage or a blocked tile host would leave the route and the day
// markers invisible on an otherwise working map.
const drawRoute = () => {
if (cancelled) return;
map.addSource('route', {type: 'geojson', data: {
type: 'FeatureCollection',
features: geo.legs.map(leg => ({
type: 'Feature',
properties: {mode: leg.mode, label: leg.label},
geometry: {type: 'LineString', coordinates: leg.path.map(k => lngLat(geo.places[k]))}
}))
}});

// Same order as the SVG: corridors first, then what runs on them.
for (const mode of geo.drawOrder) {
const p = PAINT[mode];
map.addLayer({
id: 'leg-' + mode,
type: 'line',
source: 'route',
filter: ['==', ['get', 'mode'], mode],
layout: {'line-cap': p.cap, 'line-join': 'round'},
paint: Object.assign({
'line-color': p.color,
'line-width': p.width
}, p.dash ? {'line-dasharray': p.dash} : {})
});
}

// Numbered discs, styled to match the inline diagram.
for (const m of geo.markers) {
const el = document.createElement('button');
el.type = 'button';
el.textContent = markerLabel(m.days);
el.setAttribute('aria-label', `${m.days.length > 1 ? 'Days' : 'Day'} ${dayList(m.days)}, ${m.label}`);
el.style.cssText = 'width:34px;height:34px;border-radius:50%;border:2px solid #A8532E;background:#F6F1E7;color:#242019;font:600 14px/1 var(--font-mono),monospace;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0';
const days = m.days.map(day => {
const d = itinerary.find(x => x.day === day);
return d ? `Day ${d.day}: ${d.location} — ${d.theme}` : `Day ${day}`;
}).join('<br>');
new maplibregl.Marker({element: el})
.setLngLat(lngLat(geo.places[m.place]))
.setPopup(new maplibregl.Popup({offset: 22}).setHTML(`<strong>${m.label}</strong><br>${days}`))
.addTo(map);
}
};
if (map.isStyleLoaded()) drawRoute(); else map.once('style.load', drawRoute);
}).catch(e => !cancelled && setError(e.message));

return () => {cancelled = true; if (map) map.remove();};
}, [geo]);

return <MapDialog title={`${tourTitle} — interactive map`} onClose={onClose}>
{error
? <p style={{font: 'var(--text-body)', color: 'var(--text-secondary)', margin: 0}}>The interactive map could not be loaded ({error}). The route diagram on the page shows the same journey.</p>
: <div ref={host} style={{width: '100%', height: '62vh', minHeight: 360, background: 'var(--surface-sunken)', border: '1px solid var(--border-subtle)'}} />}
<p style={{font: 'var(--text-body-sm)', color: 'var(--text-tertiary)', marginTop: 'var(--space-4)', marginBottom: 0}}>
{usingMapTiler ? 'Basemap © MapTiler and OpenStreetMap contributors.' : 'Basemap © OpenStreetMap contributors.'} Alignments are indicative and drawn between named locations rather than surveyed from the railway centreline.
</p>
</MapDialog>;
}

Object.assign(window, {TourMap, InteractiveTourMap, MapDialog, markerLabel, dayList, MODE_STYLE, PAINT});
