psytask
Version:
JavaScript Framework for Psychology task
1,488 lines (1,469 loc) • 47.8 kB
JavaScript
/**
* Psytask v1.1.1
* @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/util.ts
var h = (tagName, props, children) => {
const el = document.createElement(tagName);
if (props != null) {
for (const key of Object.keys(props)) {
if (key === "style") {
for (const k of Object.keys(props.style)) {
el.style.setProperty(k, props.style[k]);
}
continue;
}
if (key === "dataset") {
for (const k of Object.keys(props.dataset)) {
el.dataset[k] = props.dataset[k];
}
continue;
}
el[key] = props[key];
}
}
if (children != null) {
Array.isArray(children) ? el.append(...children) : el.append(children);
}
return el;
};
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function proxyNonKey(obj, onNoKey) {
return new Proxy(obj, {
get(o, k) {
if (hasOwn(o, k))
return o[k];
return onNoKey(k);
}
});
}
var promiseWithResolvers = process.env.NODE_ENV !== "test" && 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 on(target, type, listener, options) {
target.addEventListener(type, listener, options);
return () => target.removeEventListener(type, listener, options);
}
Symbol.dispose ??= Symbol.for("Symbol.dispose");
class EventEmitter {
listeners = {};
[Symbol.dispose]() {
this.emit("cleanup", null);
}
on(type, listener) {
(this.listeners[type] ??= new Set).add(listener);
return this;
}
off(type, listener) {
this.listeners[type]?.delete(listener);
return this;
}
once(type, listener) {
const wrapper = (evt) => {
try {
listener(evt);
} finally {
this.off(type, wrapper);
}
};
this.on(type, wrapper);
return this;
}
emit(type, e) {
const listeners = this.listeners[type];
if (!listeners)
return 0;
for (const listener of listeners)
listener(e);
return listeners.size;
}
}
// src/reactive.ts
var currentEffect = null;
var pendingEffects = new Set;
var track = (map, key) => {
if (currentEffect)
(map[key] ??= new Set).add(currentEffect);
};
var trigger = (map, key) => {
const effects = map[key];
if (!effects || effects.size === 0)
return;
for (const effect of effects)
if (currentEffect !== effect) {
if (pendingEffects.size === 0)
globalThis.queueMicrotask(() => {
for (const effect2 of pendingEffects) {
try {
effect2();
} catch (error) {
console.error("Error in reactive effect:", error);
}
}
pendingEffects.clear();
});
pendingEffects.add(effect);
}
};
var ITER_KEY = Symbol("Object.iterator");
var _reactive = (raw) => {
const map = {};
const proxy = new Proxy(raw, {
_effectMap: map,
get(o, k, receiver) {
track(map, k);
return Reflect.get(o, k, receiver);
},
has(o, k) {
track(map, k);
return Reflect.has(o, k);
},
ownKeys(o) {
track(map, ITER_KEY);
return Reflect.ownKeys(o);
},
set(o, k, v, receiver) {
if (Object.is(o[k], v))
return true;
const r = Reflect.set(o, k, v, receiver);
trigger(map, k);
trigger(map, ITER_KEY);
return r;
},
deleteProperty(o, k) {
const hasKey = hasOwn(o, k);
const r = Reflect.deleteProperty(o, k);
if (r && hasKey) {
trigger(map, k);
trigger(map, ITER_KEY);
}
return r;
}
});
return { proxy, map };
};
var reactive = (raw) => _reactive(raw).proxy;
var effect = (fn) => {
if (process.env.NODE_ENV === "development" && currentEffect) {
throw new Error("Nested effects are not supported.");
}
currentEffect = fn;
try {
currentEffect();
} finally {
currentEffect = null;
}
};
// src/components/form.ts
var init = (inputEl, props, getValue) => {
inputEl.style.width = "100%";
effect(() => inputEl.id = props.id);
effect(() => inputEl.name = props.id);
effect(() => inputEl.required = props.required ?? true);
let cleanup;
effect(() => {
cleanup?.();
if (props.validate) {
const validate = () => {
const result = props.validate(getValue());
inputEl.setCustomValidity(result === true ? "" : result);
};
validate();
cleanup = on(inputEl, "change", validate);
}
});
const labelEl = h("label");
effect(() => labelEl.htmlFor = props.id);
effect(() => {
const { label } = props;
labelEl.replaceChildren(label instanceof HTMLElement ? label : label ?? props.id + (props.required ?? true ? "*" : ""));
});
const root = h("div", {}, [labelEl, " ", inputEl]);
effect(() => props.setup?.(root));
return {
node: root,
data: () => ({ value: getValue() })
};
};
var TextField = function(props) {
const el = h("input");
effect(() => el.type = props.inputType ?? "text");
effect(() => el.value = props.defaultValue ?? "");
return init(el, props, () => el.value);
};
var NumberField = function(props) {
const el = h("input", { type: "number" });
effect(() => el.value = props.defaultValue?.toString() ?? "");
effect(() => el.min = props.min?.toString() ?? "0");
effect(() => el.max = props.max?.toString() ?? "");
effect(() => el.step = props.step?.toString() ?? "");
return init(el, props, () => +el.value);
};
var TextArea = function(props) {
const el = h("textarea", { style: { resize: "vertical" } });
effect(() => el.value = props.defaultValue ?? "");
return init(el, props, () => el.value);
};
var Select = function(props) {
const el = h("select");
effect(() => el.value = props.defaultValue ?? "");
effect(() => {
el.replaceChildren(...props.options.map((opt) => h("option", { value: opt.value, selected: opt.value === props.defaultValue }, opt.label)));
});
return init(el, props, () => el.value);
};
var Checkbox = function(props) {
const el = h("input", {
type: "checkbox",
style: { "margin-right": "0.5rem", width: "auto" }
});
effect(() => el.checked = !!props.defaultValue);
return init(el, props, () => el.checked);
};
var Radio = function(props) {
const el = h("input", { type: "radio" });
effect(() => el.checked = !!props.defaultValue);
return init(el, props, () => el.checked);
};
var fieldMap = { TextField, NumberField, TextArea, Select, Checkbox, Radio };
var Form = function(props, ctx) {
const titleEl = h("h2", { style: { "margin-bottom": "0.5rem" } });
effect(() => titleEl.textContent = props.title ?? "");
const subtitleEl = h("p");
effect(() => subtitleEl.textContent = props.subtitle ?? "");
const submitButton = h("button", {
type: "button",
style: { width: "100%", "margin-top": "1rem" },
onclick() {
formEl.checkValidity() ? ctx.close() : formEl.reportValidity();
}
});
effect(() => submitButton.textContent = props.submitLabel ?? "OK");
const fieldContainer = h("div", {
style: {
display: "grid",
gap: "0.5rem",
"grid-template-columns": "repeat(auto-fit, minmax(min(100%, 8rem), 1fr))"
}
});
const formEl = h("form", {
style: { margin: "2rem", "min-width": "60%" },
onsubmit: (e) => e.preventDefault()
}, [titleEl, subtitleEl, fieldContainer, submitButton]);
let fields;
ctx.on("scene:show", () => fields = []);
effect(() => {
if (!props.fields)
return;
fields = Object.entries(props.fields).reduce((acc, [id, { type, ...p }]) => {
const Comp = fieldMap[type];
if (!Comp)
throw new Error(`Unknown field type: ${type}`);
acc.push({ id, ...Comp({ id, ...p }) });
return acc;
}, []);
fieldContainer.replaceChildren(...fields.map((f) => f.node));
});
return {
node: h("div", {
className: "psytask-center",
style: { "white-space": "pre-line", "line-height": 1.5 }
}, formEl),
data() {
const data = fields.reduce((acc, { id, data: data2 }) => ({ ...acc, [id]: data2().value }), {});
console.info("Form", data);
return data;
}
};
};
// src/components/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/components/jspsych.ts
var jsPsychStim = function(trial, ctx) {
if (process.env.NODE_ENV === "production") {
window["jsPsychModule"] ??= { ParameterType };
}
let data;
const content = h("div", {
id: "jspsych-content",
className: "jspsych-content"
});
effect(() => {
const Plugin = trial.type;
if (typeof Plugin !== "function" || typeof Plugin.prototype === "undefined" || typeof Plugin.info === "undefined") {
throw new Error(`jsPsych trial.type only supports jsPsych class plugins, but got ${Plugin}`);
}
if (process.env.NODE_ENV === "development") {
const unsupportedParams = new Set([
"extensions",
"record_data",
"save_timeline_variables",
"save_trial_parameters",
"simulation_options"
]);
for (const key in trial) {
if (hasOwn(trial, key) && unsupportedParams.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(() => ctx.root),
new TimeoutAPI
].reduce((api, item) => Object.assign(api, import_auto_bind2.default(item)), {});
const mock_jsPsych = {
finishTrial(_data) {
data = Object.assign({}, trial.data, _data);
trial.on_finish?.(data);
if (typeof trial.post_trial_gap === "number") {
window.setTimeout(() => ctx.close(), trial.post_trial_gap);
} else {
ctx.close();
}
},
pluginAPI: process.env.NODE_ENV === "production" ? mock_jsPsychPluginAPI : proxyNonKey(mock_jsPsychPluginAPI, (key) => {
console.warn(`jsPsych.pluginAPI.${key.toString()} is not supported, only supports: ${Object.keys(mock_jsPsychPluginAPI).join(", ")}`);
})
};
trial.on_start?.(trial);
content.className = "jspsych-content";
const classes = trial.css_classes;
if (typeof classes === "string") {
content.classList.add(classes);
} else if (Array.isArray(classes)) {
content.classList.add(...classes);
}
content.innerHTML = "";
const plugin = new Plugin(process.env.NODE_ENV === "production" ? mock_jsPsych : proxyNonKey(mock_jsPsych, (key) => {
console.warn(`jsPsych.${key.toString()} is not supported, only supports: ${Object.keys(mock_jsPsych).join(", ")}`);
}));
plugin.trial(content, trial, () => {
trial.on_load?.();
});
});
return {
node: h("div", {
className: "jspsych-display-element",
style: { height: "100%", width: "100%" }
}, h("div", { className: "jspsych-content-wrapper" }, content)),
data: () => data
};
};
// src/components/visual.ts
var GaussianMask = (sigma) => (x, y) => Math.exp(-(x ** 2 + y ** 2) / (2 * sigma ** 2));
var TextStim = function(props) {
const el = h("div", {
className: "psytask-center",
style: { "white-space": "pre-line", "line-height": 1.5, padding: "2rem" }
});
effect(() => {
const children = props.children ?? "Hello Word";
Array.isArray(children) ? el.replaceChildren(...children) : el.replaceChildren(children);
});
for (const key of Object.keys(props))
if (key !== "children")
effect(() => el.style[key] = props[key]);
return { node: el };
};
var ImageStim = function(props) {
const el = h("canvas");
const ctx = el.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas 2d context");
}
effect(() => {
ctx.clearRect(0, 0, el.width, el.height);
const image = props.image;
if (image) {
[el.width, el.height] = [image.width, image.height];
if (image instanceof ImageData)
ctx.putImageData(image, 0, 0);
else
ctx.drawImage(image, 0, 0);
}
props.draw?.(ctx);
});
return { node: el };
};
var waves = {
sin: Math.sin,
square: (x) => Math.sin(x) >= 0 ? 1 : -1,
triangle: (x) => 2 / Math.PI * Math.asin(Math.sin(x)),
sawtooth: (x) => 2 / Math.PI * (x % (2 * Math.PI) - Math.PI)
};
var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
var Grating = function(props, ctx) {
const image = ctx.use(ImageStim, {});
effect(() => {
const p = { ori: 0, phase: 0, color: [0, 0, 0], ...props };
const [w, h2] = typeof p.size === "number" ? [p.size, p.size] : p.size;
const cosOri = Math.cos(p.ori);
const sinOri = Math.sin(p.ori);
const centerX = w / 2;
const centerY = h2 / 2;
const imageData = new ImageData(w, h2);
for (let y = 0;y < h2; y++) {
for (let x = 0;x < w; x++) {
const dx = x - centerX;
const dy = y - centerY;
const rotatedX = dx * cosOri + dy * sinOri;
const pos = rotatedX * p.sf * 2 * Math.PI + p.phase;
const waveValue = typeof p.type === "string" ? waves[p.type](pos) : p.type(pos);
const intensity = (waveValue + 1) / 2;
const rgba = p.color.length === 2 ? [
...p.color[1].map((c, i) => c + intensity * (p.color[0][i] - c)),
255
] : [...p.color, 255 * intensity];
if (rgba[3] > 0 && p.mask) {
rgba[3] *= p.mask(dx / centerX, dy / centerY);
}
let pixelIndex = (y * w + x) * 4;
for (const value of rgba) {
imageData.data[pixelIndex++] = clamp(Math.round(value), 0, 255);
}
}
}
image.props.image = imageData;
});
return image;
};
var VirtualChinrest = function(props, ctx) {
const p = Object.assign({
i18n: {
confirm: "Use previous chinrest data?",
yes: "Yes",
no: "No",
screen_width: "Screen Width",
line_spacing: "Line Spacing",
distance: "Distance",
SWT_guide: "Move the lower right line and measure the spacing (cm) between the two lines.",
DT_guide: "Close right eye, focus left eye on square, keep head still.",
DT_start: "\uD83D\uDC46\uD83C\uDFFB Click here to start",
DT_stop: "\uD83D\uDC46\uD83C\uDFFB Click again when red circle disappears"
},
blindspotDegree: 13.5
}, props);
const state = reactive({
line_spacing_pix: Math.floor(ctx.app.data.window_wh_pix[0] / 2),
line_spacing_cm: 0,
pix_per_cm: 0,
screen_width_cm: 0,
move_width_pix: 0,
move_widths: [],
distance_cm: 0
});
effect(() => {
state.pix_per_cm = state.line_spacing_pix / state.line_spacing_cm;
});
effect(() => {
state.line_spacing_cm = state.line_spacing_pix / state.pix_per_cm;
});
effect(() => {
state.pix_per_cm = ctx.app.data.screen_wh_pix[0] / state.screen_width_cm;
});
effect(() => {
state.screen_width_cm = ctx.app.data.screen_wh_pix[0] / state.pix_per_cm;
});
effect(() => {
const { move_widths } = state;
const move_width_cm = move_widths.length && move_widths.reduce((a, b) => a + b.cm, 0) / move_widths.length;
state.distance_cm = move_width_cm / 2 / Math.tan(p.blindspotDegree / 2 * (Math.PI / 180));
});
const inputTemplate = (p2) => {
const inp = h("input", {
id: p2.id,
type: "number",
min: "1",
step: "any",
required: true,
onchange(e) {
const val = +e.target.value;
if (!Number.isNaN(val))
state[p2.key] = val;
else
console.warn(`Invalid ${p2.id}:`, e);
}
});
effect(() => {
inp.value = state[p2.key] + "";
});
return h("div", null, [h("label", { htmlFor: p2.id }, p2.label + " "), inp]);
};
const panelTemplate = (p2) => {
return h("form", { style: { margin: "2rem" }, onsubmit: (e) => e.preventDefault() }, [
h("h2", null, p2.title),
p2.content,
inputTemplate(p2),
h("button", {
type: "button",
style: { width: "100%", "margin-top": "0.5rem" },
onclick(e) {
const form = e.target.form;
form.checkValidity() ? p2.onSuccess() : form.reportValidity();
}
}, "OK")
]);
};
const triangleTemplate = (color, direction) => {
const size = 6;
return h("div", {
style: {
position: "absolute",
left: -(size - 0.5) + "px",
width: "0",
height: "0",
"border-style": "solid",
"border-width": direction === "up" ? `0 ${size}px ${size}px ${size}px` : `${size}px ${size}px 0 ${size}px`,
"border-color": direction === "up" ? `transparent transparent ${color} transparent` : `${color} transparent transparent transparent`,
[direction === "up" ? "bottom" : "top"]: -(size - 0.5) + "px"
}
});
};
const crossTemplate = () => {
const sharedStyle = {
position: "absolute",
"background-color": "#000",
"pointer-events": "none"
};
return [
h("div", {
style: { ...sharedStyle, left: "50%", width: "1px", height: "100%" }
}),
h("div", {
style: { ...sharedStyle, top: "50%", width: "100%", height: "1px" }
})
];
};
const screenWidthEL = panelTemplate({
title: "\uD83D\uDC40 " + p.i18n.screen_width,
id: "screen-width-input",
label: p.i18n.screen_width + " (cm):",
key: "screen_width_cm",
content: (() => {
const sharedStyle = {
position: "absolute",
width: "1px",
height: "10rem"
};
const fixedLine = h("div", { style: { background: "#fff", ...sharedStyle, left: 0 } }, [triangleTemplate("#fff", "up"), triangleTemplate("#fff", "down")]);
let isDragging = false;
const movableLine = h("div", {
style: { background: "#f00", ...sharedStyle, cursor: "ew-resize" },
onpointerdown: () => isDragging = true
}, [triangleTemplate("#f00", "up"), triangleTemplate("#f00", "down")]);
effect(() => {
movableLine.style.left = state.line_spacing_pix / ctx.app.data.dpr + "px";
});
ctx.on("pointerup", () => isDragging = false).on("pointermove", (e) => {
if (!isDragging)
return;
const sx = fixedLine.getBoundingClientRect().x;
state.line_spacing_pix = (e.clientX - sx) * ctx.app.data.dpr;
});
const hint = h("p");
effect(() => {
hint.textContent = p.i18n.line_spacing + " (pix): " + state.line_spacing_pix;
});
return h("div", null, [
p.i18n.SWT_guide,
h("div", {
style: {
position: "relative",
margin: "2rem",
height: sharedStyle.height
}
}, [fixedLine, movableLine]),
hint,
inputTemplate({
id: "line-spacing-input",
label: p.i18n.line_spacing + " (cm):",
key: "line_spacing_cm"
})
]);
})(),
onSuccess() {
console.info("screen width:", state.screen_width_cm);
text.props.children = distanceEl;
}
});
const distanceEl = panelTemplate({
title: "\uD83D\uDC40 " + p.i18n.distance,
id: "distance-input",
label: p.i18n.distance + " (cm):",
key: "distance_cm",
content: (() => {
const size = 24;
const sharedStyle = {
position: "absolute",
width: size + "px",
height: size + "px",
"user-select": "none"
};
const guide = h("div", { style: { position: "absolute", top: "100%", width: "max-content" } }, p.i18n.DT_start);
const fixedObj = h("div", {
style: {
...sharedStyle,
right: 0,
"background-color": "#fff",
cursor: "pointer"
},
onpointerup() {
if (!isMoving) {
isMoving = true;
fixedObj.style.cursor = "progress";
guide.textContent = p.i18n.DT_stop;
return;
}
isMoving = false;
fixedObj.style.cursor = "pointer";
guide.textContent = p.i18n.DT_start;
const pix = state.move_width_pix;
state.move_width_pix = 0;
state.move_widths = [
...state.move_widths,
{ pix, cm: pix / state.pix_per_cm }
];
}
}, [...crossTemplate(), guide]);
let isMoving = false;
const movableObj = h("div", {
style: {
...sharedStyle,
"background-color": "#f00",
"border-radius": "50%"
}
}, crossTemplate());
effect(() => {
movableObj.style.right = size + state.move_width_pix * ctx.app.data.dpr + "px";
});
ctx.on("scene:frame", () => {
if (isMoving)
state.move_width_pix += 1;
});
const dots = h("div");
const dotSize = 4;
effect(() => {
dots.replaceChildren(...state.move_widths.map(({ pix, cm }) => h("span", {
title: cm + " cm",
style: {
position: "absolute",
top: (size - dotSize) / 2 + "px",
right: size + pix * ctx.app.data.dpr + "px",
"background-color": "#fff5",
width: dotSize + "px",
height: dotSize + "px",
"border-radius": "50%"
}
})));
});
return h("div", null, [
p.i18n.DT_guide,
h("div", {
style: {
position: "relative",
margin: "2rem",
height: size + "px"
}
}, [dots, fixedObj, movableObj])
]);
})(),
onSuccess() {
const { screen_width_cm, distance_cm } = state;
localStorage.setItem(sKey, JSON.stringify({ screen_width_cm, distance_cm }, null, 2));
ctx.close();
}
});
const sKey = "psytask:VirtualChinrest:store";
const text = ctx.use(TextStim, { children: "" });
effect(() => {
const { usePreviousData } = props;
const sValue = localStorage.getItem(sKey);
if (!sValue || usePreviousData === false) {
text.props.children = screenWidthEL;
return;
}
if (usePreviousData === true) {
Object.assign(state, JSON.parse(sValue));
ctx.on("scene:show", () => ctx.close());
return;
}
text.props.children = [
h("h3", null, p.i18n.confirm),
h("pre", null, sValue),
h("div", {
style: {
display: "grid",
"grid-template-columns": "1fr 1fr",
gap: "1rem"
}
}, [
{
style: { width: "5rem" },
textContent: p.i18n.yes,
onclick() {
Object.assign(state, JSON.parse(sValue));
ctx.close();
}
},
{
textContent: p.i18n.no,
onclick: () => text.props.children = screenWidthEL
}
].map((p2) => h("button", p2)))
];
});
return {
node: text.node,
data() {
console.info("VirtualChinrest", { ...state });
const { pix_per_cm, distance_cm } = state;
const deg2cm = (deg) => 2 * distance_cm * Math.tan(deg / 2 * (Math.PI / 180));
const deg2pix = (deg) => deg2cm(deg) * pix_per_cm;
return {
pix_per_cm,
distance_cm,
deg2cm,
deg2pix,
deg2csspix: (deg) => deg2pix(deg) / ctx.app.data.dpr
};
}
};
};
// src/data-collector.ts
class DataStringifier {
value = "";
}
class CSVStringifier extends DataStringifier {
keys = [];
normalize(value) {
if (value == null)
return "";
value = "" + value;
return /[,"\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
}
transform(data) {
let chunk = "";
let len = this.keys.length;
if (len === 0) {
this.keys = Object.keys(data);
len = this.keys.length;
chunk = this.keys.reduce((acc, key, i) => acc + this.normalize(key) + (i < len - 1 ? "," : ""), "");
}
chunk += this.keys.reduce((acc, key, i) => acc + this.normalize(data[key]) + (i < len - 1 ? "," : ""), `
`);
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() {
const chunk = this.value === "" ? "[]" : "]";
this.value += chunk;
return chunk;
}
}
class DataCollector extends EventEmitter {
filename;
static stringifiers = {
csv: CSVStringifier,
json: JSONStringifier
};
#saved = false;
rows = [];
stringifier;
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.on("cleanup", on(document, "visibilitychange", () => {
if (document.visibilityState === "hidden")
this.download(`-${Date.now()}.backup`);
})).on("cleanup", () => this.save());
}
add(row) {
console.info("data", row);
this.rows.push(row);
const chunk = this.stringifier.transform(row);
this.emit("add", { row, chunk });
return chunk;
}
save() {
if (this.#saved) {
console.warn("Repeated save is not allowed");
return;
}
this.#saved = true;
const chunk = this.stringifier.final();
let hasPrevented = false;
this.emit("save", {
chunk,
preventDefault: () => hasPrevented = true
});
if (!hasPrevented)
this.download();
}
download(suffix = "") {
if (this.rows.length === 0)
return;
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();
URL.revokeObjectURL(url);
document.body.removeChild(el);
}
}
// src/scene.ts
var createShowInfo = () => ({ start_time: 0, frame_times: [] });
var buttonTypeMap = ["mouse:left", "mouse:middle", "mouse:right"];
var setup2show = (f) => f;
class Scene extends EventEmitter {
app;
defaultOptions;
root = h("div", {
className: "psytask-scene",
tabIndex: -1,
oncontextmenu: (e) => e.preventDefault(),
style: { transform: "scale(0)" }
});
props;
data;
show = this.#show;
options;
#showPromiseWithResolvers = null;
constructor(app, setup, defaultOptions) {
super();
this.app = app;
this.defaultOptions = defaultOptions;
this.options = defaultOptions;
const { node, data, props } = this.use(setup, {
...defaultOptions.defaultProps
});
this.data = data;
this.props = props;
Array.isArray(node) ? this.root.append(...node) : this.root.append(node);
app.root.appendChild(this.root);
this.on("cleanup", () => app.root.removeChild(this.root));
}
use(setup, defaultProps) {
const props = reactive(defaultProps);
return { ...setup(props, this), props };
}
config(patchOptions) {
this.options = { ...this.defaultOptions, ...patchOptions };
return this;
}
close() {
if (!this.#showPromiseWithResolvers) {
throw new Error("Scene hasn't been shown");
}
this.root.style.transform = "scale(0)";
this.#showPromiseWithResolvers.resolve(null);
}
async#show(patchProps) {
if (this.#showPromiseWithResolvers) {
throw new Error("Scene has been shown");
}
this.root.focus();
this.root.style.transform = "scale(1)";
this.#showPromiseWithResolvers = promiseWithResolvers();
const { defaultProps, duration, close_on, frame_times } = this.options;
Object.assign(this.props, defaultProps, patchProps);
if (process.env.NODE_ENV === "development") {
window["s"] = this;
}
this.emit("scene:show", null);
if (typeof close_on !== "undefined") {
const close_ons = Array.isArray(close_on) ? close_on : [close_on];
const close = () => this.close();
for (const close_on2 of close_ons) {
this.on(close_on2, close);
this.once("scene:close", () => this.off(close_on2, close));
}
}
const eventTypes = Object.keys(this.listeners);
const hasSpecialType = [false, false];
for (const type of eventTypes) {
if (!hasSpecialType[0] && type.startsWith("mouse:")) {
hasSpecialType[0] = true;
this.once("scene:close", on(this.root, "mousedown", (e) => this.emit(buttonTypeMap[e.button] ?? "mouse:unknown", e)));
continue;
}
if (!hasSpecialType[1] && type.startsWith("key:")) {
hasSpecialType[1] = true;
this.once("scene:close", on(this.root, "keydown", (e) => this.emit(`key:${e.key}`, e)));
continue;
}
if (!type.startsWith("scene:")) {
this.once("scene:close", on(this.root, type, (e) => this.emit(type, e)));
}
}
const frame_ms = this.app.data.frame_ms;
if (process.env.NODE_ENV === "development" && typeof duration !== "undefined") {
const theoreticalDuration = Math.round(duration / frame_ms) * frame_ms;
const error = theoreticalDuration - duration;
if (Math.abs(error) >= 1) {
console.warn(`Scene duration is not a multiple of frame_ms, theoretical duration is ${theoreticalDuration} ms, but got ${duration} ms (error: ${error} ms)`);
}
}
let rAFid;
const showInfo = createShowInfo();
const onFrame = (lastFrameTime) => {
frame_times && showInfo.frame_times.push(lastFrameTime);
if (typeof duration !== "undefined" && lastFrameTime - showInfo.start_time >= duration - frame_ms * 1.5) {
this.close();
return;
}
this.emit("scene:frame", { lastFrameTime });
rAFid = window.requestAnimationFrame(onFrame);
};
rAFid = window.requestAnimationFrame((lastFrameTime) => {
showInfo.start_time = lastFrameTime;
onFrame(lastFrameTime);
});
await this.#showPromiseWithResolvers.promise;
this.emit("scene:close", null);
window.cancelAnimationFrame(rAFid);
this.options = this.defaultOptions;
this.#showPromiseWithResolvers = null;
return Object.assign(this.data?.() ?? {}, showInfo);
}
}
// src/app.ts
class App extends EventEmitter {
root;
data = {
frame_ms: 16.67,
leave_count: 0,
dpr: window.devicePixelRatio,
screen_wh_pix: [window.screen.width, window.screen.height],
window_wh_pix: [window.innerWidth, window.innerHeight]
};
constructor(root) {
super();
this.root = root;
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]
});
});
if (window.getComputedStyle(this.root).getPropertyValue("--psytask") === "") {
throw new Error("Please import psytask CSS file in your HTML file");
}
this.on("cleanup", on(window, "beforeunload", (e) => {
e.preventDefault();
return e.returnValue = "Leaving the page will discard progress. Are you sure?";
})).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"));
}
})).on("cleanup", (() => {
let cleanup;
effect(() => {
cleanup?.();
cleanup = on(window.matchMedia(`(resolution: ${this.data.dpr}dppx)`), "change", () => this.data.dpr = window.devicePixelRatio);
});
return () => cleanup();
})()).on("cleanup", on(window, "resize", () => {
const dpr = this.data.dpr;
this.data.window_wh_pix = [
window.innerWidth * dpr,
window.innerHeight * dpr
];
})).on("cleanup", () => {
this.root.appendChild(h("div", { className: "psytask-center" }, "Thanks for participating!"));
});
}
async load(urls, convertor) {
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");
}
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;
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}`);
await new Promise(() => {});
}
});
const datas = await Promise.all(tasks);
this.root.removeChild(container);
return datas;
}
collector(...e) {
return new DataCollector(...e);
}
scene(...e) {
return new Scene(this, ...e);
}
text(content, defaultOptions) {
return this.scene(TextStim, {
defaultProps: { children: content, ...defaultOptions?.defaultProps },
...defaultOptions
});
}
}
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 = 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);
});
});
}
var createApp = async (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);
}
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;
};
// 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 {
reactive,
on,
jsPsychStim,
h,
setup2show as generic,
effect,
createApp,
VirtualChinrest,
TrialIterator,
TextStim,
TextField,
TextArea,
StairCase,
Select,
ResponsiveTrialIterator,
RandomSampling,
Radio,
NumberField,
JSONStringifier,
ImageStim,
Grating,
GaussianMask,
Form,
DataStringifier,
DataCollector,
Checkbox,
CSVStringifier
};