psytask
Version:
JavaScript Framework for Psychology task
803 lines (785 loc) • 25.8 kB
JavaScript
/**
* 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
};