psytask
Version:
JavaScript Framework for Psychology task
362 lines (342 loc) • 10.3 kB
text/typescript
import type { Data, MaybePromise } from '../types';
import { TextStim } from './components';
import { DataCollector } from './data-collector';
import { effect, reactive } from './reactive';
import { Scene, type SceneFunction, type SceneOptions } from './scene';
import { EventEmitter, h, on } from './util';
export class App extends EventEmitter<{}> {
readonly data = {
/** Frame duration */
frame_ms: 16.67,
/** Number of times the user has left the page */
leave_count: 0,
/** Device pixel ratio */
dpr: window.devicePixelRatio,
/** Screen physical size */
screen_wh_pix: [window.screen.width, window.screen.height] as const,
/** Window physical size */
window_wh_pix: [window.innerWidth, window.innerHeight] as const,
};
constructor(
/** Root element of the app */
public root: Element,
) {
super();
this.data = reactive(this.data);
effect(() => {
const dpr = this.data.dpr;
Object.assign(this.data, {
screen_wh_pix: [window.screen.width * dpr, window.screen.height * dpr],
window_wh_pix: [window.innerWidth * dpr, window.innerHeight * dpr],
});
});
// check styles
if (
window.getComputedStyle(this.root).getPropertyValue('--psytask') === ''
) {
// TODO: show on screen
throw new Error('Please import psytask CSS file in your HTML file');
}
// warn before unloading the page, not compatible with IOS
this.on(
'cleanup',
on(window, 'beforeunload', (e) => {
e.preventDefault();
return (e.returnValue =
'Leaving the page will discard progress. Are you sure?');
}),
)
// alert when the page is hidden
.on(
'cleanup',
on(document, 'visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.data.leave_count++;
window.setTimeout(() =>
alert(
'Please keep the page visible on the screen during the task running',
),
);
}
}),
)
// update device pixel ratio on resolution change
.on(
'cleanup',
(() => {
let cleanup: () => void;
effect(() => {
cleanup?.();
cleanup = on(
window.matchMedia(`(resolution: ${this.data.dpr}dppx)`),
'change',
() => (this.data.dpr = window.devicePixelRatio),
);
});
return () => cleanup();
})(),
)
// update window size on resize
.on(
'cleanup',
on(window, 'resize', () => {
const dpr = this.data.dpr;
this.data.window_wh_pix = [
window.innerWidth * dpr,
window.innerHeight * dpr,
];
}),
)
// show last message
.on('cleanup', () => {
this.root.appendChild(
h(
'div',
{ className: 'psytask-center' },
'Thanks for participating!',
),
);
});
}
/**
* Load resources to RAM
*
* It will show loading progress for each resource on page
*
* @example Load web medias
*
* ```ts
* const [image, audio] = await app.load(['image.png', 'audio.mp3']);
* ```
*
* @example Convert image blob to bitmap
*
* ```ts
* const [image] = await app.load(['image.png'], (blob, url) => {
* console.log('Convert image from', url);
* return window.createImageBitmap(blob);
* });
* ```
*
* @param urls - Array of resource URLs to load
* @param convertor - Optional function to convert blob data
* @returns Promise that resolves to array of loaded resources
*/
async load<const T extends readonly string[], D = Blob>(
urls: T,
convertor?: (blob: Blob, url: string) => MaybePromise<D>,
) {
const container = this.root.appendChild(TextStim({ children: '' }).node);
const tasks = urls.map(async (url) => {
const link = h('a', { href: url, target: '_blank' }, url);
const el = container.appendChild(
h('p', { title: url }, ['Fetch ', link, '...']),
);
try {
const res = await fetch(url);
if (res.body == null) {
throw new Error('no response body');
}
// no progress
const totalStr = res.headers.get('Content-Length');
if (totalStr == null) {
console.warn(`Failed to get content length for ${url}`);
el.replaceChildren('Loading', link, '...');
return res.blob();
}
const total = +totalStr;
// show progress
const reader = res.body.getReader();
const chunks = [];
for (let loaded = 0; ; ) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
el.replaceChildren(
'Loading',
link,
`... ${((loaded / total) * 100).toFixed(2)}%`,
);
chunks.push(value);
}
const blob = new Blob(chunks);
return convertor ? convertor(blob, url) : blob;
} catch (err) {
el.style.color = '#000';
el.replaceChildren('Failed to load', link, `: ${err}`);
// wait forever
await new Promise(() => {});
}
});
const datas = await Promise.all(tasks);
this.root.removeChild(container);
return datas as { [K in keyof T]: D };
}
/**
* Create data collector
*
* @example Basic usage
*
* ```ts
* using dc = await app.collector('data.csv');
* dc.add({ name: 'Alice', age: 25 });
* dc.add({ name: 'Bob', age: 30 });
* ```
*
* @example Custom hooks
*
* ```ts
* using dc = await app
* .collector('data.csv')
* .on('add', ({ row, chunk }) => {
* console.log('add a row', row, 'its chunk is', chunk);
* })
* .on('save', ({ chunk, preventDefault }) => {
* preventDefault(); // Prevent the default save behavior
* console.log('save all rows, the final chunk is', chunk);
* });
* ```
*
* @see {@link DataCollector}
*/
collector<T extends Data>(
...e: ConstructorParameters<typeof DataCollector<T>>
) {
return new DataCollector<T>(...e);
}
/**
* Create a scene
*
* @example Creating a text scene
*
* ```ts
* const setup = (props: { text: string }, ctx) => {
* const el = h('div'); // create element
* effect(() => {
* el.textContent = props.text; // update element content when props.text changes
* });
* return { node: el, data: () => ({ text: el.textContent }) }; // return element and data getter
* };
*
* // create scene by setup function
* using scene = app.scene(setup, {
* defaultProps: { text: 'default text' },
* });
* // change props.text and show, then get data
* const data = await scene.show({ text: 'new text' });
* ```
*
* @see {@link Scene}
*/
scene<T extends SceneFunction>(
...e: ConstructorParameters<typeof Scene<T>> extends [infer L, ...infer R]
? R
: never
) {
return new Scene(this, ...e);
}
/**
* Create a text scene
*
* @param content - Optional text content to display
* @param defaultOptions - Optional default scene options
* @see {@link TextStim}
*/
text(
content?: string,
defaultOptions?: Partial<SceneOptions<typeof TextStim>>,
) {
return this.scene(TextStim, {
defaultProps: { children: content, ...defaultOptions?.defaultProps },
...defaultOptions,
});
}
}
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 };
}
/** @ignore */
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 = frameDurations.filter(
(v) => mean - std * 2 <= v && v <= mean + std * 2,
);
if (valids.length < 1) {
throw new Error('No valid frames found');
}
const frame_ms = valids.reduce((acc, v) => acc + v) / valids.length;
console.info('detectFPS', {
mean,
std,
valids,
raws: frameDurations,
frame_ms,
});
resolve(frame_ms);
});
});
}
/**
* Create app
*
* @example
*
* ```ts
* using app = await createApp();
* using dc = app.collector();
* using fixation = app.text('+', { duration: 500 });
* using guide = app.text('Welcome to the task!', { close_on: 'key: ' });
* ```
*
* @see {@link App}
*/
export const createApp = async (options?: Parameters<typeof detectFPS>[0]) => {
const opts = {
root: document.body,
framesCount: 60,
...options,
};
if (!opts.root.isConnected) {
console.warn(
'Root element is not connected to the document, it will be mounted to document.body',
);
document.body.appendChild(opts.root);
}
const app = new App(opts.root);
const panel = h('div', { className: 'psytask-center' });
opts.root.appendChild(panel);
app.data.frame_ms = await detectFPS({ ...opts, root: panel });
opts.root.removeChild(panel);
return app;
};