/** * Full screen for the trading-network graph. * * What goes full screen is the graph's own box: its header (caption, subtext, * spread and full-screen buttons), the canvas and the time-lapse panel - not * the page around it. Esc leaves it; that is the browser's own handling. * * In full screen the header floats over the canvas (see the markup), and it * and the playback panel fade out after IDLE_MS without activity, so the graph * has the whole screen. Any pointer movement, tap, drag, wheel or key brings * them back for another IDLE_MS. */ const IDLE_MS = 2000; /** * @param {object} opts * @param {HTMLElement} opts.screenBox The box that goes full screen. * @param {HTMLElement} opts.frameBox The graph's box; fills the screen (by CSS). * @param {HTMLElement} opts.headerBox The header row that hides. * @param {HTMLElement} opts.button The full-screen toggle. * @param {Function} opts.panel -> the playback panel, or null. * @param {AbortSignal} opts.signal Removes every listener on rebuild. * @param {Function} opts.onResize (inFullscreen, headerHeight) after a switch. */ function setupFullscreen({ screenBox, frameBox, headerBox, button, panel, signal, onResize }) { if (!screenBox || !frameBox || !button) { return; } // Prefixed for older Safari; absent altogether on iPhone, where only video // can go full screen - the button then goes rather than doing nothing. const enter = screenBox.requestFullscreen || screenBox.webkitRequestFullscreen; const leave = document.exitFullscreen || document.webkitExitFullscreen; if (!enter || !leave) { button.hidden = true; return; } const active = () => document.fullscreenElement || document.webkitFullscreenElement || null; const options = { capture: true, passive: true, signal }; let idleTimer = null; // What the pointer is doing, for busy(). let pointerType = 'mouse'; let pressed = false; let lastPoint = null; // Whether the pointer's last known position lies on an element. // // By position rather than ":hover": hidden controls take no pointer // events, so a mouse that wakes them by moving onto them and then rests // there never makes them ":hover" - they hid from under it. const under = (el) => { if (!el || !lastPoint) { return false; } const r = el.getBoundingClientRect(); return lastPoint.x >= r.left && lastPoint.x <= r.right && lastPoint.y >= r.top && lastPoint.y <= r.bottom; }; const busy = () => { if (pressed) { // Mid-drag: a slider, or panning the graph. return true; } // Kept up while the mouse rests on them. Mouse only: a finger lifted // after a tap is not resting anywhere, and the last tap would // otherwise keep them up for ever. // // Focus deliberately does NOT count. Clicking the full-screen button // leaves it focused inside the header, and treating focus as "in use" // meant the controls never hid at all. return pointerType === 'mouse' && [headerBox, panel ? panel() : null].some(under); }; const scheduleHide = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { if (active() !== screenBox) { return; } if (busy()) { scheduleHide(); return; } screenBox.classList.add('controls-hidden'); }, IDLE_MS); }; const wake = () => { if (active() !== screenBox) { return; } screenBox.classList.remove('controls-hidden'); scheduleHide(); }; // Capture phase on the document: the canvas handles pointer events // itself, and a drag on it must still count as movement. On the document // rather than the screen box so that nothing inside the box - whatever // element ends up under the pointer after a rebuild - can keep the // controls from coming back; wake() itself ignores anything while the box // is not full screen. mousemove as well, for browsers that leave out // pointer events in some cases. ['pointermove', 'mousemove', 'pointerdown', 'touchstart', 'wheel', 'keydown'].forEach((type) => { document.addEventListener(type, wake, options); }); // A rebuild aborts these listeners and sets up afresh. A hide still // pending from the old setup would otherwise fire later and hide the // controls behind the new one's back. if (signal) { signal.addEventListener('abort', () => clearTimeout(idleTimer), { once: true }); } screenBox.addEventListener('pointerdown', (event) => { pressed = true; pointerType = event.pointerType || 'mouse'; lastPoint = { x: event.clientX, y: event.clientY }; }, options); screenBox.addEventListener('pointermove', (event) => { pointerType = event.pointerType || pointerType; lastPoint = { x: event.clientX, y: event.clientY }; }, options); // Gone from the screen box altogether (another window, the taskbar). screenBox.addEventListener('pointerleave', () => { lastPoint = null; }, { signal }); // On the document: a drag can end outside the box. Waking again on // release restarts the countdown from the end of the drag, not its start. ['pointerup', 'pointercancel'].forEach((type) => { document.addEventListener(type, () => { if (pressed) { pressed = false; wake(); } }, options); }); button.addEventListener('click', (event) => { event.preventDefault(); const result = active() === screenBox ? leave.call(document) : enter.call(screenBox); if (result && typeof result.catch === 'function') { result.catch(() => {}); } }, { signal }); const onChange = () => { const on = active() === screenBox; const label = on ? button.dataset.labelExit : button.dataset.labelEnter; button.setAttribute('title', label); button.setAttribute('aria-label', label); button.querySelector('[data-fullscreen-enter]')?.classList.toggle('hidden', on); button.querySelector('[data-fullscreen-exit]')?.classList.toggle('hidden', !on); // The layout itself - the header floating over the canvas, the graph // taking the whole screen - is pure CSS on :fullscreen (the // group-fullscreen variant in the markup). Not a class or inline // height set here: the time-lapse settings are wire:model.live, and // the morph after each one reset both to the server's markup while // the box stayed full screen, leaving the graph at its normal height. screenBox.classList.remove('controls-hidden'); clearTimeout(idleTimer); if (on) { scheduleHide(); } if (onResize) { requestAnimationFrame(() => onResize(on, headerBox ? headerBox.offsetHeight : 0)); } }; document.addEventListener('fullscreenchange', onChange, { signal }); document.addEventListener('webkitfullscreenchange', onChange, { signal }); } export { setupFullscreen, IDLE_MS };