199 lines
7.2 KiB
JavaScript
199 lines
7.2 KiB
JavaScript
/**
|
|
* 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 };
|