UNPKG

psytask

Version:

JavaScript Framework for Psychology task

1,488 lines (1,469 loc) 47.8 kB
/** * 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 };