UNPKG

psytask

Version:

JavaScript Framework for Psychology task

209 lines (196 loc) 6.22 kB
import type { Properties as CSSProperties } from 'csstype'; import { detect } from 'detect-browser'; import type { EventType, Merge } from '../types'; /** Creates a new HTML element quickly and easily */ export function h<K extends keyof HTMLElementTagNameMap>( tagName: K, props?: Partial<Merge<HTMLElementTagNameMap[K], { style?: CSSProperties }>>, children?: Node | string | (Node | string)[], ) { const el = document.createElement(tagName); if (typeof props !== 'undefined') { for (const key in props) { if (hasOwn(props, key)) { if (key === 'style') { for (const styleKey in props.style!) { if (hasOwn(props.style, styleKey)) { el.style[styleKey] = props.style[styleKey]!; } } } else { //@ts-ignore el[key] = props[key]!; } } } } if (typeof children !== 'undefined') { if (typeof children === 'string') { el.textContent = children; } else if (Array.isArray(children)) { el.append(...children); } else { el.appendChild(children); } } return el; } export function pipe<T>(...fns: ((v: T) => T)[]) { return (v: T) => fns.reduce((acc, fn) => fn(acc), v); } export function hasOwn<T, K extends PropertyKey>( obj: T, key: K, ): obj is K extends keyof T ? T : T & { [P in K]: unknown } { return Object.prototype.hasOwnProperty.call(obj, key); } export const promiseWithResolvers = ( hasOwn(Promise, 'withResolvers') && typeof Promise.withResolvers === 'function' ? Promise.withResolvers.bind(Promise) : function () { let resolve, reject; const promise = new Promise( (res, rej) => ((resolve = res), (reject = rej)), ); return { promise, resolve, reject }; } ) as <T>() => { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void; reject: (reason?: any) => void; }; export function proxy<T extends object>( obj: T, opts: { onNoKey?: (key: PropertyKey) => void; }, ) { return new Proxy(obj, { get(o, k) { if (hasOwn(o, k)) return o[k as keyof T]; opts.onNoKey?.(k); }, }); } //@ts-ignore Symbol.dispose ??= Symbol.for('Symbol.dispose'); /** * Most browsers do not support this feature, but using it is good practice. * * @see https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html */ export class DisposableClass implements Disposable { #cleanups: (() => void)[] = []; [Symbol.dispose]() { for (const task of this.#cleanups) task(); this.#cleanups.length = 0; } /** Add a cleanup function to be called on dispose */ addCleanup(cleanup: () => void) { this.#cleanups.push(cleanup); } /** * Add disposable event listener * * @example * this.useEventListener(window, 'resize', (e) => { * console.log('Window resized', e); * }); */ useEventListener<T extends EventTarget, K extends EventType<T>>( target: T, type: K, //@ts-ignore listener: (ev: Parameters<T[`on${K}`]>[0]) => void, options?: boolean | AddEventListenerOptions, ) { target.addEventListener(type, listener, options); this.addCleanup(() => target.removeEventListener(type, listener, options)); } } // stat export function mean_std(arr: number[]) { const n = arr.length; const mean = arr.reduce((acc, v) => acc + v) / n; const std = Math.sqrt( arr.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) / (n - 1), ); return { mean, std }; } // detect environment export function detectFPS(opts: { root: Element; framesCount: number }) { function checkPageVisibility() { if (document.visibilityState === 'hidden') { alert( 'Please keep the page visible on the screen during the FPS detection', ); location.reload(); } } document.addEventListener('visibilitychange', checkPageVisibility); let startTime = 0; const frameDurations: number[] = []; const el = opts.root.appendChild(h('p')); return new Promise<number>((resolve) => { window.requestAnimationFrame(function frame(lastTime) { if (startTime !== 0) { frameDurations.push(lastTime - startTime); } startTime = lastTime; const progress = frameDurations.length / opts.framesCount; el.textContent = `test fps ${Math.floor(progress * 100)}%`; if (progress < 1) { window.requestAnimationFrame(frame); return; } document.removeEventListener('visibilitychange', checkPageVisibility); // calculate average frame duration const { mean, std } = mean_std(frameDurations); const valids = (function filter(stdNum = 1): number[] { const temp = frameDurations.filter( (v) => mean - std * stdNum <= v && v <= mean + std * stdNum, ); return temp.length > 0 ? temp : filter(stdNum + 0.1); })(); console.log('detectFPS', { mean, std, valids }); resolve(valids.reduce((acc, v) => acc + v) / valids.length); }); }); } export async function detectEnvironment(options?: { /** The detection panel container */ root?: Element; /** Count of frames to calculate perFrame milliseconds */ framesCount?: number; }) { const opts = { root: document.body, framesCount: 60, ...options }; const panel = opts.root.appendChild( h('div', { style: { textAlign: 'center', lineHeight: '100dvh' } }), ); const ua = navigator.userAgent; const browser = detect(ua); if (!browser) { throw new Error('Cannot detect browser environment'); } const env = { ua, os: browser.os, browser: browser.name + '/' + browser.version, mobile: /Mobi/i.test(ua), 'in-app': /wv|in-app/i.test(ua), // webview or in-app browser screen_wh: [window.screen.width, window.screen.height], window_wh: (function () { const wh = [window.innerWidth, window.innerHeight]; window.addEventListener('resize', () => { wh[0] = window.innerWidth; wh[1] = window.innerHeight; }); return wh; })(), frame_ms: await detectFPS({ root: panel, framesCount: opts.framesCount }), } as const; opts.root.removeChild(panel); console.log('env', env); return env; }