Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in new issue
Block a user