UNPKG

psytask

Version:

JavaScript Framework for Psychology tasks

412 lines (405 loc) 12.8 kB
/** psytask v1.2.0 cubxx MIT */ import { createTimer, EventEmitter, Scene } from '@psytask/core'; export * from '@psytask/core'; const ERR = (msg) => { throw Error(msg); }; const $Object = Object; const modify = (a, b) => $Object.assign(a, b); const doc = document; const mount = (child, root = doc.body) => root.appendChild(child); const tags = new Proxy( {}, { get: (_, tag) => (props) => modify(doc.createElement(tag), props) } ); const clamp = (value, min, max) => value < min ? min : value > max ? max : value; const array_normalize = (e) => Array.isArray(e) ? e : [e]; const { div, a, style } = tags; const css = (obj) => $Object.entries(obj).reduce((acc, [key, val]) => acc + `${key}:${val};`, ""); const on = (target, type, listener, options) => (target.addEventListener(type, listener, options), () => target.removeEventListener(type, listener, options)); const onPageLeave = (fn) => on(doc, "visibilitychange", () => doc.hidden && fn()); const defaultProps = (props, defaults) => new Proxy(props, { get: (target, prop) => target[prop] ?? defaults[prop] }); const detectFPS = async (options) => { const el = mount( div({ className: "psytask-scene psytask-center" }), options.root ); const cleanup = onPageLeave(() => (alert(options.leave_alert), history.go())); const records = await createTimer((records2) => { const progress = records2.length / (options.frames_count + 1); el.innerText = `Detecting FPS... ${(progress * 100).toFixed(0)}%`; return progress >= 1; }).start(); el.remove(); cleanup(); return records.map((t, i, arr) => i > 0 ? t - arr[i - 1] : 0).slice(1); }; const csv_normalize = (value) => { if (value == null) return ""; const text = typeof value === "object" ? JSON.stringify(value) : value + ""; return /[,"\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; }; const serializers = { /** @see {@link https://www.rfc-editor.org/rfc/rfc4180 RFC-4180} */ csv: { header: (row) => $Object.keys(row).reduce((acc, key, i) => acc + (i ? "," : "") + csv_normalize(key), ""), body: (row) => $Object.values(row).reduce( (acc, value, i) => acc + (i ? "," : "\n") + csv_normalize(value), "" ), footer: () => "" }, /** @see {@link https://www.json.org JSON} */ json: { header: () => "[", body: (row, rows) => (rows.length ? "," : "") + JSON.stringify(row), footer: () => "]" } }; class Collector extends EventEmitter { /** * 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(filename = `data-${Date.now()}.csv`, options) { super(); this.filename = filename; const match = filename.match(/\.([^.]+)$/); const extname = match ? match[1] : ERR(`Can't detect extension from "${filename}".`); if (options?.serializer) { this.#serializer = options.serializer; } else { const extnames = $Object.keys(serializers); this.#serializer = extnames.includes(extname) ? serializers[extname] : ERR( `Unsupported file extension: "${extname}", please use one of: ${extnames.join(", ")}. Or add custom Serializer to Collector.serializers.` ); } if (options?.backup_on_leave ?? true) { this.on( "dispose", // backup when the page is hidden onPageLeave(() => this.download(`.${Date.now()}.bak`)) ); } } /** * 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 serializers = serializers; rows = []; #serializer; #temp = ""; /** * 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) { this.emit("add", row); const { rows } = this; const chunk = (this.#temp ? "" : this.#serializer.header(row, rows)) + this.#serializer.body(row, rows); rows.push(row); return this.emit("chunk", chunk).#temp += chunk; } /** * 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() { const chunk = this.#temp ? this.#serializer.footer(this.rows) : ""; return this.emit("chunk", chunk).#temp + chunk; } /** Download final serialized data */ download(suffix = "") { const output = this.final(); if (!output) return; const url = URL.createObjectURL(new Blob([output], { type: "text/plain" })); const el = mount(a({ download: this.filename + suffix, href: url })); el.click(); URL.revokeObjectURL(url); el.remove(); } } mount(style(), doc.head).innerText = `.psytask-scene{${`all:unset;position:fixed;inset:0;overflow:hidden;`}}.psytask-center{${`display:flex;flex-direction:column;align-items:center;justify-content:center;white-space:pre-wrap;height:100%;`}}`; const mouseSuffixs = ["left", "middle", "right"]; const prefix2type = { key: "keydown", mouse: "mousedown" }; class App extends EventEmitter { constructor(root, data) { super(); this.root = root; this.data = data; this.on("dispose", () => root.remove()); } /** * 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(...e) { return new Collector(...e).on("add", (row) => modify(row, this.data)); } /** * 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(...[component, opts]) { const timer_condition = (records) => opts.duration != null && records[records.length - 1] - records[0] > opts.duration - this.data.frame_ms * 1.5; const options = { root: mount( div({ className: "psytask-scene", oncontextmenu: (e) => e.preventDefault() }), this.root ), timer: () => createTimer(timer_condition), ...opts }; const scene = new Scene(component, options).on( "close", () => $Object.keys(opts).map((key) => opts[key] = options[key]) ); const close = () => scene.close(); return modify(scene, { /** Change options one-time */ config(patchOptions) { modify(opts, patchOptions); return scene; } }).on("show", () => { if (opts.close_on == null) return; const close_ons = array_normalize(opts.close_on); const close_on_set = new Set(close_ons); let hasKeyType = 0, hasMouseType = 0; const cleanups = close_ons.map((type) => { const DOM_type = prefix2type[type.split(":")[0]] ?? type; return DOM_type === "keydown" ? !hasKeyType++ && on(options.root, DOM_type, (e) => { (close_on_set.has(DOM_type) || close_on_set.has(`key:${e.key}`)) && close(); }) : DOM_type === "mousedown" ? !hasMouseType++ && on(options.root, DOM_type, (e) => { (close_on_set.has(DOM_type) || close_on_set.has( `mouse:${mouseSuffixs[e.button] ?? "unknown"}` )) && close(); }) : on( options.root, DOM_type, close ); }); scene.once("close", () => cleanups.map((fn) => fn && fn())); }); } } const createApp = async ({ root = mount(div()), alert_on_leave = true, i18n = { leave_alert_on_fps: "Please DON'T leave the page during the FPS detection!", leave_alert_on_task: "Please DON'T leave the page during the task!", beforeunload_alert: "Your progress will be lost. Are you sure?" }, frames_count = 10, frame_calcer = (durations) => { const sorted = [...durations].sort((a, b) => a - b); const Q1_idx = sorted.length / 4; const Q1 = sorted[Math.floor(Q1_idx)]; const Q3 = sorted[Math.floor(Q1_idx * 3)]; const valid_durations = durations.filter((d) => Q1 <= d && d <= Q3); const frame_ms = valid_durations.reduce((a, b) => a + b) / valid_durations.length; console.info("Detect fps", frame_ms, { durations, Q1, Q3, valid_durations }); return frame_ms; } } = {}) => { const data = { // detect fps frame_ms: frame_calcer( await detectFPS({ root, leave_alert: i18n.leave_alert_on_fps, frames_count }) ), leave_count: 0 }; const cleanups = [ onPageLeave( () => (++data.leave_count, alert_on_leave && alert(i18n.leave_alert_on_task)) ), on( window, "beforeunload", (e) => alert_on_leave && (e.preventDefault(), e.returnValue = i18n.beforeunload_alert) ) ]; return new App(root, data).on("dispose", () => cleanups.map((f) => f())); }; const createIterableBuilder = (gen) => (...e) => { let _response, _data, _done = 0; const generator = gen(...e); return { [Symbol.iterator]: () => ({ next() { if (_done) ERR("Iterator already done"); const r = generator.next(_response); r.done && (_data = r.value, _done = 1); return r; } }), response(response) { _response = response; }, get data() { if (!_done) ERR("Iterator not done yet"); return _data; } }; }; const RandomSampling = createIterableBuilder(function* ({ candidates, sample = candidates.length, replace = true }) { const cands = [...candidates]; const len = cands.length; if (!replace && sample > len) ERR(`Sample size should be <= ${len} without replacement`); while (cands.length && sample--) { const idx = Math.floor(Math.random() * cands.length); yield cands[idx]; if (!replace) cands.splice(idx, 1); } }); const StairCase = createIterableBuilder(function* ({ start, step, down, up, reversals, trials = Infinity, max = Infinity, min = -Infinity }) { const data = []; while (true) { const trial_num = data.length; const current_reversal_num = data.filter((e) => e.reversal).length; if (current_reversal_num >= reversals || trial_num >= trials) break; let value; const prev = data[trial_num - 1]; if (!prev) value = start; else { const prev_value = value = prev.value; if (!current_reversal_num) value += prev.response ? -step : step; else { if (trial_num >= down && data.slice(-down).every((e) => e.value === prev_value && e.response)) value -= step; if (trial_num >= up && data.slice(-up).every((e) => e.value === prev_value && !e.response)) value += step; } } value = clamp(value, min, max); const response = yield value; typeof response !== "boolean" && ERR("StairCase iterator requires boolean response"); data.push({ value, response, reversal: (prev?.response ?? response) !== response }); } return data; }); export { App, Collector, RandomSampling, StairCase, createApp, createIterableBuilder, css, defaultProps, detectFPS, on };