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