UNPKG

psytask

Version:

JavaScript Framework for Psychology task

803 lines (785 loc) 25.8 kB
/** * Psytask v1.0.0-rc1 * @author cubxx * @license MIT */ var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __toESM = (mod, isNodeMode, target) => { target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: () => mod[key], enumerable: true }); return to; }; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); // ../../node_modules/auto-bind/index.js var require_auto_bind = __commonJS((exports, module) => { var getAllProperties = (object) => { const properties = new Set; do { for (const key of Reflect.ownKeys(object)) { properties.add([object, key]); } } while ((object = Reflect.getPrototypeOf(object)) && object !== Object.prototype); return properties; }; module.exports = (self, { include, exclude } = {}) => { const filter = (key) => { const match = (pattern) => typeof pattern === "string" ? key === pattern : pattern.test(key); if (include) { return include.some(match); } if (exclude) { return !exclude.some(match); } return true; }; for (const [object, key] of getAllProperties(self.constructor.prototype)) { if (key === "constructor" || !filter(key)) { continue; } const descriptor = Reflect.getOwnPropertyDescriptor(object, key); if (descriptor && typeof descriptor.value === "function") { self[key] = self[key].bind(self); } } return self; }; }); // src/jspsych.ts var import_auto_bind2 = __toESM(require_auto_bind(), 1); // ../../node_modules/jspsych/src/modules/plugin-api/KeyboardListenerAPI.ts var import_auto_bind = __toESM(require_auto_bind(), 1); class KeyboardListenerAPI { getRootElement; areResponsesCaseSensitive; minimumValidRt; constructor(getRootElement, areResponsesCaseSensitive = false, minimumValidRt = 0) { this.getRootElement = getRootElement; this.areResponsesCaseSensitive = areResponsesCaseSensitive; this.minimumValidRt = minimumValidRt; import_auto_bind.default(this); this.registerRootListeners(); } listeners = new Set; heldKeys = new Set; areRootListenersRegistered = false; registerRootListeners() { if (!this.areRootListenersRegistered) { const rootElement = this.getRootElement(); if (rootElement) { rootElement.addEventListener("keydown", this.rootKeydownListener); rootElement.addEventListener("keyup", this.rootKeyupListener); this.areRootListenersRegistered = true; } } } rootKeydownListener(e) { for (const listener of [...this.listeners]) { listener(e); } this.heldKeys.add(this.toLowerCaseIfInsensitive(e.key)); } toLowerCaseIfInsensitive(string) { return this.areResponsesCaseSensitive ? string : string.toLowerCase(); } rootKeyupListener(e) { this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key)); } isResponseValid(validResponses, allowHeldKey, key) { if (!allowHeldKey && this.heldKeys.has(key)) { return false; } if (validResponses === "ALL_KEYS") { return true; } if (validResponses === "NO_KEYS") { return false; } return validResponses.includes(key); } getKeyboardResponse({ callback_function, valid_responses = "ALL_KEYS", rt_method = "performance", persist, audio_context, audio_context_start_time, allow_held_key = false, minimum_valid_rt = this.minimumValidRt }) { if (rt_method !== "performance" && rt_method !== "audio") { console.log('Invalid RT method specified in getKeyboardResponse. Defaulting to "performance" method.'); rt_method = "performance"; } const usePerformanceRt = rt_method === "performance"; const startTime = usePerformanceRt ? performance.now() : audio_context_start_time * 1000; this.registerRootListeners(); if (!this.areResponsesCaseSensitive && typeof valid_responses !== "string") { valid_responses = valid_responses.map((r) => r.toLowerCase()); } const listener = (e) => { const rt = Math.round((rt_method == "performance" ? performance.now() : audio_context.currentTime * 1000) - startTime); if (rt < minimum_valid_rt) { return; } const key = this.toLowerCaseIfInsensitive(e.key); if (this.isResponseValid(valid_responses, allow_held_key, key)) { e.preventDefault(); if (!persist) { this.cancelKeyboardResponse(listener); } callback_function({ key: e.key, rt }); } }; this.listeners.add(listener); return listener; } cancelKeyboardResponse(listener) { this.listeners.delete(listener); } cancelAllKeyboardResponses() { this.listeners.clear(); } compareKeys(key1, key2) { if (typeof key1 !== "string" && key1 !== null || typeof key2 !== "string" && key2 !== null) { console.error("Error in jsPsych.pluginAPI.compareKeys: arguments must be key strings or null."); return; } if (typeof key1 === "string" && typeof key2 === "string") { return this.areResponsesCaseSensitive ? key1 === key2 : key1.toLowerCase() === key2.toLowerCase(); } return key1 === null && key2 === null; } } // ../../node_modules/jspsych/src/modules/plugin-api/TimeoutAPI.ts class TimeoutAPI { timeout_handlers = []; setTimeout(callback, delay) { const handle = window.setTimeout(callback, delay); this.timeout_handlers.push(handle); return handle; } clearAllTimeouts() { for (const handler of this.timeout_handlers) { clearTimeout(handler); } this.timeout_handlers = []; } } // ../../node_modules/jspsych/src/modules/plugins.ts var ParameterType; ((ParameterType2) => { ParameterType2[ParameterType2["BOOL"] = 0] = "BOOL"; ParameterType2[ParameterType2["STRING"] = 1] = "STRING"; ParameterType2[ParameterType2["INT"] = 2] = "INT"; ParameterType2[ParameterType2["FLOAT"] = 3] = "FLOAT"; ParameterType2[ParameterType2["FUNCTION"] = 4] = "FUNCTION"; ParameterType2[ParameterType2["KEY"] = 5] = "KEY"; ParameterType2[ParameterType2["KEYS"] = 6] = "KEYS"; ParameterType2[ParameterType2["SELECT"] = 7] = "SELECT"; ParameterType2[ParameterType2["HTML_STRING"] = 8] = "HTML_STRING"; ParameterType2[ParameterType2["IMAGE"] = 9] = "IMAGE"; ParameterType2[ParameterType2["AUDIO"] = 10] = "AUDIO"; ParameterType2[ParameterType2["VIDEO"] = 11] = "VIDEO"; ParameterType2[ParameterType2["OBJECT"] = 12] = "OBJECT"; ParameterType2[ParameterType2["COMPLEX"] = 13] = "COMPLEX"; ParameterType2[ParameterType2["TIMELINE"] = 14] = "TIMELINE"; })(ParameterType ||= {}); // src/util.ts import { detect } from "detect-browser"; function h(tagName, props, children) { 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 { 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; } function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); } var 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 }; }; function proxy(obj, opts) { return new Proxy(obj, { get(o, k) { if (hasOwn(o, k)) return o[k]; opts.onNoKey?.(k); } }); } Symbol.dispose ??= Symbol.for("Symbol.dispose"); class DisposableClass { #cleanups = []; [Symbol.dispose]() { for (const task of this.#cleanups) task(); this.#cleanups.length = 0; } addCleanup(cleanup) { this.#cleanups.push(cleanup); } useEventListener(target, type, listener, options) { target.addEventListener(type, listener, options); this.addCleanup(() => target.removeEventListener(type, listener, options)); } } function mean_std(arr) { 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 }; } function detectFPS(opts) { 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 = []; const el = opts.root.appendChild(h("p")); return new Promise((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); const { mean, std } = mean_std(frameDurations); const valids = function filter(stdNum = 1) { 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); }); }); } async function detectEnvironment(options) { 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), 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 }) }; opts.root.removeChild(panel); console.log("env", env); return env; } // src/jspsych.ts if (process.env.NODE_ENV === "production") { window["jsPsychModule"] ??= { ParameterType }; } function createSceneByJsPsychPlugin(app, trial) { const Plugin = trial.type; if (typeof Plugin !== "function" || typeof Plugin.prototype === "undefined" || typeof Plugin.info === "undefined") { const msg = "jsPsych trial.type only supports jsPsych class plugins"; console.warn(msg + ", but got", Plugin); const scene2 = app.text(msg); return scene2; } if (process.env.NODE_ENV === "development") { const unsupporteds = new Set([ "extensions", "record_data", "save_timeline_variables", "save_trial_parameters", "simulation_options" ]); for (const key in trial) { if (hasOwn(trial, key) && unsupporteds.has(key)) { console.warn(`jsPsych trial "${key}" parameter is not supported`); } } } for (const key in Plugin.info.parameters) { if (!hasOwn(trial, key)) { trial[key] = Plugin.info.parameters[key].default; } } const mock_jsPsychPluginAPI = [ new KeyboardListenerAPI(() => document.body), new TimeoutAPI ].reduce((api, item) => Object.assign(api, import_auto_bind2.default(item)), {}); const mock_jsPsych = { finishTrial(data) { trial.on_finish?.(Object.assign(scene.data, trial.data, data)); if (typeof trial.post_trial_gap === "number") { window.setTimeout(() => scene.close(), trial.post_trial_gap); } else { scene.close(); } }, pluginAPI: process.env.NODE_ENV === "production" ? mock_jsPsychPluginAPI : proxy(mock_jsPsychPluginAPI, { onNoKey(key) { console.warn(`jsPsych.pluginAPI.${key.toString()} is not supported, only supports: ${Object.keys(mock_jsPsychPluginAPI).join(", ")}`); } }) }; const plugin = new Plugin(process.env.NODE_ENV === "production" ? mock_jsPsych : proxy(mock_jsPsych, { onNoKey(key) { console.warn(`jsPsych.${key.toString()} is not supported, only supports: ${Object.keys(mock_jsPsych).join(", ")}`); } })); const scene = app.scene(function(self) { const content = h("div", { id: "jspsych-content", className: "jspsych-content" }); self.root.appendChild(h("div", { className: "jspsych-display-element", style: { height: "100%", width: "100%" } }, h("div", { className: "jspsych-content-wrapper" }, content))); trial.on_start?.(trial); const classes = trial.css_classes; if (typeof classes === "string") { content.classList.add(classes); } else if (Array.isArray(classes)) { content.classList.add(...classes); } plugin.trial(content, trial, () => { trial.on_load?.(); }); return () => {}; }); return scene; } // src/scene.ts class Scene extends DisposableClass { app; options; root = h("div"); data = { start_time: 0 }; update; #isShown = true; #showPromiseWithResolvers; constructor(app, setup, options = {}) { super(); this.app = app; this.options = options; this.close(); this.update = setup(this); this.addCleanup(() => this.app.root.removeChild(this.root)); const closeKeys = typeof options.close_on === "undefined" ? [] : typeof options.close_on === "string" ? [options.close_on] : options.close_on; const closeFn = this.close.bind(this); for (const key of closeKeys) { this.useEventListener(this.root, key, closeFn); } } config(options) { Object.assign(this.options, options); return this; } close() { if (!this.#isShown) { console.warn("Scene is already closed"); return; } this.#isShown = false; this.root.style.transform = "scale(0)"; this.#showPromiseWithResolvers?.resolve(this.data); } show(...e) { if (this.#isShown) { console.warn("Scene is already shown"); return this.data; } this.#isShown = true; this.root.style.transform = "scale(1)"; this.#showPromiseWithResolvers = promiseWithResolvers(); this.update(...e); if (typeof this.options.duration !== "undefined" && this.options.duration < this.app.data.frame_ms) { console.warn("Scene duration is shorter than frame_ms, it will show 1 frame"); } const onFrame = (lastFrameTime) => { const elapsedTime = lastFrameTime - this.data.start_time; if (typeof this.options.duration !== "undefined" && elapsedTime >= this.options.duration - this.app.data.frame_ms * 1.4) { this.close(); return; } this.options.on_frame?.(lastFrameTime); window.requestAnimationFrame(onFrame); }; window.requestAnimationFrame((lastFrameTime) => { this.data.start_time = lastFrameTime; onFrame(lastFrameTime); }); return this.#showPromiseWithResolvers.promise; } } // src/app.ts class App extends DisposableClass { root; data; constructor(root, data) { super(); this.root = root; this.data = data; if (window.getComputedStyle(document.documentElement).getPropertyValue("--psytask") === "") { throw new Error("Please import psytask CSS file in your HTML file"); } this.useEventListener(window, "beforeunload", (e) => { e.preventDefault(); return e.returnValue = "Leaving the page will discard progress. Are you sure?"; }); this.useEventListener(document, "visibilitychange", () => { if (document.visibilityState === "hidden") { alert("Please keep the page visible on the screen during the task running"); } }); } scene(...e) { const scene = new Scene(this, ...e); scene.root.classList.add("psytask-scene"); this.root.appendChild(scene.root); return scene; } text(text, options) { return this.scene(function(self) { const el = h("p", { textContent: text }); self.root.appendChild(h("div", { style: { textAlign: "center", lineHeight: "100dvh" } }, el)); return (props) => { const p = { ...props }; if (p.text) { el.textContent = p.text; } if (p.size) { el.style.fontSize = p.size; } if (p.color) { el.style.color = p.color; } }; }, options); } fixation(options) { return this.text("+", options); } blank(options) { return this.text("", options); } jsPsych(trial) { return createSceneByJsPsychPlugin(this, trial); } } async function createApp(options) { 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); } return new App(opts.root, await detectEnvironment(opts)); } // src/data-collector.ts class DataStringifier { value = ""; } class CSVStringifier extends DataStringifier { keys = []; transform(data) { let chunk = ""; if (this.keys.length === 0) { this.keys = Object.keys(data); chunk = this.keys.reduce((acc, key) => acc + (key.includes(",") ? `"${key}"` : key) + ",", ""); } chunk += this.keys.reduce((acc, key) => { const value = data[key]; return acc + (("" + value).includes(",") ? `"${value}"` : value) + ","; }, ` `); this.value += chunk; return chunk; } final() { return ""; } } class JSONStringifier extends DataStringifier { transform(data) { const chunk = (this.value === "" ? "[" : ",") + JSON.stringify(data); this.value += chunk; return chunk; } final() { this.value += "]"; return "]"; } } class DataCollector extends DisposableClass { filename; static stringifiers = { csv: CSVStringifier, json: JSONStringifier }; rows = []; #saved = false; stringifier; fileStream; constructor(filename = `data-${Date.now()}.csv`, stringifier) { super(); this.filename = filename; const match = filename.match(/\.([^\.]+)$/); const defaultExt = "csv"; const extname = match ? match[1] : (console.warn("Please specify the file extension in the filename"), defaultExt); if (stringifier instanceof DataStringifier) { this.stringifier = stringifier; } else { const extnames = Object.keys(DataCollector.stringifiers); if (extnames.includes(extname)) { this.stringifier = new DataCollector.stringifiers[extname]; } else { console.warn(`Please specify a valid file extension: ${extnames.join(", ")}, but got "${extname}". Or, add your DataStringifier class to DataCollector.stringifiers.`); this.stringifier = new DataCollector.stringifiers[defaultExt]; } } this.useEventListener(document, "visibilitychange", () => { if (document.visibilityState === "hidden" && !this.fileStream) { this.backup(); } }); } async withFileStream(handle) { if (!handle) { return this; } const file = handle.kind === "directory" ? await handle.getFileHandle(this.filename, { create: true }) : handle; if (file.name !== this.filename) { console.warn(`File handle name "${file.name}" does not match the collector filename "${this.filename}".`); } if (await file.queryPermission({ mode: "readwrite" }) !== "granted" && await file.requestPermission({ mode: "readwrite" }) !== "granted") { console.warn("File permission denied, no file stream will be created"); return this; } this.fileStream = await file.createWritable(); return this; } async add(row) { this.rows.push(row); const chunk = this.stringifier.transform(row); await this.fileStream?.write(chunk); return chunk; } backup(suffix = ".backup") { const url = URL.createObjectURL(new Blob([this.stringifier.value], { type: "text/plain" })); const el = h("a", { download: this.filename + suffix, href: url }); document.body.appendChild(el); el.click(); document.body.removeChild(el); URL.revokeObjectURL(url); } async save() { if (this.#saved) { console.warn("Repeated save is not allowed"); return; } const chunk = this.stringifier.final(); if (this.fileStream) { await this.fileStream.write(chunk); await this.fileStream.close(); } else { this.backup(""); } this.#saved = true; } } // src/trial-iterator.ts class TrialIterator { options; #isDone = false; #isUsed = false; constructor(options) { this.options = options; } [Symbol.iterator]() { if (this.#isUsed) { throw new Error("Please create a new trial iterator, it can only be used once."); } return this; } next() { if (this.#isDone) { throw new Error("Unexpected call to next() after the iterator is done"); } this.#isUsed = true; const value = this.nextValue(); if (typeof value === "undefined") { this.#isDone = true; return { value: undefined, done: true }; } return { value, done: false }; } } class ResponsiveTrialIterator extends TrialIterator { } class RandomSampling extends TrialIterator { #count = 0; constructor(options) { super({ candidates: [...options.candidates], sampleSize: options.sampleSize ?? options.candidates.length, replace: options.replace ?? true }); if (this.options.candidates.length === 0) { console.warn("No candidates provided, iterator will not yield any values"); return; } if (!this.options.replace && this.options.sampleSize > this.options.candidates.length) { this.options.sampleSize = this.options.candidates.length; console.warn("Sample size should be <= the number of candidates when not replacing"); } } nextValue() { if (this.options.candidates.length === 0) return; if (this.#count >= this.options.sampleSize) return; this.#count++; const idx = Math.floor(Math.random() * this.options.candidates.length); const target = this.options.candidates[idx]; if (!this.options.replace) { this.options.candidates.splice(idx, 1); } return target; } } class StairCase extends ResponsiveTrialIterator { options; data = []; constructor(options) { super(options); this.options = options; } nextValue() { const n = this.data.length; if (n === 0) { const value2 = this.options.start; this.data.push({ value: value2, response: false, isReversal: false }); return value2; } const nReversals = this.data.filter((e) => e.isReversal).length; if (nReversals >= this.options.reversal) { return; } const { step, down, up, max, min } = this.options; const prev = this.data.at(-1); let value = prev.value; if (nReversals === 0) { value += prev.response ? -step : step; } else { if (n >= down && this.data.slice(-down).every((e) => e.value === prev.value && e.response === true)) { value -= step; } if (n >= up && this.data.slice(-up).every((e) => e.value === prev.value && e.response === false)) { value += step; } } if (typeof min === "number" && value < min) value = min; if (typeof max === "number" && value > max) value = max; this.data.push({ value, response: false, isReversal: false }); return value; } response(value) { if (this.data.length === 0) { console.warn("Please iterate first to get a value"); return; } const curr = this.data.at(-1); curr.response = value; if (this.data.length > 1) { const prev = this.data.at(-2); if (value !== prev.response) { curr.isReversal = true; } } } getThreshold(reversalCount = this.options.reversal) { const allReversalTrials = this.data.filter((e) => e.isReversal); const actualReversalCount = allReversalTrials.length; if (actualReversalCount < reversalCount) { console.warn(`Not enough reversals, only ${actualReversalCount} found, but requested ${reversalCount}`); } const validReversalTrials = allReversalTrials.slice(-reversalCount); const mean = validReversalTrials.reduce((acc, e) => acc + e.value, 0) / validReversalTrials.length; return mean; } } export { h, detectEnvironment, createApp, TrialIterator, StairCase, ResponsiveTrialIterator, RandomSampling, JSONStringifier, DataStringifier, DataCollector, CSVStringifier };