psytask
Version:
JavaScript Framework for Psychology tasks
443 lines (435 loc) • 12.3 kB
TypeScript
import { EventEmitter, MaybeGenericComponent, Scene, SceneOptions } from '@psytask/core';
export * from '@psytask/core';
import { PropertiesHyphen } from 'csstype';
type LooseObject = Record<string, unknown>;
type Merge<T, U> = T extends unknown ? Omit<T, keyof U> & U : never;
type Split<T extends string, S extends string> = T extends unknown
? T extends `${infer L}${S}${infer R}`
? [L, ...Split<R, S>]
: [T]
: never;
declare global {
interface ObjectConstructor {
keys<T>(o: T): (keyof T & string)[];
}
interface String {
split<T extends string, S extends string>(
this: T,
separator: S,
): Split<T, S>;
}
interface Array<T> {
map<R>(fn: <E extends T>(e: E, i: number, arr: this) => R): R[];
}
interface Performance {
getEntriesByType(type: 'resource'): PerformanceResourceTiming[];
}
}
type Serializer<T extends LooseObject = LooseObject> = {
header: (row: T, rows: T[]) => string;
body: (row: T, rows: T[]) => string;
footer: (rows: T[]) => string;
};
declare const serializers: {
/** @see {@link https://www.rfc-editor.org/rfc/rfc4180 RFC-4180} */
csv: {
header: (row: LooseObject) => string;
body: (row: LooseObject) => string;
footer: () => string;
};
/** @see {@link https://www.json.org JSON} */
json: {
header: () => string;
body: (row: LooseObject, rows: LooseObject[]) => string;
footer: () => string;
};
};
declare class Collector<T extends LooseObject> extends EventEmitter<{
add: T;
chunk: string;
}> {
#private;
/** @default `data-${Date.now()}.csv` */
readonly filename: string;
/**
* Map of serializers by file extension
*
* You can add your own {@link Serializer} to this map.
*
* @example
*
* Add Markdown serializer
*
* ```ts
* Collector.serializers['md'] = {
* head: (row) => '', // generate header from the first row
* body: (row) => '', // generate body from each row
* tail: () => '', // generate footer
* };
* using dc = new Collector('data.md'); // now you can save to Markdown file
* ```
*/
static readonly serializers: typeof serializers & Record<string, Serializer>;
readonly rows: T[];
/**
* Collect, serialize and save data.
*
* Built-in supports for CSV and JSON formats. You can extend this by
* {@link Collector.serializers} or provide `serializer` parameter.
*/
constructor(
/** @default `data-${Date.now()}.csv` */
filename?: string, options?: {
/** @default true */
backup_on_leave?: boolean;
/**
* If not provided, a default {@link Serializer} based on the file
* extension will be used.
*/
serializer?: Serializer<T>;
});
/**
* Add a data row. For the default serializer, object fields will be
* serialized using {@link JSON.stringify}.
*
* @returns The total serialized data up to now.
*/
add(row: T): string;
/**
* Get the final serialized data.
*
* @example
*
* Call multiple times
*
* ```ts
* using dc = new Collector('test.csv');
*
* dc.add({ a: 1, b: 'hello' });
* dc.final() === 'a,b\n1,hello'; // true
*
* dc.add({ a: 2, b: 'world' });
* dc.final() === 'a,b\n1,hello\n2,world'; //true
* ```
*/
final(): string;
/** Download final serialized data */
download(suffix?: string): void;
}
type CloseEventMap = HTMLElementEventMap & {
[K in `mouse:${'left' | 'middle' | 'right' | 'unknown'}`]: MouseEvent;
} & {
[K in `key:${string}`]: KeyboardEvent;
};
type ExtendedSceneOptions = {
duration?: number;
close_on?: keyof CloseEventMap | (keyof CloseEventMap)[];
};
declare class App<T extends {
frame_ms: number;
} = {
frame_ms: number;
}> extends EventEmitter {
/** Root element of the app */
readonly root: HTMLElement;
/** Data will be collected automatically */
readonly data: T & LooseObject;
constructor(
/** Root element of the app */
root: HTMLElement,
/** Data will be collected automatically */
data: T & LooseObject);
/**
* 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 });
* dc.final(); // get final text
* dc.download(); // download data.csv
* ```
*
* Add listeners
*
* ```ts
* using dc = await app
* .collector('data.csv')
* .on('add', (row) => {
* console.log('add a row', row);
* })
* .on('chunk', (chunk) => {
* console.log('a chunk of raw is ready', chunk);
* });
* ```
*
* @see {@link Collector}
*/
collector<T extends LooseObject>(...e: ConstructorParameters<typeof Collector<T>>): Collector<T>;
/**
* Create a scene
*
* @example
*
* Create text scene
*
* ```ts
* const Component = (props: { text: string }, ctx: Scene<any>) => {
* const el = document.createElement('div');
* ctx.on('show', (props) => {
* el.textContent = props.text; // update element
* });
* return { node: el, data: () => ({ text: el.textContent }) }; // return element and data getter
* };
*
* // create scene
* using scene = app.scene(Component, {
* defaultProps: { text: 'default text' }, // default props is required
* close_on: 'key: ', // close when space is pressed
* duration: 100, // auto close after 100ms
* });
* // change props.text and show, then get data
* const data = await scene.show({ text: 'new text' });
* ```
*
* @see {@link Scene}
*/
scene<T extends MaybeGenericComponent>(...[component, opts]: ConstructorParameters<typeof Scene<T>> extends [
infer L,
infer R extends SceneOptions<T>
] ? [
L,
Pick<R, 'defaultProps' | 'adapter'> & Partial<Omit<R, 'defaultProps' | 'adapter'>> & ExtendedSceneOptions
] : never): Scene<T> & {
/** Change options one-time */
config(patchOptions: Partial<ExtendedSceneOptions>): Scene<T>;
};
}
/**
* Create app
*
* @example
*
* Basic usage
*
* ```ts
* using app = await createApp();
* using dc = app.collector();
* using fixation = app.scene(
* (props: {}) => {
* const node = document.createElement('div');
* node.textContent = '+';
* return node;
* },
* {
* defaultProps: {},
* duration: 500,
* },
* );
* ```
*
* @see {@link App} {@link App.scene}
*/
declare const createApp: ({ root, alert_on_leave, i18n, frames_count, frame_calcer, }?: Partial<{
/** @default document.createElement('div') */
root: HTMLElement;
/** @default true */
alert_on_leave: boolean;
i18n: {
/** Alert after leaving the page during the FPS detection */
leave_alert_on_fps: string;
/** Alert after leaving the page during the task */
leave_alert_on_task: string;
/** Alert before close or reload the page, not compatible with IOS */
beforeunload_alert: string;
};
/** @default 10 */
frames_count: number;
frame_calcer: (durations: number[]) => number;
}>) => Promise<App<{
frame_ms: number;
leave_count: number;
}>>;
/**
* @example
*
* Custom iterable builder
*
* ```ts
* const MyIterable = createIterableBuilder(function* (count: number) {
* for (let i = 0; i < count; i++) {
* const response: string = yield i + 1; // yield number; receive string
* }
* });
*
* const myIterable = MyIterable(3);
* for (const trial of myIterable) {
* // set current trial response to calculate next value
* myIterable.response(`Response to ${trial}`);
* }
* ```
*/
declare const createIterableBuilder: <T extends unknown[], G extends Generator<unknown, unknown, never>, P extends unknown[] = G extends Generator<infer Val, infer Data, infer Res> ? [Val, Data, Res] : never>(gen: (...e: T) => G) => (...e: T) => {
[Symbol.iterator]: () => {
next(): IteratorResult<P[0], P[1]>;
};
response(response: P[2]): void;
readonly data: P[1];
};
/**
* @example
*
* Basic usage
*
* ```ts
* for (const value of RandomSampling({
* candidates: [1, 2, 3],
* sample: 5,
* replace: true,
* })) {
* console.log(value);
* }
* ```
*/
declare const RandomSampling: <const T>(e_0: {
/** Anything to be sampled */
candidates: readonly T[];
/** Size of samples @default candidates.length */
sample?: number;
/** With or without replacement @default true */
replace?: boolean;
}) => {
[Symbol.iterator]: () => {
next(): IteratorResult<T, void>;
};
response(response: never): void;
readonly data: void;
};
/**
* It will use 1-down-1-up before the first reversal.
*
* @example
*
* Basic usage
*
* ```ts
* const staircase = StairCase({
* start: 0,
* step: 1,
* down: 3,
* up: 1,
* reversal: 3,
* min: 0,
* max: 3,
* });
* for (const value of staircase) {
* console.log(value);
* // set current trial response to calculate next value
* staircase.response(true);
* }
* // get data after iteration
* const threshold = staircase.data
* .filter((e) => e.reversal)
* .reduce((acc, e, i, arr) => acc + e.value / arr.length, 0);
* ```
*/
declare const StairCase: (e_0: {
/** Start value */
start: number;
/** Step size */
step: number;
/** Number of same trials before going down */
down: number;
/** Number of same trials before going up */
up: number;
/** Number of reversals */
reversals: number;
/** Max number of trials */
trials?: number;
/** Minimum value */
min?: number;
/** Maximum value */
max?: number;
}) => {
[Symbol.iterator]: () => {
next(): IteratorResult<number, {
value: number;
response: boolean;
reversal: boolean;
}[]>;
};
response(response: boolean): void;
readonly data: {
value: number;
response: boolean;
reversal: boolean;
}[];
};
/**
* CSS styles builder
*
* @example
*
* Basic usage
*
* ```ts
* const style = css({ 'background-color': 'red', 'font-size': '16px' });
* // "background-color:red;font-size:16px;"
* ```
*/
declare const css: (obj: PropertiesHyphen) => string;
type EventType<T extends EventTarget, U = keyof T> = U extends `on${infer K}` ? K : never;
/**
* Add event listener and return cleanup function
*
* @example
*
* Listen to window resize event
*
* ```ts
* const cleanup = on(window, 'resize', (e) => {});
* ```
*/
declare const on: <T extends EventTarget, K extends EventType<T>>(target: T, type: K, listener: (ev: `on${K}` extends infer P extends Extract<keyof T, string> ? //eslint-disable-next-line @typescript-eslint/no-explicit-any -- Required for inferring event handler parameters
T[P] & {} extends infer F extends (...args: any) => any ? Parameters<F>[0] : never : never) => void, options?: boolean | AddEventListenerOptions) => () => void;
/**
* Define default props
*
* @example
*
* Usage with component
*
* ```ts
* const Component = (props: { a?: number; b?: string }) => {
* const p = defaultProps(props, { a: 1, b: 'default' });
* // p.a is number, p.b is string
* return '';
* };
* ```
*/
declare const defaultProps: <T extends Record<string | symbol, unknown>, U extends Partial<T>>(props: T, defaults: U) => Merge<T, U>;
/**
* @example
*
* Basic usage
*
* ```ts
* const durations = await detectFPS({
* leave_alert: leave_alert_on_fps,
* frames_count,
* });
* const average_frame_ms =
* durations.reduce((a, b) => a + b) / durations.length;
* ```
*/
declare const detectFPS: (options: {
root: HTMLElement;
frames_count: number;
leave_alert: string;
}) => Promise<number[]>;
export { App, Collector, RandomSampling, StairCase, createApp, createIterableBuilder, css, defaultProps, detectFPS, on };
export type { CloseEventMap, Serializer };